@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/migrate.js CHANGED
@@ -59,9 +59,10 @@ function resolveServerConfig(env = process.env) {
59
59
  artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
60
60
  artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
61
61
  inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
62
- bundleSigningKey: env.HASNA_SKILLS_SIGNING_KEY || undefined,
62
+ bundleSigningKey: env.HASNA_SKILLS_API_SIGNING_KEY || env.HASNA_SKILLS_SIGNING_KEY || undefined,
63
63
  requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
64
64
  skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
65
+ tombstoneWindowMs: parsePositiveInt(env.HASNA_SKILLS_TOMBSTONE_WINDOW_MS, 7 * 24 * 60 * 60 * 1000),
65
66
  publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
66
67
  nodeEnv,
67
68
  allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
@@ -274,6 +275,7 @@ var ANY_SEGMENT_EXCLUDES = new Set([
274
275
  ".docker"
275
276
  ]);
276
277
  var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
278
+ var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json"]);
277
279
  var CREDENTIAL_FILENAMES = new Set([
278
280
  ".npmrc",
279
281
  ".pypirc",
@@ -466,7 +468,20 @@ function rowToSkill(row) {
466
468
  ...row.bundle_byte_size === null || row.bundle_byte_size === undefined ? {} : { bundleByteSize: Number(row.bundle_byte_size) },
467
469
  ...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
468
470
  createdAt: dateString(row.created_at),
469
- updatedAt: dateString(row.updated_at)
471
+ updatedAt: dateString(row.updated_at),
472
+ revisionId: String(row.revision_id ?? ""),
473
+ revisionNumber: Number(row.revision_number ?? 0),
474
+ ...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
475
+ ...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
476
+ };
477
+ }
478
+ function rowToPin(row) {
479
+ return {
480
+ orgId: String(row.org_id),
481
+ principal: String(row.principal),
482
+ slug: String(row.slug),
483
+ pinnedAt: dateString(row.pinned_at),
484
+ metadata: parseJsonObject(row.metadata_json)
470
485
  };
471
486
  }
472
487
  function rowToSkillBundle(row) {
@@ -534,9 +549,45 @@ class StaleLeaseGenerationError extends Error {
534
549
  }
535
550
  }
536
551
 
552
+ class SkillRevisionConflictError extends Error {
553
+ slug;
554
+ expectedRevisionId;
555
+ currentRevisionId;
556
+ constructor(slug, expectedRevisionId, currentRevisionId) {
557
+ super(`revision conflict for '${slug}': expected revision ${expectedRevisionId ?? "(none)"}, ` + `current is ${currentRevisionId ?? "(none)"}. Refused rather than silently overwriting a newer revision.`);
558
+ this.name = "SkillRevisionConflictError";
559
+ this.slug = slug;
560
+ this.expectedRevisionId = expectedRevisionId;
561
+ this.currentRevisionId = currentRevisionId;
562
+ }
563
+ }
564
+
565
+ // src/lib/revision.ts
566
+ import { createHash as createHash2 } from "crypto";
567
+ function revisionIdOf(content) {
568
+ const canonical = JSON.stringify({
569
+ slug: content.slug,
570
+ displayName: content.displayName,
571
+ description: content.description,
572
+ category: content.category,
573
+ tags: content.tags,
574
+ source: content.source,
575
+ kind: content.kind,
576
+ version: content.version ?? null,
577
+ skillMd: content.skillMd ?? null,
578
+ bundleSha256: content.bundleSha256 ?? null,
579
+ bundleByteSize: content.bundleByteSize ?? null
580
+ });
581
+ return createHash2("sha256").update(canonical).digest("hex");
582
+ }
583
+ function revisionIdOfRecord(record) {
584
+ return revisionIdOf(record);
585
+ }
586
+
537
587
  // src/server/sqlite-store.ts
538
588
  var CLAIM_ATTEMPTS = 8;
539
589
  var CLAIMABLE_STATUSES = ["queued", "retrying"];
590
+ var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
540
591
  var LAST_USED_RESOLUTION_MS = 60000;
541
592
 
542
593
  class SqliteSkillsStore {
@@ -556,12 +607,24 @@ class SqliteSkillsStore {
556
607
  if (options.migrate !== false) {
557
608
  applySqliteMigrations(this.db, options.migrationsDir);
558
609
  }
610
+ this.backfillLegacyRevisions();
559
611
  this.backend = {
560
612
  kind: "sqlite",
561
613
  durable: !inMemory,
562
614
  label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
563
615
  };
564
616
  }
617
+ backfillLegacyRevisions() {
618
+ const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
619
+ for (const row of rows) {
620
+ const record = rowToSkill(row);
621
+ this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
622
+ revisionIdOfRecord(record),
623
+ record.orgId,
624
+ record.slug
625
+ ]);
626
+ }
627
+ }
565
628
  get database() {
566
629
  return this.db;
567
630
  }
@@ -785,8 +848,29 @@ class SqliteSkillsStore {
785
848
  const orgId = input.principal.orgId;
786
849
  const now = nowIso();
787
850
  return this.db.transaction(() => {
788
- const previous = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
851
+ 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]);
789
852
  const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
853
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
854
+ const tombstoned = previous?.tombstoned_at != null;
855
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
856
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
857
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
858
+ }
859
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
860
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
861
+ const revisionId = revisionIdOfRecord({
862
+ slug: input.slug,
863
+ displayName: input.displayName,
864
+ description: input.description,
865
+ category: input.category,
866
+ tags: input.tags,
867
+ source: input.source,
868
+ kind: input.kind,
869
+ ...input.version ? { version: input.version } : {},
870
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
871
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
872
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
873
+ });
790
874
  if (input.bundle) {
791
875
  this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
792
876
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -807,8 +891,8 @@ class SqliteSkillsStore {
807
891
  ]);
808
892
  }
809
893
  const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
810
- bundle_sha256, bundle_byte_size, published_by_user_id, created_at, updated_at)
811
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
894
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
895
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
812
896
  ON CONFLICT (org_id, slug) DO UPDATE SET
813
897
  display_name = excluded.display_name,
814
898
  description = excluded.description,
@@ -826,7 +910,16 @@ class SqliteSkillsStore {
826
910
  bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
827
911
  bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
828
912
  published_by_user_id = excluded.published_by_user_id,
913
+ revision_id = excluded.revision_id,
914
+ -- Current + 1, not the inserted 1: on the update path the ACTUAL row's
915
+ -- counter is the truth (it may have advanced since the pre-read, which is
916
+ -- exactly what the WHERE guard below detects).
917
+ revision_number = skills_registry.revision_number + 1,
918
+ tombstoned_at = NULL,
919
+ tombstone_purge_after = NULL,
829
920
  updated_at = excluded.updated_at
921
+ WHERE skills_registry.tombstoned_at IS NOT NULL
922
+ OR skills_registry.revision_id = ?
830
923
  RETURNING *`, [
831
924
  orgId,
832
925
  input.slug,
@@ -837,62 +930,165 @@ class SqliteSkillsStore {
837
930
  input.source,
838
931
  input.kind,
839
932
  input.version ?? null,
840
- input.skillMd ?? null,
933
+ carriedSkillMd,
841
934
  input.bundle?.sha256 ?? null,
842
935
  input.bundle?.byteSize ?? null,
843
936
  input.principal.userId,
937
+ revisionId,
938
+ now,
844
939
  now,
845
- now
940
+ input.expectedRevisionId ?? NO_REVISION_SENTINEL
846
941
  ]);
942
+ if (!row) {
943
+ const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
944
+ const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
945
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
946
+ }
847
947
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
848
948
  this.collectOrphanBundle(orgId, previousSha);
949
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
950
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
951
+ for (const tag of input.tags) {
952
+ if (!tag.trim())
953
+ continue;
954
+ insertTag.run(orgId, input.slug, tag);
955
+ }
849
956
  return rowToSkill(row);
850
957
  })();
851
958
  }
852
959
  async listSkills(principal) {
853
- return this.all("SELECT * FROM skills_registry WHERE org_id = ? ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
960
+ await this.purgeExpiredTombstones(principal);
961
+ return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
854
962
  }
855
963
  async getSkill(principal, slug) {
856
964
  const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
857
965
  return row ? rowToSkill(row) : null;
858
966
  }
859
- async updateSkill(principal, slug, patch) {
967
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
860
968
  const current = await this.getSkill(principal, slug);
861
- if (!current)
969
+ if (!current || current.tombstonedAt)
862
970
  return null;
971
+ if (expectedRevisionId !== current.revisionId) {
972
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
973
+ }
863
974
  const next = { ...current, ...patch };
864
- const row = this.get(`UPDATE skills_registry
865
- SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?, updated_at = ?
866
- WHERE org_id = ? AND slug = ?
867
- RETURNING *`, [
868
- next.displayName,
869
- next.description,
870
- next.category,
871
- JSON.stringify(next.tags),
872
- next.kind,
873
- next.version ?? null,
874
- next.skillMd ?? null,
875
- nowIso(),
876
- principal.orgId,
877
- slug
878
- ]);
879
- return row ? rowToSkill(row) : null;
975
+ return this.db.transaction(() => {
976
+ const revisionId = revisionIdOfRecord(next);
977
+ const row = this.get(`UPDATE skills_registry
978
+ SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
979
+ revision_id = ?, revision_number = revision_number + 1, updated_at = ?
980
+ WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
981
+ RETURNING *`, [
982
+ next.displayName,
983
+ next.description,
984
+ next.category,
985
+ JSON.stringify(next.tags),
986
+ next.kind,
987
+ next.version ?? null,
988
+ next.skillMd ?? null,
989
+ revisionId,
990
+ nowIso(),
991
+ principal.orgId,
992
+ slug,
993
+ current.revisionId
994
+ ]);
995
+ if (!row) {
996
+ const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
997
+ principal.orgId,
998
+ slug
999
+ ]);
1000
+ if (nowRow && nowRow.tombstoned_at == null) {
1001
+ const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
1002
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
1003
+ }
1004
+ return null;
1005
+ }
1006
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
1007
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
1008
+ for (const tag of next.tags) {
1009
+ if (!tag.trim())
1010
+ continue;
1011
+ insertTag.run(principal.orgId, slug, tag);
1012
+ }
1013
+ return rowToSkill(row);
1014
+ })();
880
1015
  }
881
- async deleteSkill(principal, slug) {
1016
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
882
1017
  return this.db.transaction(() => {
883
- const existing = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
1018
+ const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
884
1019
  if (!existing)
885
- return false;
886
- this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
887
- if (typeof existing.bundle_sha256 === "string")
888
- this.collectOrphanBundle(principal.orgId, existing.bundle_sha256);
889
- return true;
1020
+ return null;
1021
+ if (existing.tombstoned_at != null) {
1022
+ const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
1023
+ return rowToSkill(row2);
1024
+ }
1025
+ const tombstonedAt = nowIso();
1026
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
1027
+ const row = this.get(`UPDATE skills_registry
1028
+ SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
1029
+ WHERE org_id = ? AND slug = ?
1030
+ RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
1031
+ return rowToSkill(row);
1032
+ })();
1033
+ }
1034
+ async purgeExpiredTombstones(principal) {
1035
+ return this.db.transaction(() => {
1036
+ const now = nowIso();
1037
+ const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
1038
+ const purged = [];
1039
+ for (const row of expired) {
1040
+ const record = rowToSkill(row);
1041
+ this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
1042
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
1043
+ if (record.bundleSha256)
1044
+ this.collectOrphanBundle(principal.orgId, record.bundleSha256);
1045
+ purged.push(record);
1046
+ }
1047
+ return purged;
890
1048
  })();
891
1049
  }
892
1050
  async getSkillBundle(principal, sha256) {
893
1051
  const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
894
1052
  return row ? rowToSkillBundle(row) : null;
895
1053
  }
1054
+ async pinSkill(principal, slug, metadata = {}) {
1055
+ const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
1056
+ VALUES (?, ?, ?, ?, ?)
1057
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
1058
+ pinned_at = excluded.pinned_at,
1059
+ metadata_json = excluded.metadata_json
1060
+ RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
1061
+ return rowToPin(row);
1062
+ }
1063
+ async unpinSkill(principal, slug) {
1064
+ const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
1065
+ return result.changes > 0;
1066
+ }
1067
+ async listPins(principal) {
1068
+ return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
1069
+ }
1070
+ async listTags(principal) {
1071
+ await this.purgeExpiredTombstones(principal);
1072
+ return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
1073
+ }
1074
+ async listSkillsByTag(principal, tag) {
1075
+ await this.purgeExpiredTombstones(principal);
1076
+ return this.all(`SELECT s.* FROM skills_registry s
1077
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
1078
+ WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
1079
+ ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
1080
+ }
1081
+ async listPinsByTag(principal, tag) {
1082
+ await this.purgeExpiredTombstones(principal);
1083
+ return this.all(`SELECT p.* FROM skills_pins p
1084
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
1085
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
1086
+ WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
1087
+ ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
1088
+ }
1089
+ async listPublishedSlugs(principal) {
1090
+ 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));
1091
+ }
896
1092
  collectOrphanBundle(orgId, sha256) {
897
1093
  const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
898
1094
  if (referenced)