Skip to content

Commit 0cc0bdc

Browse files
authored
feat: SoulHub registry + auto-seed
SoulHub SOUL.md registry (souls table, versions, search, OG) + first-run auto-seed; fixes seed concurrency and GitHub backup owner handle.
1 parent cc0027a commit 0cc0bdc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+4841
-340
lines changed

.env.local.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Frontend
22
VITE_CONVEX_URL=
33
VITE_CONVEX_SITE_URL=
4+
VITE_SOULHUB_SITE_URL=
5+
VITE_SOULHUB_HOST=
6+
VITE_SITE_MODE=
47
SITE_URL=http://localhost:3000
58
CONVEX_SITE_URL=
69

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Web: dynamic OG image cards for skills (name, description, version).
77
- CLI: auto-scan Clawdbot skill roots (per-agent workspaces, shared skills, extraDirs).
88
- Web: import skills from public GitHub URLs (auto-detect `SKILL.md`, smart file selection, provenance).
9+
- Web/API: SoulHub (SOUL.md registry) with v1 endpoints and first-run auto-seed.
910

1011
### Fixed
1112
- Web: stabilize skill OG image generation on server runtimes.

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,26 @@
99
ClawdHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
1010
It’s designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
1111

12+
onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same way you publish skills.
13+
1214
Live: `https://clawdhub.com`
15+
onlycrabs.ai: `https://onlycrabs.ai`
1316

1417
## What you can do
1518

1619
- Browse skills + render their `SKILL.md`.
17-
- Publish new versions with changelogs + tags (including `latest`).
20+
- Publish new skill versions with changelogs + tags (including `latest`).
21+
- Browse souls + render their `SOUL.md`.
22+
- Publish new soul versions with changelogs + tags.
1823
- Search via embeddings (vector index) instead of brittle keywords.
19-
- Star + comment; admins/mods can curate and approve.
24+
- Star + comment; admins/mods can curate and approve skills.
25+
26+
## onlycrabs.ai (SOUL.md registry)
27+
28+
- Entry point is host-based: `onlycrabs.ai`.
29+
- On the onlycrabs.ai host, the home page and nav default to souls.
30+
- On ClawdHub, souls live under `/souls`.
31+
- Soul bundles only accept `SOUL.md` for now (no extra files).
2032

2133
## How it works (high level)
2234

@@ -72,6 +84,9 @@ This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for y
7284

7385
- `VITE_CONVEX_URL`: Convex deployment URL (`https://<deployment>.convex.cloud`).
7486
- `VITE_CONVEX_SITE_URL`: Convex site URL (`https://<deployment>.convex.site`).
87+
- `VITE_SOULHUB_SITE_URL`: onlycrabs.ai site URL (`https://onlycrabs.ai`).
88+
- `VITE_SOULHUB_HOST`: onlycrabs.ai host match (`onlycrabs.ai`).
89+
- `VITE_SITE_MODE`: Optional override (`skills` or `souls`) for SSR builds.
7590
- `CONVEX_SITE_URL`: same as `VITE_CONVEX_SITE_URL` (auth + cookies).
7691
- `SITE_URL`: App URL (local: `http://localhost:3000`).
7792
- `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET`: GitHub OAuth App.

convex/_generated/api.d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type * as downloads from "../downloads.js";
1515
import type * as githubBackups from "../githubBackups.js";
1616
import type * as githubBackupsNode from "../githubBackupsNode.js";
1717
import type * as githubImport from "../githubImport.js";
18+
import type * as githubSoulBackups from "../githubSoulBackups.js";
19+
import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
1820
import type * as http from "../http.js";
1921
import type * as httpApi from "../httpApi.js";
2022
import type * as httpApiV1 from "../httpApiV1.js";
@@ -24,15 +26,23 @@ import type * as lib_changelog from "../lib/changelog.js";
2426
import type * as lib_embeddings from "../lib/embeddings.js";
2527
import type * as lib_githubBackup from "../lib/githubBackup.js";
2628
import type * as lib_githubImport from "../lib/githubImport.js";
29+
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
2730
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
2831
import type * as lib_skillPublish from "../lib/skillPublish.js";
2932
import type * as lib_skills from "../lib/skills.js";
33+
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
34+
import type * as lib_soulPublish from "../lib/soulPublish.js";
3035
import type * as lib_tokens from "../lib/tokens.js";
3136
import type * as lib_webhooks from "../lib/webhooks.js";
3237
import type * as maintenance from "../maintenance.js";
3338
import type * as rateLimits from "../rateLimits.js";
3439
import type * as search from "../search.js";
40+
import type * as seed from "../seed.js";
3541
import type * as skills from "../skills.js";
42+
import type * as soulComments from "../soulComments.js";
43+
import type * as soulDownloads from "../soulDownloads.js";
44+
import type * as soulStars from "../soulStars.js";
45+
import type * as souls from "../souls.js";
3646
import type * as stars from "../stars.js";
3747
import type * as telemetry from "../telemetry.js";
3848
import type * as tokens from "../tokens.js";
@@ -54,6 +64,8 @@ declare const fullApi: ApiFromModules<{
5464
githubBackups: typeof githubBackups;
5565
githubBackupsNode: typeof githubBackupsNode;
5666
githubImport: typeof githubImport;
67+
githubSoulBackups: typeof githubSoulBackups;
68+
githubSoulBackupsNode: typeof githubSoulBackupsNode;
5769
http: typeof http;
5870
httpApi: typeof httpApi;
5971
httpApiV1: typeof httpApiV1;
@@ -63,15 +75,23 @@ declare const fullApi: ApiFromModules<{
6375
"lib/embeddings": typeof lib_embeddings;
6476
"lib/githubBackup": typeof lib_githubBackup;
6577
"lib/githubImport": typeof lib_githubImport;
78+
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
6679
"lib/skillBackfill": typeof lib_skillBackfill;
6780
"lib/skillPublish": typeof lib_skillPublish;
6881
"lib/skills": typeof lib_skills;
82+
"lib/soulChangelog": typeof lib_soulChangelog;
83+
"lib/soulPublish": typeof lib_soulPublish;
6984
"lib/tokens": typeof lib_tokens;
7085
"lib/webhooks": typeof lib_webhooks;
7186
maintenance: typeof maintenance;
7287
rateLimits: typeof rateLimits;
7388
search: typeof search;
89+
seed: typeof seed;
7490
skills: typeof skills;
91+
soulComments: typeof soulComments;
92+
soulDownloads: typeof soulDownloads;
93+
soulStars: typeof soulStars;
94+
souls: typeof souls;
7595
stars: typeof stars;
7696
telemetry: typeof telemetry;
7797
tokens: typeof tokens;

convex/githubSoulBackups.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { v } from 'convex/values'
2+
import { internal } from './_generated/api'
3+
import type { Doc, Id } from './_generated/dataModel'
4+
import { action, internalMutation, internalQuery } from './_generated/server'
5+
import { assertRole, requireUserFromAction } from './lib/access'
6+
7+
const DEFAULT_BATCH_SIZE = 50
8+
const MAX_BATCH_SIZE = 200
9+
const SYNC_STATE_KEY = 'souls'
10+
11+
type BackupPageItem =
12+
| {
13+
kind: 'ok'
14+
soulId: Id<'souls'>
15+
versionId: Id<'soulVersions'>
16+
slug: string
17+
displayName: string
18+
version: string
19+
ownerHandle: string
20+
files: Doc<'soulVersions'>['files']
21+
publishedAt: number
22+
}
23+
| { kind: 'missingLatestVersion'; soulId: Id<'souls'> }
24+
| { kind: 'missingVersionDoc'; soulId: Id<'souls'>; versionId: Id<'soulVersions'> }
25+
| { kind: 'missingOwner'; soulId: Id<'souls'>; ownerUserId: Id<'users'> }
26+
27+
type BackupPageResult = {
28+
items: BackupPageItem[]
29+
cursor: string | null
30+
isDone: boolean
31+
}
32+
33+
type BackupSyncState = {
34+
cursor: string | null
35+
}
36+
37+
export type SyncGitHubSoulBackupsResult = {
38+
stats: {
39+
soulsScanned: number
40+
soulsSkipped: number
41+
soulsBackedUp: number
42+
soulsMissingVersion: number
43+
soulsMissingOwner: number
44+
errors: number
45+
}
46+
cursor: string | null
47+
isDone: boolean
48+
}
49+
50+
export const getGitHubSoulBackupPageInternal = internalQuery({
51+
args: {
52+
cursor: v.optional(v.string()),
53+
batchSize: v.optional(v.number()),
54+
},
55+
handler: async (ctx, args): Promise<BackupPageResult> => {
56+
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
57+
const { page, isDone, continueCursor } = await ctx.db
58+
.query('souls')
59+
.order('asc')
60+
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
61+
62+
const items: BackupPageItem[] = []
63+
for (const soul of page) {
64+
if (soul.softDeletedAt) continue
65+
if (!soul.latestVersionId) {
66+
items.push({ kind: 'missingLatestVersion', soulId: soul._id })
67+
continue
68+
}
69+
70+
const version = await ctx.db.get(soul.latestVersionId)
71+
if (!version) {
72+
items.push({
73+
kind: 'missingVersionDoc',
74+
soulId: soul._id,
75+
versionId: soul.latestVersionId,
76+
})
77+
continue
78+
}
79+
80+
const owner = await ctx.db.get(soul.ownerUserId)
81+
if (!owner || owner.deletedAt) {
82+
items.push({ kind: 'missingOwner', soulId: soul._id, ownerUserId: soul.ownerUserId })
83+
continue
84+
}
85+
86+
items.push({
87+
kind: 'ok',
88+
soulId: soul._id,
89+
versionId: version._id,
90+
slug: soul.slug,
91+
displayName: soul.displayName,
92+
version: version.version,
93+
ownerHandle: owner.handle ?? owner._id,
94+
files: version.files,
95+
publishedAt: version.createdAt,
96+
})
97+
}
98+
99+
return { items, cursor: continueCursor, isDone }
100+
},
101+
})
102+
103+
export const getGitHubSoulBackupSyncStateInternal = internalQuery({
104+
args: {},
105+
handler: async (ctx): Promise<BackupSyncState> => {
106+
const state = await ctx.db
107+
.query('githubBackupSyncState')
108+
.withIndex('by_key', (q) => q.eq('key', SYNC_STATE_KEY))
109+
.unique()
110+
return { cursor: state?.cursor ?? null }
111+
},
112+
})
113+
114+
export const setGitHubSoulBackupSyncStateInternal = internalMutation({
115+
args: {
116+
cursor: v.optional(v.string()),
117+
},
118+
handler: async (ctx, args) => {
119+
const now = Date.now()
120+
const state = await ctx.db
121+
.query('githubBackupSyncState')
122+
.withIndex('by_key', (q) => q.eq('key', SYNC_STATE_KEY))
123+
.unique()
124+
125+
if (!state) {
126+
await ctx.db.insert('githubBackupSyncState', {
127+
key: SYNC_STATE_KEY,
128+
cursor: args.cursor,
129+
updatedAt: now,
130+
})
131+
return { ok: true as const }
132+
}
133+
134+
await ctx.db.patch(state._id, {
135+
cursor: args.cursor,
136+
updatedAt: now,
137+
})
138+
139+
return { ok: true as const }
140+
},
141+
})
142+
143+
export const syncGitHubSoulBackups: ReturnType<typeof action> = action({
144+
args: {
145+
dryRun: v.optional(v.boolean()),
146+
batchSize: v.optional(v.number()),
147+
maxBatches: v.optional(v.number()),
148+
resetCursor: v.optional(v.boolean()),
149+
},
150+
handler: async (ctx, args): Promise<SyncGitHubSoulBackupsResult> => {
151+
const { user } = await requireUserFromAction(ctx)
152+
assertRole(user, ['admin'])
153+
154+
if (args.resetCursor && !args.dryRun) {
155+
await ctx.runMutation(internal.githubSoulBackups.setGitHubSoulBackupSyncStateInternal, {
156+
cursor: undefined,
157+
})
158+
}
159+
160+
return ctx.runAction(internal.githubSoulBackupsNode.syncGitHubSoulBackupsInternal, {
161+
dryRun: args.dryRun,
162+
batchSize: args.batchSize,
163+
maxBatches: args.maxBatches,
164+
}) as Promise<SyncGitHubSoulBackupsResult>
165+
},
166+
})
167+
168+
function clampInt(value: number, min: number, max: number) {
169+
return Math.max(min, Math.min(max, Math.floor(value)))
170+
}

0 commit comments

Comments
 (0)