Browse Source

feat: 自定义技能版本号改为 主.次.补丁——保存、恢复、审核推送时由管理员选择升级哪一段或填写更大的版本号(审核默认取 SKILL.md 声明的版本号);已有修订号 N 换算为 N.0.0;导出 zip 文件名带版本号;推送工具 0.4.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 16 hours ago
parent
commit
3d6ff2d3ed

+ 43 - 13
apps/admin/src/components/CustomSkillsPanel.vue

@@ -160,14 +160,29 @@ async function deleteFile(f: CustomSkillFileDto) {
 }
 
 // ---- 保存 / 发布 / 版本 ----
+// 新版本的版本号(主.次.补丁):选择升级哪一段(默认补丁),或填写更大的版本号
+const bump = ref<'patch' | 'minor' | 'major' | 'custom'>('patch')
+const customSemver = ref('')
+const nextSemver = computed(() => {
+  const [a = 0, b = 0, c = 0] = (current.value?.latestSemver ?? '0.0.0').split('.').map(Number)
+  return { patch: `${a}.${b}.${c + 1}`, minor: `${a}.${b + 1}.0`, major: `${a + 1}.0.0` }
+})
+const chosenSemver = computed(() => (bump.value === 'custom' ? customSemver.value.trim().replace(/^v/i, '') : nextSemver.value[bump.value]))
 async function save(publish: boolean) {
   if (!current.value || !(await flush())) return
-  const d = await run('save', () => admin.customSkills.save(current.value!.id, current.value!.latestVersion, publish), publish ? '已保存并发布' : '已保存为草稿(未发布)')
-  if (d) apply(d)
+  if (bump.value === 'custom' && !chosenSemver.value) return toast('请填写版本号,如 1.2.0', 'error')
+  const before = current.value.latestVersion
+  const d = await run('save', () => admin.customSkills.save(current.value!.id, before, publish, chosenSemver.value))
+  if (d) {
+    toast(d.latestVersion > before ? `已保存为 v${d.latestSemver}${publish ? ' 并发布' : '(草稿,未发布)'}` : publish ? '没有改动,已发布最新版本' : '没有改动', 'success')
+    apply(d)
+    bump.value = 'patch'
+    customSemver.value = ''
+  }
 }
 async function publishLatest() {
   if (!current.value) return
-  const d = await run('publish', () => admin.customSkills.publish(current.value!.id, current.value!.latestVersion), `已发布版本 ${current.value.latestVersion}`)
+  const d = await run('publish', () => admin.customSkills.publish(current.value!.id, current.value!.latestVersion), `已发布 v${current.value.latestSemver}`)
   if (d) apply(d)
 }
 async function discard() {
@@ -216,8 +231,12 @@ async function showDiff(from: number | 'working', to: number | 'working', title:
   if (files) diff.value = { title, files }
 }
 async function restore(v: number) {
-  if (!current.value || !window.confirm(`恢复到版本 ${v}?将另存为新版本并立即发布。`)) return
-  const d = await run('restore', () => admin.customSkills.restore(current.value!.id, v), `已恢复并发布`)
+  const s = current.value
+  if (!s) return
+  const from = s.versions.find(x => x.version === v)?.semver ?? String(v)
+  const semver = window.prompt(`恢复到 v${from}:将另存为新版本并立即发布。新版本号(必须大于 ${s.latestSemver}):`, nextSemver.value.patch)?.trim()
+  if (!semver) return
+  const d = await run('restore', () => admin.customSkills.restore(s.id, v, semver), `已恢复为 v${semver.replace(/^v/i, '')} 并发布`)
   if (d) {
     apply(d)
     showVersions.value = false
@@ -226,7 +245,7 @@ async function restore(v: number) {
 }
 async function publishVersion(v: number) {
   if (!current.value) return
-  const d = await run('publish', () => admin.customSkills.publish(current.value!.id, v), `已发布版本 ${v}`)
+  const d = await run('publish', () => admin.customSkills.publish(current.value!.id, v), `已发布 v${current.value.versions.find(x => x.version === v)?.semver ?? v}`)
   if (d) apply(d)
 }
 
@@ -263,7 +282,7 @@ const SUB_STATUS: Record<string, [string, string]> = {
 }
 
 const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
-const statusOf = (s: CustomSkillDto) => !s.enabled ? '已停用' : s.publishedVersion === null ? '未发布' : s.publishedVersion < s.latestVersion ? `v${s.publishedVersion} 生效 · 有新版本` : `v${s.publishedVersion} 生效`
+const statusOf = (s: CustomSkillDto) => !s.enabled ? '已停用' : s.publishedVersion === null ? '未发布' : s.publishedVersion < s.latestVersion ? `v${s.publishedSemver} 生效 · 有新版本 v${s.latestSemver}` : `v${s.publishedSemver} 生效`
 
 onMounted(load)
 defineExpose({ load })
@@ -333,8 +352,8 @@ defineExpose({ load })
         <div class="flex items-center gap-2 flex-wrap">
           <span class="font-600 text-[14px] font-mono">{{ current.name }}</span>
           <code class="font-mono text-[11.5px] text-fg-muted">custom-skills:{{ current.name }}</code>
-          <span class="badge bg-code" :class="current.publishedVersion === null ? 'text-warning' : 'text-success'">{{ current.publishedVersion === null ? '未发布' : `生效版本 v${current.publishedVersion}` }}</span>
-          <span class="hint">最新 v{{ current.latestVersion }}</span>
+          <span class="badge bg-code" :class="current.publishedVersion === null ? 'text-warning' : 'text-success'">{{ current.publishedVersion === null ? '未发布' : `生效版本 v${current.publishedSemver}` }}</span>
+          <span class="hint">最新 v{{ current.latestSemver }}</span>
           <span class="flex-1" />
           <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" :checked="current.enabled" @change="setFlag({ enabled: !current.enabled })">启用</label>
           <label class="flex items-center gap-1 cursor-pointer" title="选择&quot;全部技能&quot;的 Profile 也会加载它"><input type="checkbox" :checked="current.includeInAll" @change="setFlag({ includeInAll: !current.includeInAll })">包含在"全部技能"中</label>
@@ -342,10 +361,20 @@ defineExpose({ load })
         <div class="hint line-clamp-2">{{ current.description }}</div>
         <div class="hint">{{ current.profileNames.length ? `使用它的 Profile:${current.profileNames.join('、')}` : '还没有 Profile 勾选这个技能' }}</div>
         <div class="flex items-center gap-1.5 flex-wrap">
+          <label class="flex items-center gap-1" title="保存为新版本时的版本号(主.次.补丁)">
+            <span class="hint">新版本</span>
+            <select v-model="bump" class="field !w-auto h-7" aria-label="新版本号">
+              <option value="patch">补丁 v{{ nextSemver.patch }}</option>
+              <option value="minor">次版本 v{{ nextSemver.minor }}</option>
+              <option value="major">主版本 v{{ nextSemver.major }}</option>
+              <option value="custom">自定义…</option>
+            </select>
+            <input v-if="bump === 'custom'" v-model="customSemver" class="field !w-24 h-7 font-mono" :placeholder="nextSemver.minor" aria-label="自定义版本号">
+          </label>
           <button type="button" class="btn-primary" :disabled="!!busy" @click="save(true)">保存并发布</button>
           <button type="button" class="btn-secondary" :disabled="!!busy" @click="save(false)">保存草稿</button>
           <button type="button" class="btn-ghost" :disabled="!!busy || (!current.hasUnsavedChanges && !editorDirty)" @click="discard">放弃改动</button>
-          <button v-if="current.publishedVersion !== current.latestVersion" type="button" class="btn-secondary" :disabled="!!busy" @click="publishLatest">发布 v{{ current.latestVersion }}</button>
+          <button v-if="current.publishedVersion !== current.latestVersion" type="button" class="btn-secondary" :disabled="!!busy" @click="publishLatest">发布 v{{ current.latestSemver }}</button>
           <button type="button" class="btn-ghost" @click="showVersions = true"><span class="codicon codicon-history" />版本历史</button>
           <button v-if="current.hasUnsavedChanges || editorDirty" type="button" class="btn-ghost" @click="showDiff(current.latestVersion, 'working', '未保存的改动')"><span class="codicon codicon-diff" />查看改动</button>
           <a :href="admin.customSkills.exportUrl(current.id)" class="btn-ghost no-underline"><span class="codicon codicon-desktop-download" />导出 zip</a>
@@ -409,19 +438,20 @@ defineExpose({ load })
         <div v-for="v in current.versions" :key="v.version" class="flex items-start gap-3 py-2 border-t border-t-solid border-border first:border-t-0">
           <div class="flex-1 min-w-0">
             <div class="flex items-center gap-2">
-              <span class="font-600">v{{ v.version }}</span>
+              <span class="font-600">v{{ v.semver }}</span>
               <span class="badge bg-code">{{ REASON[v.reason] }}</span>
               <span v-if="v.version === current.publishedVersion" class="badge bg-code text-success">生效中</span>
               <span class="hint">{{ formatTime(v.createdAt) }} · {{ v.fileCount }} 个文件 · {{ fmtSize(v.size) }}</span>
             </div>
-            <div v-if="v.note" class="hint">来自 {{ v.note.keyName }} 的推送<template v-if="v.note.message">:{{ v.note.message }}</template></div>
+            <div v-if="v.note?.keyName" class="hint">来自 {{ v.note.keyName }} 的推送<template v-if="v.note.message">:{{ v.note.message }}</template></div>
+            <div v-else-if="v.note?.restoredFrom" class="hint">恢复自 v{{ v.note.restoredFrom }}</div>
             <div class="hint">
               <template v-if="v.changed.added.length">新增 {{ v.changed.added.join('、') }} </template>
               <template v-if="v.changed.modified.length">修改 {{ v.changed.modified.join('、') }} </template>
               <template v-if="v.changed.removed.length">删除 {{ v.changed.removed.join('、') }}</template>
             </div>
           </div>
-          <button v-if="v.version > 1" type="button" class="btn-ghost flex-none" @click="showDiff(v.version - 1, v.version, `v${v.version - 1} → v${v.version}`)">对比上一版</button>
+          <button v-if="v.version > 1" type="button" class="btn-ghost flex-none" @click="showDiff(v.version - 1, v.version, `v${current.versions.find(x => x.version === v.version - 1)?.semver} → v${v.semver}`)">对比上一版</button>
           <a :href="admin.customSkills.exportUrl(current.id, v.version)" class="btn-ghost flex-none no-underline">导出</a>
           <button v-if="v.version !== current.publishedVersion && v.version === current.latestVersion" type="button" class="btn-secondary flex-none" @click="publishVersion(v.version)">发布</button>
           <button v-if="v.version !== current.latestVersion" type="button" class="btn-secondary flex-none" @click="restore(v.version)">恢复</button>

+ 38 - 10
apps/admin/src/components/SkillReviewPane.vue

@@ -25,6 +25,12 @@ const reason = ref('')
 const diff = ref<SkillSubmissionDiffDto | null>(null)
 const diffLoading = ref(false)
 const confirming = ref(false)
+/** 批准后的版本号(主.次.补丁):默认取建议值(SKILL.md 声明了更大的合法版本号时用它,否则新建 1.0.0、覆盖补丁升级) */
+const semver = ref('')
+const declaredSemver = (v: string | null) => {
+  const m = /^v?(\d+\.\d+\.\d+)$/i.exec((v ?? '').trim())
+  return m ? m[1]! : null
+}
 
 const STATUS: Record<SkillSubmissionStatus, [string, string]> = {
   pending: ['待审核', 'text-warning'], approved: ['已批准', 'text-success'], rejected: ['已拒绝', 'text-error'], withdrawn: ['已撤回', 'text-fg-muted'],
@@ -44,6 +50,7 @@ async function load() {
     targetId.value = d.existing?.id ?? ''
     mode.value = d.existing ? 'overwrite' : 'create'
     publish.value = true
+    semver.value = declaredSemver(d.declaredVersion) ?? '1.0.0'
     await openFile(d.files[0]?.path ?? null)
     if (d.status === 'pending' && mode.value === 'overwrite') await loadDiff()
   } catch (err) {
@@ -71,6 +78,7 @@ async function loadDiff() {
     diff.value = mode.value === 'overwrite'
       ? targetId.value ? await admin.skillSubmissions.diff(props.id, { target: targetId.value }) : null
       : await admin.skillSubmissions.diff(props.id, { name: newName.value })
+    if (diff.value) semver.value = diff.value.suggestedSemver
   } catch (err) {
     diff.value = null
     toast(errorMessage(err), 'error')
@@ -82,8 +90,16 @@ watch([mode, targetId], () => {
   confirming.value = false
   diff.value = null
   if (mode.value === 'overwrite') void loadDiff()
+  else if (item.value) semver.value = declaredSemver(item.value.declaredVersion) ?? '1.0.0'
 })
 
+/** 覆盖时的快捷选择:补丁 / 次版本 / 主版本 */
+const overwriteBumps = computed<[string, string][]>(() => {
+  const t = diff.value?.target
+  if (!t) return []
+  const [a = 0, b = 0, c = 0] = t.semver.split('.').map(Number)
+  return [['补丁', `${a}.${b}.${c + 1}`], ['次版本', `${a}.${b + 1}.0`], ['主版本', `${a + 1}.0.0`]]
+})
 const nameTaken = computed(() => mode.value === 'create' && props.skills.some(s => s.name === newName.value.trim()))
 const target = computed(() => props.skills.find(s => s.id === targetId.value) ?? null)
 
@@ -102,15 +118,16 @@ async function act<T>(fn: () => Promise<T>, done: string) {
 }
 function approveCreate() {
   const name = newName.value.trim()
-  void act(() => admin.skillSubmissions.approve(props.id, { action: 'create', name, publish: publish.value }), publish.value ? `已新建并发布技能 ${name}` : `已新建技能 ${name}(未发布)`)
+  const v = semver.value.trim()
+  void act(() => admin.skillSubmissions.approve(props.id, { action: 'create', name, publish: publish.value, semver: v }), `已新建技能 ${name} v${v}${publish.value ? ',已发布' : '(未发布)'}`)
 }
 function approveOverwrite() {
   const d = diff.value
   if (!d?.target) return
   confirming.value = false
   void act(
-    () => admin.skillSubmissions.approve(props.id, { action: 'overwrite', targetId: d.target!.id, expectedVersion: d.target!.version, publish: publish.value }),
-    `已覆盖 ${d.target.name}(v${d.target.version + 1}${publish.value ? ',已发布' : ',未发布'})`,
+    () => admin.skillSubmissions.approve(props.id, { action: 'overwrite', targetId: d.target!.id, expectedVersion: d.target!.version, publish: publish.value, semver: semver.value.trim() }),
+    `已覆盖 ${d.target.name}(v${semver.value.trim()}${publish.value ? ',已发布' : ',未发布'})`,
   )
 }
 function reject() {
@@ -158,7 +175,7 @@ const changeCount = computed(() => diff.value?.files.length ?? 0)
         <div v-if="item.reason" class="text-error">{{ item.reason }}</div>
         <div v-if="item.status === 'rejected'" class="text-error">拒绝原因:{{ item.reviewNote }}<span class="hint">({{ item.reviewedAt && formatTime(item.reviewedAt) }})</span></div>
         <div v-if="item.result" class="flex items-center gap-2">
-          <span class="text-success">{{ item.result.action === 'create' ? '已新建技能' : '已覆盖技能' }} {{ item.result.skillName }} v{{ item.result.version }}{{ item.result.published ? '(已发布)' : '(未发布)' }}</span>
+          <span class="text-success">{{ item.result.action === 'create' ? '已新建技能' : '已覆盖技能' }} {{ item.result.skillName }} v{{ item.result.semver ?? item.result.version }}{{ item.result.published ? '(已发布)' : '(未发布)' }}</span>
           <button v-if="skills.some(s => s.id === item!.result!.skillId)" type="button" class="btn-ghost !h-6" @click="emit('openSkill', item.result.skillId)">打开技能</button>
         </div>
       </div>
@@ -210,8 +227,13 @@ const changeCount = computed(() => diff.value?.files.length ?? 0)
           <div v-if="nameTaken" class="text-warning">已存在同名技能 {{ newName.trim() }}:请改名,或选择"覆盖已有技能"。</div>
           <div v-else-if="newName.trim() !== item.name" class="hint">批准时会把 SKILL.md 中的 name 改为 {{ newName.trim() }}。</div>
           <FileDiffList v-if="diff && !diff.target" :files="diff.files" />
+          <div>
+            <label class="label" for="rv-semver-c">版本号</label>
+            <input id="rv-semver-c" v-model="semver" class="field font-mono w-32" placeholder="1.0.0">
+            <span class="hint ml-2">默认取 SKILL.md 声明的版本号,没有时为 1.0.0</span>
+          </div>
           <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="publish" type="checkbox">立即发布(不勾选则先保存为版本,之后在技能详情中发布)</label>
-          <div><button type="button" class="btn-primary" :disabled="busy || nameTaken || !newName.trim()" @click="approveCreate">批准为新技能</button></div>
+          <div><button type="button" class="btn-primary" :disabled="busy || nameTaken || !newName.trim() || !semver.trim()" @click="approveCreate">批准为新技能</button></div>
         </template>
 
         <template v-else-if="mode === 'overwrite'">
@@ -226,17 +248,23 @@ const changeCount = computed(() => diff.value?.files.length ?? 0)
           <template v-else-if="diff?.target">
             <div class="rounded-md border border-solid border-border p-2.5 flex flex-col gap-1">
               <div>
-                <span class="font-mono">{{ diff.target.name }}</span> 当前最新版本 v{{ diff.target.version }}({{ formatTime(diff.target.updatedAt) }}),覆盖后为 v{{ diff.target.version + 1 }}。
-                <span v-if="target && target.publishedVersion !== null && target.publishedVersion !== diff.target.version" class="hint">生效中的是 v{{ target.publishedVersion }}。</span>
+                <span class="font-mono">{{ diff.target.name }}</span> 当前最新版本 v{{ diff.target.semver }}({{ formatTime(diff.target.updatedAt) }})。
+                <span v-if="target && target.publishedVersion !== null && target.publishedVersion !== diff.target.version" class="hint">生效中的是 v{{ target.publishedSemver }}。</span>
               </div>
               <div>声明版本号:{{ diff.target.declaredVersion ?? '未声明' }} → {{ item.declaredVersion ?? '未声明' }}<span v-if="diff.target.declaredVersion && diff.target.declaredVersion === item.declaredVersion" class="text-warning">(没有变化,但内容不同)</span></div>
               <div v-if="diff.renamed" class="text-warning">技能名不同:批准时会把 SKILL.md 中的 name 改为 {{ diff.target.name }}。</div>
               <div v-if="diff.target.unsaved" class="text-warning">这个技能有尚未保存的编辑,覆盖后这些编辑会丢失。</div>
-              <div class="hint">以下是与 v{{ diff.target.version }} 的差异(共 {{ changeCount }} 个文件);旧版本可以在版本历史中恢复。</div>
+              <div class="hint">以下是与 v{{ diff.target.semver }} 的差异(共 {{ changeCount }} 个文件);旧版本可以在版本历史中恢复。</div>
             </div>
             <FileDiffList :files="diff.files" />
+            <div class="flex items-center gap-2 flex-wrap">
+              <label class="label m-0" for="rv-semver">覆盖后的版本号</label>
+              <input id="rv-semver" v-model="semver" class="field font-mono w-32" :placeholder="diff.suggestedSemver">
+              <button v-for="b in overwriteBumps" :key="b[0]" type="button" class="btn-ghost !h-6 !px-2" @click="semver = b[1]">{{ b[0] }} {{ b[1] }}</button>
+            </div>
+            <div class="hint">必须大于 {{ diff.target.semver }};默认取 SKILL.md 声明的更大版本号,否则补丁升级。</div>
             <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="publish" type="checkbox">立即发布(不勾选则先保存为版本,之后在技能详情中发布)</label>
-            <div><button type="button" class="btn-primary" :disabled="busy || !changeCount" @click="confirming = true">覆盖…</button></div>
+            <div><button type="button" class="btn-primary" :disabled="busy || !changeCount || !semver.trim()" @click="confirming = true">覆盖…</button></div>
           </template>
         </template>
 
@@ -254,7 +282,7 @@ const changeCount = computed(() => diff.value?.files.length ?? 0)
     <div v-if="confirming && diff?.target && item" class="fixed inset-0 z-50 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="confirming = false">
       <div class="w-[min(480px,92vw)] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
         <div class="font-600 text-[14px]">确认覆盖 {{ diff.target.name }}?</div>
-        <div>将用 {{ item.keyName }} 推送的内容覆盖 <span class="font-mono">{{ diff.target.name }}</span>(v{{ diff.target.version }} → v{{ diff.target.version + 1 }}),{{ changeCount }} 个文件有变化{{ publish ? ',并立即发布' : ',暂不发布' }}。</div>
+        <div>将用 {{ item.keyName }} 推送的内容覆盖 <span class="font-mono">{{ diff.target.name }}</span>(v{{ diff.target.semver }} → v{{ semver.trim() }}),{{ changeCount }} 个文件有变化{{ publish ? ',并立即发布' : ',暂不发布' }}。</div>
         <div v-if="diff.target.unsaved" class="text-warning">这个技能尚未保存的编辑会丢失。</div>
         <div class="hint">旧版本可以在版本历史中恢复。</div>
         <div class="flex justify-end gap-2">

+ 2 - 2
apps/admin/src/lib/api.ts

@@ -94,9 +94,9 @@ export const admin = {
     deleteFile: (id: string, path: string) => req<CustomSkillDetailDto>('DELETE', `/custom-skills/${id}/file?path=${encodeURIComponent(path)}`),
     move: (id: string, from: string, to: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/move`, { from, to }),
     discard: (id: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/discard`),
-    save: (id: string, baseVersion: number, publish: boolean) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/save`, { baseVersion, publish }),
+    save: (id: string, baseVersion: number, publish: boolean, semver: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/save`, { baseVersion, publish, semver }),
     publish: (id: string, version: number) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/publish`, { version }),
-    restore: (id: string, version: number) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/versions/${version}/restore`),
+    restore: (id: string, version: number, semver: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/versions/${version}/restore`, { semver }),
     diff: (id: string, from: number | 'working', to: number | 'working') => req<FileDiffDto[]>('GET', `/custom-skills/${id}/diff?from=${from}&to=${to}`),
     exportUrl: (id: string, version?: number) => `/admin/v1/custom-skills/${id}/export${version === undefined ? '' : `?version=${version}`}`,
   },

+ 4 - 0
apps/server/drizzle/0023_skill_semver.sql

@@ -0,0 +1,4 @@
+ALTER TABLE `custom_skill_versions` ADD `semver` text DEFAULT '' NOT NULL;--> statement-breakpoint
+UPDATE `custom_skill_versions` SET `semver` = `version` || '.0.0';--> statement-breakpoint
+UPDATE `skill_submission_items` SET `result` = json_set(`result`, '$.semver', json_extract(`result`, '$.version') || '.0.0') WHERE `result` IS NOT NULL AND json_extract(`result`, '$.semver') IS NULL;--> statement-breakpoint
+CREATE UNIQUE INDEX `custom_skill_versions_semver` ON `custom_skill_versions` (`skill_id`,`semver`);

+ 3178 - 0
apps/server/drizzle/meta/0023_snapshot.json

@@ -0,0 +1,3178 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "0f31da2a-5eb1-441e-83b8-3b4e619e7176",
+  "prevId": "7173820d-1aa5-4a43-aea7-45d04c844233",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "background_calls": {
+      "name": "background_calls",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "server_id": {
+          "name": "server_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_name": {
+          "name": "server_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_title": {
+          "name": "server_title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tool": {
+          "name": "tool",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "delivered": {
+          "name": "delivered",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "mode": {
+          "name": "mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'wait'"
+        },
+        "mcp_task_id": {
+          "name": "mcp_task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "conn_scope": {
+          "name": "conn_scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_after": {
+          "name": "poll_after",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "poll_error_since": {
+          "name": "poll_error_since",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status_message": {
+          "name": "status_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "background_calls_session_idx": {
+          "name": "background_calls_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_team_idx": {
+          "name": "background_calls_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_status_idx": {
+          "name": "background_calls_status_idx",
+          "columns": [
+            "status",
+            "delivered"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "semver": {
+          "name": "semver",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skill_versions_semver": {
+          "name": "custom_skill_versions_semver",
+          "columns": [
+            "skill_id",
+            "semver"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_credentials": {
+      "name": "mcp_credentials",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "values": {
+          "name": "values",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "url_prefixes": {
+          "name": "url_prefixes",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_credentials_name_unique": {
+          "name": "mcp_credentials_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "background_tools": {
+          "name": "background_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_hints": {
+          "name": "tool_hints",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "oauth": {
+          "name": "oauth",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "credential_id": {
+          "name": "credential_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "features": {
+          "name": "features",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "codex_plugin_ids": {
+          "name": "codex_plugin_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin_key": {
+          "name": "builtin_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "context_tokens": {
+          "name": "context_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedule_runs": {
+      "name": "schedule_runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "schedule_id": {
+          "name": "schedule_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "outcome": {
+          "name": "outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "schedule_runs_idx": {
+          "name": "schedule_runs_idx",
+          "columns": [
+            "schedule_id",
+            "id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedules": {
+      "name": "schedules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_by_bot_id": {
+          "name": "created_by_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "prompt": {
+          "name": "prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "spec": {
+          "name": "spec",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "status_note": {
+          "name": "status_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "next_run_at": {
+          "name": "next_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_run_at": {
+          "name": "last_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_outcome": {
+          "name": "last_outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "run_count": {
+          "name": "run_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "schedules_due_idx": {
+          "name": "schedules_due_idx",
+          "columns": [
+            "status",
+            "next_run_at"
+          ],
+          "isUnique": false
+        },
+        "schedules_session_idx": {
+          "name": "schedules_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "schedules_team_idx": {
+          "name": "schedules_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_device_logins": {
+      "name": "skill_device_logins",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "device_code_hash": {
+          "name": "device_code_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_code": {
+          "name": "user_code",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "client": {
+          "name": "client",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ip": {
+          "name": "ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'pending'"
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key_days": {
+          "name": "key_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_poll_at": {
+          "name": "last_poll_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "skill_device_logins_device_code_hash_unique": {
+          "name": "skill_device_logins_device_code_hash_unique",
+          "columns": [
+            "device_code_hash"
+          ],
+          "isUnique": true
+        },
+        "skill_device_logins_user_code_unique": {
+          "name": "skill_device_logins_user_code_unique",
+          "columns": [
+            "user_code"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_publish_keys": {
+      "name": "skill_publish_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_used_ip": {
+          "name": "last_used_ip",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "skill_publish_keys_key_hash_unique": {
+          "name": "skill_publish_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submission_items": {
+      "name": "skill_submission_items",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "submission_id": {
+          "name": "submission_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "declared_version": {
+          "name": "declared_version",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "checks": {
+          "name": "checks",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "review_note": {
+          "name": "review_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reviewed_at": {
+          "name": "reviewed_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_submissions": {
+      "name": "skill_submissions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_name": {
+          "name": "key_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "message": {
+          "name": "message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "meta": {
+          "name": "meta",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "package_sha": {
+          "name": "package_sha",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "package_size": {
+          "name": "package_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "follow_up": {
+          "name": "follow_up",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "reset_requested": {
+          "name": "reset_requested",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "case_id": {
+          "name": "case_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -162,6 +162,13 @@
       "when": 1793779663500,
       "tag": "0022_mcp_credentials",
       "breakpoints": true
+    },
+    {
+      "idx": 23,
+      "version": "6",
+      "when": 1793782887650,
+      "tag": "0023_skill_semver",
+      "breakpoints": true
     }
   ]
 }

+ 2 - 2
apps/server/src/api-reference/registry-reference.ts

@@ -163,11 +163,11 @@ export const REGISTRY_TYPES: ApiTypeDef[] = [
       { name: 'status', type: '"pending" | "approved" | "rejected" | "withdrawn" | "superseded" | "invalid" | "unchanged"', desc: '待审核 / 已批准 / 已拒绝 / 已撤回 / 被同一密钥更新的推送替代 / 校验不通过 / 与同名技能当前版本相同' },
       { name: 'reason', type: 'string | null', desc: '`invalid` 的原因' },
       { name: 'declaredVersion', type: 'string | null', desc: 'SKILL.md frontmatter 中的 `version` 或 `metadata.version`' },
-      { name: 'existing', type: '{ version, declaredVersion } | null', desc: '平台上的同名技能:最新版本号与声明的版本号' },
+      { name: 'existing', type: '{ version, semver, declaredVersion } | null', desc: '平台上的同名技能:`semver` 为最新版本号(主.次.补丁);`version` 为内部修订号(递增整数);`declaredVersion` 为其 SKILL.md 声明的版本号' },
       { name: 'changes', type: '{ added, modified, removed } | null', desc: '待审核时与同名技能当前版本相比变化的文件(没有同名技能时全部为新增)' },
       { name: 'checks', type: 'SkillCheck[]', desc: '自动检查结果(辅助审核,不会自动拒绝)' },
       { name: 'reviewNote', type: 'string | null', desc: '拒绝的原因' },
-      { name: 'result', type: '{ action, skill, version, published } | null', desc: '批准后:`action` 为 `create`(新建技能)或 `overwrite`(覆盖已有技能);`skill` 为技能名(管理员可能改名);`version` 为生成的版本号;`published` 是否已发布生效' },
+      { name: 'result', type: '{ action, skill, version, semver, published } | null', desc: '批准后:`action` 为 `create`(新建技能)或 `overwrite`(覆盖已有技能);`skill` 为技能名(管理员可能改名);`semver` 为生成的版本号(主.次.补丁,由管理员审核时确定:默认取 SKILL.md 声明的更大的版本号,否则补丁升级);`version` 为内部修订号;`published` 是否已发布生效' },
     ],
   },
   {

+ 11 - 9
apps/server/src/custom-skills/registry.ts

@@ -15,6 +15,7 @@ import { AppError, badRequest, conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { hashKey } from '../services/catalog'
 import { detectSkillDirs, extractZip, readSkillDir, type PackageSkill } from './package'
+import { suggestSemver } from './semver'
 import type { CustomSkillService } from './service'
 import { changesBetween, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath, sha256 } from './store'
 
@@ -250,9 +251,10 @@ export class SkillRegistryService {
     const name = target?.name ?? opts.name?.trim() ?? r.name
     const files = this.withName(r.files, name)
     return {
-      target: target && { id: target.id, name: target.name, version: target.version, declaredVersion: target.declaredVersion, unsaved: target.unsaved, updatedAt: target.updatedAt },
+      target: target && { id: target.id, name: target.name, version: target.version, semver: target.semver, declaredVersion: target.declaredVersion, unsaved: target.unsaved, updatedAt: target.updatedAt },
+      suggestedSemver: suggestSemver(target?.semver ?? null, r.declaredVersion),
       renamed: name !== r.name,
-      files: this.skills.diffFiles(target?.files ?? {}, files, target ? `v${target.version}` : '(空)', '提交'),
+      files: this.skills.diffFiles(target?.files ?? {}, files, target ? `v${target.semver}` : '(空)', '提交'),
     }
   }
 
@@ -264,11 +266,11 @@ export class SkillRegistryService {
     if (input.action === 'create' && !NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)')
     const res = this.skills.applySubmission(
       input.action === 'create' ? { action: 'create', name } : { action: 'overwrite', id: input.targetId, expectedVersion: input.expectedVersion },
-      this.withName(r.files, name), { submissionId: sub.id, keyName: sub.keyName, message: sub.message }, input.publish,
+      this.withName(r.files, name), { submissionId: sub.id, keyName: sub.keyName, message: sub.message }, input.publish, input.semver,
     )
-    const result = { action: input.action, skillId: res.id, skillName: res.name, version: res.version, published: input.publish }
+    const result = { action: input.action, skillId: res.id, skillName: res.name, version: res.version, semver: res.semver, published: input.publish }
     this.db.update(skillSubmissionItems).set({ status: 'approved', result, reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run()
-    this.audit(`批准推送的技能 ${r.name}(${sub.keyName}):${input.action === 'create' ? `新建技能 ${res.name}` : `覆盖技能 ${res.name}`} v${res.version}${input.publish ? ',已发布' : ',暂不发布'}`, { itemId: id, submissionId: sub.id })
+    this.audit(`批准推送的技能 ${r.name}(${sub.keyName}):${input.action === 'create' ? `新建技能 ${res.name}` : `覆盖技能 ${res.name}`} v${res.semver}${input.publish ? ',已发布' : ',暂不发布'}`, { itemId: id, submissionId: sub.id })
     return this.toItem(this.itemRow(id), sub)
   }
 
@@ -362,7 +364,7 @@ export class SkillRegistryService {
     const r = this.skills.byName(name)
     if (!r) return null
     const l = this.skills.latest(r.id)
-    return { id: r.id, version: l.version, declaredVersion: l.declaredVersion, files: l.files }
+    return { id: r.id, version: l.version, semver: l.semver, declaredVersion: l.declaredVersion, files: l.files }
   }
 
   private toItem(r: ItemRow, s: SubmissionRow): SkillSubmissionItemDto {
@@ -371,7 +373,7 @@ export class SkillRegistryService {
     return {
       id: r.id, submissionId: r.submissionId, name: r.name, description: r.description, declaredVersion: r.declaredVersion, status: r.status, reason: r.reason,
       checks: r.checks, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), keyName: s.keyName, message: s.message, meta: s.meta,
-      createdAt: r.createdAt, existing: e && { id: e.id, version: e.version, declaredVersion: e.declaredVersion }, reviewNote: r.reviewNote, reviewedAt: r.reviewedAt, result: r.result,
+      createdAt: r.createdAt, existing: e && { id: e.id, version: e.version, semver: e.semver, declaredVersion: e.declaredVersion }, reviewNote: r.reviewNote, reviewedAt: r.reviewedAt, result: r.result,
     }
   }
 
@@ -379,10 +381,10 @@ export class SkillRegistryService {
     const e = r.status === 'pending' || r.status === 'unchanged' || !r.id ? this.existingOf(r.name) : null
     return {
       id: r.id, name: r.name, status: r.status, reason: r.reason, declaredVersion: r.declaredVersion,
-      existing: e && { version: e.version, declaredVersion: e.declaredVersion },
+      existing: e && { version: e.version, semver: e.semver, declaredVersion: e.declaredVersion },
       changes: r.status === 'pending' ? changesBetween(e?.files ?? {}, r.files) : null,
       checks: r.checks, reviewNote: r.reviewNote,
-      result: r.result && { action: r.result.action, skill: r.result.skillName, version: r.result.version, published: r.result.published },
+      result: r.result && { action: r.result.action, skill: r.result.skillName, version: r.result.version, semver: r.result.semver ?? `${r.result.version}.0.0`, published: r.result.published },
     }
   }
 

+ 45 - 0
apps/server/src/custom-skills/semver.ts

@@ -0,0 +1,45 @@
+// 自定义技能的版本号(主.次.补丁,docs/design/12-custom-skills.md §版本号):每个版本保存时由管理员选择升级哪一段,或填写更大的版本号。
+// 内部仍用递增的整数修订号作主键、并发检查与生效标记
+import type { SkillVersionChoice } from '@agent-server/shared'
+import { badRequest } from '../lib/errors'
+
+export const SEMVER_RE = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/
+
+/** 合法的版本号(x.y.z,可带前缀 v);不合法时为 null */
+export function parseSemver(raw: string | null | undefined): [number, number, number] | null {
+  const m = SEMVER_RE.exec((raw ?? '').trim().replace(/^v/i, ''))
+  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
+}
+
+export function compareSemver(a: string, b: string): number {
+  const pa = parseSemver(a) ?? [0, 0, 0]
+  const pb = parseSemver(b) ?? [0, 0, 0]
+  return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2]
+}
+
+export function bumpSemver(v: string, bump: 'major' | 'minor' | 'patch'): string {
+  const [a, b, c] = parseSemver(v) ?? [0, 0, 0]
+  return bump === 'major' ? `${a + 1}.0.0` : bump === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`
+}
+
+/** 新版本的版本号:填写的版本号(必须比最新版本大),或按选择升级(默认补丁);没有最新版本(新建)时默认 1.0.0 */
+export function nextSemver(latest: string | null, c: SkillVersionChoice = {}): string {
+  if (c.semver !== undefined && c.semver !== null && c.semver.trim()) {
+    const p = parseSemver(c.semver)
+    if (!p) throw badRequest(`版本号格式不正确:${c.semver}(应为 主.次.补丁,如 1.2.0)`)
+    const v = p.join('.')
+    if (latest && compareSemver(v, latest) <= 0) throw badRequest(`版本号 ${v} 必须大于当前最新版本 ${latest}`)
+    return v
+  }
+  return latest ? bumpSemver(latest, c.bump ?? 'patch') : '1.0.0'
+}
+
+/**
+ * 审核推送时建议的版本号:SKILL.md 声明了合法版本号、且大于当前最新版本(新建时任意合法版本号)时用它;
+ * 否则新建为 1.0.0,覆盖为补丁升级
+ */
+export function suggestSemver(latest: string | null, declared: string | null): string {
+  const d = parseSemver(declared)?.join('.')
+  if (d && (!latest || compareSemver(d, latest) > 0)) return d
+  return nextSemver(latest)
+}

+ 50 - 33
apps/server/src/custom-skills/service.ts

@@ -8,7 +8,7 @@ import { createTwoFilesPatch } from 'diff'
 import { and, eq } from 'drizzle-orm'
 import { strToU8, zipSync } from 'fflate'
 import {
-  CUSTOM_SKILL_PLUGIN, type CustomSkillDetailDto, type CustomSkillDto, type CustomSkillFileDto, type CustomSkillVersionDto, type FileDiffDto, type SkillInfo,
+  CUSTOM_SKILL_PLUGIN, type CustomSkillDetailDto, type CustomSkillDto, type CustomSkillFileDto, type CustomSkillVersionDto, type FileDiffDto, type SkillInfo, type SkillVersionChoice,
 } from '@agent-server/shared'
 import { customSkillsDir, customSkillStoreDir } from '../config'
 import type { DB } from '../db'
@@ -17,6 +17,7 @@ import { badRequest, conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { declaredVersion, frontmatter } from '../lib/skill-files'
 import type { PluginRef } from '../providers/claude/skills'
+import { nextSemver, suggestSemver } from './semver'
 import { BlobStore, changesBetween, checkSkillLimits, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath } from './store'
 
 type SkillRow = typeof customSkills.$inferSelect
@@ -136,7 +137,7 @@ export class CustomSkillService {
     const id = newId('csk')
     this.db.transaction(tx => {
       tx.insert(customSkills).values({ id, name, description: input.description.trim(), latestVersion: 1, workingFiles: files }).run()
-      tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'create', files, changed: changesBetween({}, files) }).run()
+      tx.insert(customSkillVersions).values({ skillId: id, version: 1, semver: '1.0.0', reason: 'create', files, changed: changesBetween({}, files) }).run()
     })
     this.audit(`新建自定义技能 ${name}`)
     return this.detail(id)
@@ -189,8 +190,8 @@ export class CustomSkillService {
     return this.detail(id)
   }
 
-  /** 把编辑中的改动保存为新版本(内容无变化时不产生新版本),可同时发布 */
-  save(id: string, opts: { baseVersion: number; publish: boolean }): CustomSkillDetailDto {
+  /** 把编辑中的改动保存为新版本(内容无变化时不产生新版本),可同时发布;版本号按选择升级(默认补丁)或使用填写的版本号 */
+  save(id: string, opts: { baseVersion: number; publish: boolean } & SkillVersionChoice): CustomSkillDetailDto {
     const r = this.row(id)
     if (opts.baseVersion !== r.latestVersion) throw conflict('version_conflict', `技能已更新到版本 ${r.latestVersion},请刷新后再保存`)
     const latest = this.version(id, r.latestVersion).files
@@ -201,8 +202,8 @@ export class CustomSkillService {
     checkSkillLimits(r.workingFiles)
     let version = r.latestVersion
     if (dirty) {
-      version = this.addVersion(r, r.workingFiles, 'edit', description)
-      this.audit(`保存自定义技能 ${r.name} 版本 ${version}`, changed)
+      version = this.addVersion(r, r.workingFiles, 'edit', description, null, opts)
+      this.audit(`保存自定义技能 ${r.name} 版本 ${this.semverOf(id, version)}`, changed)
     }
     if (opts.publish) this.publish(id, version)
     return this.detail(id)
@@ -214,18 +215,18 @@ export class CustomSkillService {
     const v = this.version(id, version)
     this.db.update(customSkills).set({ publishedVersion: version, updatedAt: nowIso() }).where(eq(customSkills.id, id)).run()
     if (r.enabled) this.materialize(r.name, v.files, version)
-    this.audit(`发布自定义技能 ${r.name} 版本 ${version}`)
+    this.audit(`发布自定义技能 ${r.name} 版本 ${v.semver}`)
     return this.detail(id)
   }
 
-  /** 恢复:把旧版本另存为新版本并发布 */
-  restore(id: string, version: number): CustomSkillDetailDto {
+  /** 恢复:把旧版本另存为新版本并发布(新版本号按选择升级,默认补丁) */
+  restore(id: string, version: number, choice: SkillVersionChoice = {}): CustomSkillDetailDto {
     const r = this.row(id)
     const old = this.version(id, version)
     const desc = frontmatter(old.files['SKILL.md'] ? this.store.get(old.files['SKILL.md'].sha).toString('utf8') : '').description ?? r.description
-    const v = this.addVersion(r, old.files, 'restore', desc)
+    const v = this.addVersion(r, old.files, 'restore', desc, { restoredFrom: old.semver }, choice)
     this.setWorking(id, old.files)
-    this.audit(`恢复自定义技能 ${r.name} 到版本 ${version}(保存为版本 ${v})`)
+    this.audit(`恢复自定义技能 ${r.name} 到版本 ${old.semver}(保存为版本 ${this.semverOf(id, v)})`)
     return this.publish(id, v)
   }
 
@@ -267,46 +268,53 @@ export class CustomSkillService {
     return this.db.select().from(customSkills).where(eq(customSkills.name, name)).get() ?? null
   }
 
-  /** 技能的最新版本(审核时对比用):文件、SKILL.md 声明的版本号、是否有尚未保存的编辑 */
-  latest(id: string): { id: string; name: string; version: number; files: FileMap; declaredVersion: string | null; unsaved: boolean; updatedAt: string } {
+  /** 技能的最新版本(审核时对比用):文件、版本号、SKILL.md 声明的版本号、是否有尚未保存的编辑 */
+  latest(id: string): { id: string; name: string; version: number; semver: string; files: FileMap; declaredVersion: string | null; unsaved: boolean; updatedAt: string } {
     const r = this.row(id)
-    const files = this.version(id, r.latestVersion).files
+    const v = this.version(id, r.latestVersion)
+    const files = v.files
     const md = files['SKILL.md']
     return {
-      id, name: r.name, version: r.latestVersion, files, updatedAt: r.updatedAt, unsaved: mapDigest(files) !== mapDigest(r.workingFiles),
+      id, name: r.name, version: r.latestVersion, semver: v.semver, files, updatedAt: r.updatedAt, unsaved: mapDigest(files) !== mapDigest(r.workingFiles),
       declaredVersion: md ? declaredVersion(this.store.get(md.sha).toString('utf8')) : null,
     }
   }
 
-  /** 审核通过:新建技能,或覆盖已有技能为新版本(expectedVersion 为审核时看到的版本);可同时发布 */
+  /**
+   * 审核通过:新建技能,或覆盖已有技能为新版本(expectedVersion 为审核时看到的版本);可同时发布。
+   * 版本号:填写的版本号,否则按 SKILL.md 声明的版本号建议(suggestSemver)
+   */
   applySubmission(
     target: { action: 'create'; name: string } | { action: 'overwrite'; id: string; expectedVersion: number },
-    files: FileMap, note: SkillVersionNote, publish: boolean,
-  ): { id: string; name: string; version: number } {
+    files: FileMap, note: SkillVersionNote, publish: boolean, semver?: string | null,
+  ): { id: string; name: string; version: number; semver: string } {
     checkSkillLimits(files)
     const name = target.action === 'create' ? target.name : this.row(target.id).name
     const md = files['SKILL.md'] ? this.store.get(files['SKILL.md'].sha) : undefined
     const { description } = checkSkillMd(name, md, true)
+    const declared = md ? declaredVersion(md.toString('utf8')) : null
     let id: string
     let version: number
     if (target.action === 'create') {
       if (!NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)')
       this.checkCapacity(name)
+      const sv = semver?.trim() ? nextSemver(null, { semver }) : suggestSemver(null, declared)
       id = newId('csk')
       version = 1
       this.db.transaction(tx => {
         tx.insert(customSkills).values({ id, name, description, latestVersion: 1, workingFiles: files }).run()
-        tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'submission', files, changed: changesBetween({}, files), note }).run()
+        tx.insert(customSkillVersions).values({ skillId: id, version: 1, semver: sv, reason: 'submission', files, changed: changesBetween({}, files), note }).run()
       })
     } else {
       const r = this.row(target.id)
-      if (r.latestVersion !== target.expectedVersion) throw conflict('version_conflict', `技能 ${r.name} 已更新到版本 ${r.latestVersion},请刷新差异后再确认`)
+      if (r.latestVersion !== target.expectedVersion) throw conflict('version_conflict', `技能 ${r.name} 已更新到版本 ${this.semverOf(r.id, r.latestVersion)},请刷新差异后再确认`)
       id = r.id
-      version = this.addVersion(r, files, 'submission', description, note)
+      const latest = this.semverOf(r.id, r.latestVersion)
+      version = this.addVersion(r, files, 'submission', description, note, { semver: semver?.trim() ? semver : suggestSemver(latest, declared) })
       this.setWorking(id, files)
     }
     if (publish) this.publish(id, version)
-    return { id, name, version }
+    return { id, name, version, semver: this.semverOf(id, version) }
   }
 
   // ---- 版本对比与导出 ----
@@ -315,7 +323,8 @@ export class CustomSkillService {
   diff(id: string, from: number | 'working', to: number | 'working'): FileDiffDto[] {
     const r = this.row(id)
     const files = (v: number | 'working') => (v === 'working' ? r.workingFiles : this.version(id, v).files)
-    return this.diffFiles(files(from), files(to), String(from), String(to))
+    const label = (v: number | 'working') => (v === 'working' ? '编辑中' : `v${this.version(id, v).semver}`)
+    return this.diffFiles(files(from), files(to), label(from), label(to))
   }
 
   /** 两份文件清单的差异(文本文件给出 unified diff) */
@@ -336,14 +345,14 @@ export class CustomSkillService {
     ].sort((x, y) => x.path.localeCompare(y.path))
   }
 
-  /** 导出为 zip(顶层目录为技能名,可以直接作为技能目录使用) */
+  /** 导出为 zip(顶层目录为技能名,可以直接作为技能目录使用;文件名带版本号,如 order-query-v1.2.0.zip) */
   exportZip(id: string, version?: number): { name: string; data: Buffer } {
     const r = this.row(id)
-    const v = version ?? r.latestVersion
+    const v = this.version(id, version ?? r.latestVersion)
     const entries: Record<string, Uint8Array> = {}
-    for (const [p, f] of Object.entries(this.version(id, v).files)) entries[`${r.name}/${p}`] = new Uint8Array(this.store.get(f.sha))
+    for (const [p, f] of Object.entries(v.files)) entries[`${r.name}/${p}`] = new Uint8Array(this.store.get(f.sha))
     if (!Object.keys(entries).length) entries[`${r.name}/`] = strToU8('')
-    return { name: `${r.name}-v${v}.zip`, data: Buffer.from(zipSync(entries, { level: 6 })) }
+    return { name: `${r.name}-v${v.semver}.zip`, data: Buffer.from(zipSync(entries, { level: 6 })) }
   }
 
   // ---- 运行时 ----
@@ -419,11 +428,16 @@ export class CustomSkillService {
     return v
   }
 
-  private addVersion(r: SkillRow, files: FileMap, reason: Reason, description: string, note: SkillVersionNote | null = null): number {
-    const prev = this.version(r.id, r.latestVersion).files
+  private semverOf(id: string, version: number): string {
+    return this.version(id, version).semver
+  }
+
+  private addVersion(r: SkillRow, files: FileMap, reason: Reason, description: string, note: SkillVersionNote | null = null, choice: SkillVersionChoice = {}): number {
+    const prev = this.version(r.id, r.latestVersion)
+    const semver = nextSemver(prev.semver, choice)
     const version = r.latestVersion + 1
     this.db.transaction(tx => {
-      tx.insert(customSkillVersions).values({ skillId: r.id, version, reason, files, changed: changesBetween(prev, files), note }).run()
+      tx.insert(customSkillVersions).values({ skillId: r.id, version, semver, reason, files, changed: changesBetween(prev.files, files), note }).run()
       tx.update(customSkills).set({ latestVersion: version, description, updatedAt: nowIso() }).where(eq(customSkills.id, r.id)).run()
     })
     return version
@@ -476,11 +490,14 @@ export class CustomSkillService {
   }
 
   private toDto(r: SkillRow, usage: Map<string, string[]>): CustomSkillDto {
-    const latest = this.db.select({ files: customSkillVersions.files }).from(customSkillVersions)
+    const latest = this.db.select({ files: customSkillVersions.files, semver: customSkillVersions.semver }).from(customSkillVersions)
       .where(and(eq(customSkillVersions.skillId, r.id), eq(customSkillVersions.version, r.latestVersion))).get()
+    const published = r.publishedVersion === null ? null : this.db.select({ semver: customSkillVersions.semver }).from(customSkillVersions)
+      .where(and(eq(customSkillVersions.skillId, r.id), eq(customSkillVersions.version, r.publishedVersion))).get()
     return {
       id: r.id, name: r.name, description: r.description,
-      enabled: r.enabled, includeInAll: r.includeInAll, publishedVersion: r.publishedVersion, latestVersion: r.latestVersion,
+      enabled: r.enabled, includeInAll: r.includeInAll, publishedVersion: r.publishedVersion, publishedSemver: published?.semver ?? null,
+      latestVersion: r.latestVersion, latestSemver: latest?.semver ?? '',
       hasUnsavedChanges: !!latest && mapDigest(latest.files) !== mapDigest(r.workingFiles),
       profileNames: usage.get(skillId(r.name)) ?? [], createdAt: r.createdAt, updatedAt: r.updatedAt,
     }
@@ -494,7 +511,7 @@ function fileList(m: FileMap): CustomSkillFileDto[] {
 
 function toVersionDto(v: VersionRow): CustomSkillVersionDto {
   const files = Object.values(v.files)
-  return { version: v.version, createdAt: v.createdAt, reason: v.reason, changed: v.changed, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), note: v.note ?? null }
+  return { version: v.version, semver: v.semver, createdAt: v.createdAt, reason: v.reason, changed: v.changed, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), note: v.note ?? null }
 }
 
 function readMarker(f: string): { version: number; digest: string } | null {

+ 11 - 5
apps/server/src/db/schema.ts

@@ -1,5 +1,5 @@
 import { sql } from 'drizzle-orm'
-import { index, integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
+import { index, integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
 import type {
   FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolHints, McpToolPolicy, PermissionMode, ProfileSnapshot, ScheduleOutcome, ScheduleSpec, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
 } from '@agent-server/shared'
@@ -282,6 +282,8 @@ export const customSkills = sqliteTable('custom_skills', {
 export const customSkillVersions = sqliteTable('custom_skill_versions', {
   skillId: text('skill_id').notNull(),
   version: integer('version').notNull(),
+  /** 版本号(主.次.补丁),由管理员保存时选择;早期的修订号 N 换算为 N.0.0 */
+  semver: text('semver').notNull().default(''),
   /** submission:审核通过的推送(docs/design/18-skill-registry.md);import / sync 为早期技能源导入的版本 */
   reason: text('reason', { enum: ['create', 'edit', 'import', 'sync', 'restore', 'submission'] }).notNull(),
   files: text('files', { mode: 'json' }).$type<FileMap>().notNull(),
@@ -289,12 +291,14 @@ export const customSkillVersions = sqliteTable('custom_skill_versions', {
   /** 来自推送时:提交说明等的副本(提交记录删除后仍可显示) */
   note: text('note', { mode: 'json' }).$type<SkillVersionNote>(),
   createdAt: createdAt(),
-}, t => [primaryKey({ columns: [t.skillId, t.version] })])
+}, t => [primaryKey({ columns: [t.skillId, t.version] }), uniqueIndex('custom_skill_versions_semver').on(t.skillId, t.semver)])
 
+/** 版本附注:来自推送时为提交 ID、密钥名称、提交说明;恢复时为恢复自的版本号 */
 export interface SkillVersionNote {
-  submissionId: string
-  keyName: string
-  message: string
+  submissionId?: string
+  keyName?: string
+  message?: string
+  restoredFrom?: string
 }
 
 /** 技能发布密钥(docs/design/18-skill-registry.md):名称即来源,只能推送待审核的技能 */
@@ -376,6 +380,8 @@ export interface SkillSubmissionResult {
   skillId: string
   skillName: string
   version: number
+  /** 版本号(主.次.补丁);迁移时为早期记录补上 N.0.0 */
+  semver?: string
   published: boolean
 }
 

+ 7 - 5
apps/server/src/http/routes/admin.ts

@@ -277,7 +277,8 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       return { ok: true }
     })
 
-    // 自定义技能(docs/design/12-custom-skills.md)
+    // 自定义技能(docs/design/12-custom-skills.md)。新版本的版本号:按选择升级(默认补丁),或填写更大的版本号
+    const versionChoice = { bump: z.enum(['major', 'minor', 'patch']).optional(), semver: z.string().max(40).nullable().optional() }
     type SkillParams = { Params: { id: string } }
     type FileQuery = { Params: { id: string }; Querystring: { path?: string; version?: string } }
     const optVersion = (v: string | undefined) => (v === undefined || v === '' ? undefined : Number(v))
@@ -319,10 +320,11 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     g.post<SkillParams>('/custom-skills/:id/discard', async req => app.customSkills.discard(req.params.id))
     g.post<SkillParams>('/custom-skills/:id/save', async req =>
-      app.customSkills.save(req.params.id, parse(z.object({ baseVersion: z.number().int(), publish: z.boolean() }), req.body)))
+      app.customSkills.save(req.params.id, parse(z.object({ baseVersion: z.number().int(), publish: z.boolean(), ...versionChoice }), req.body)))
     g.post<SkillParams>('/custom-skills/:id/publish', async req =>
       app.customSkills.publish(req.params.id, parse(z.object({ version: z.number().int() }), req.body).version))
-    g.post<{ Params: { id: string; v: string } }>('/custom-skills/:id/versions/:v/restore', async req => app.customSkills.restore(req.params.id, Number(req.params.v)))
+    g.post<{ Params: { id: string; v: string } }>('/custom-skills/:id/versions/:v/restore', async req =>
+      app.customSkills.restore(req.params.id, Number(req.params.v), parse(z.object(versionChoice), req.body ?? {})))
     g.get<{ Params: { id: string }; Querystring: { from?: string; to?: string } }>('/custom-skills/:id/diff', async req => {
       const v = (x: string | undefined) => (x === 'working' ? 'working' as const : Number(x))
       return app.customSkills.diff(req.params.id, v(req.query.from), v(req.query.to))
@@ -367,8 +369,8 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.skillRegistry.diff(req.params.id, { targetId: req.query.target || undefined, name: req.query.name || undefined }))
     g.post<SkillParams>('/skill-submissions/:id/approve', async req => {
       const b = parse(z.discriminatedUnion('action', [
-        z.object({ action: z.literal('create'), name: z.string(), publish: z.boolean().default(true) }),
-        z.object({ action: z.literal('overwrite'), targetId: z.string(), expectedVersion: z.number().int(), publish: z.boolean().default(true) }),
+        z.object({ action: z.literal('create'), name: z.string(), publish: z.boolean().default(true), semver: z.string().max(40).nullable().optional() }),
+        z.object({ action: z.literal('overwrite'), targetId: z.string(), expectedVersion: z.number().int(), publish: z.boolean().default(true), semver: z.string().max(40).nullable().optional() }),
       ]), req.body)
       return app.skillRegistry.approve(req.params.id, b)
     })

+ 43 - 5
apps/server/test/custom-skills.test.ts

@@ -145,13 +145,15 @@ describe('技能发布平台:推送与审核(docs/design/18-skill-registry.m
 
     const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
     const d = app.skillRegistry.diff(item.id, { targetId: alphaId })
-    expect(d.target).toMatchObject({ name: 'alpha', version: 1, unsaved: false })
+    expect(d.target).toMatchObject({ name: 'alpha', version: 1, semver: '1.0.0', unsaved: false })
+    // 声明的 2.0 不是 主.次.补丁:建议补丁升级
+    expect(d.suggestedSemver).toBe('1.0.1')
     expect(d.files.find(f => f.path === 'references/guide.md')!.patch).toContain('+alpha guide v3')
     // 审核期间技能有了新版本:拒绝覆盖
     expect(() => app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 0, publish: true })).toThrow(/刷新/)
     const r = app.skillRegistry.approve(item.id, { action: 'overwrite', targetId: alphaId, expectedVersion: 1, publish: false })
-    expect(r.result).toMatchObject({ action: 'overwrite', skillName: 'alpha', version: 2, published: false })
-    expect(app.customSkills.detail(alphaId)).toMatchObject({ latestVersion: 2, publishedVersion: 1 })
+    expect(r.result).toMatchObject({ action: 'overwrite', skillName: 'alpha', version: 2, semver: '1.0.1', published: false })
+    expect(app.customSkills.detail(alphaId)).toMatchObject({ latestVersion: 2, latestSemver: '1.0.1', publishedVersion: 1, publishedSemver: '1.0.0' })
     expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v1\nline2\n')
     app.customSkills.publish(alphaId, 2)
     expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v3\n')
@@ -163,6 +165,20 @@ describe('技能发布平台:推送与审核(docs/design/18-skill-registry.m
     app.skillRegistry.approve(next.id, { action: 'overwrite', targetId: gammaId, expectedVersion: 1, publish: true })
     expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('name: gamma')
     expect(fs.readFileSync(live('gamma'), 'utf8')).toContain('# alpha-next')
+
+    // 版本号:SKILL.md 声明了更大的合法版本号时默认用它;也可以填写;不能小于当前版本
+    await push(key, zip({ 'delta/SKILL.md': md('delta', undefined, 'version: 2.5.0\n') }))
+    const delta = pending('delta')
+    expect(app.skillRegistry.diff(delta.id, { name: 'delta' }).suggestedSemver).toBe('2.5.0')
+    expect(app.skillRegistry.approve(delta.id, { action: 'create', name: 'delta', publish: true }).result).toMatchObject({ version: 1, semver: '2.5.0' })
+    await push(key, zip({ 'delta/SKILL.md': md('delta', '新的说明:处理某类业务数据的流程与规范', 'version: 2.4.0\n') }))
+    const delta2 = pending('delta')
+    const deltaId = app.customSkills.list().find(s => s.name === 'delta')!.id
+    expect(app.skillRegistry.diff(delta2.id, { targetId: deltaId }).suggestedSemver).toBe('2.5.1')
+    expect(() => app.skillRegistry.approve(delta2.id, { action: 'overwrite', targetId: deltaId, expectedVersion: 1, publish: true, semver: '2.4.0' })).toThrow(/必须大于/)
+    expect(app.skillRegistry.approve(delta2.id, { action: 'overwrite', targetId: deltaId, expectedVersion: 1, publish: true, semver: '3.0.0' }).result).toMatchObject({ version: 2, semver: '3.0.0' })
+    const reg = await fetch(`${base}/skills/registry/v1/submissions/${delta2.submissionId}`, { headers: { Authorization: `Bearer ${key}` } }).then(x => x.json() as Promise<{ items: { result: unknown }[] }>)
+    expect(reg.items[0]!.result).toMatchObject({ skill: 'delta', version: 2, semver: '3.0.0' })
   })
 
   it('C-04 撤回;删除已处理的提交项(待审核的不能删),技能版本保留提交说明', async () => {
@@ -220,7 +236,7 @@ describe('自定义技能:在线编写、草稿与发布、版本', () => {
   it('C-04 新建 → 上传模版并保存为草稿:不生效;发布后生效;恢复到上一版本', () => {
     const d = app.customSkills.create({ name: 'report-kit', description: '生成月度报告的规范与模版,涉及月报时使用' })
     id = d.id
-    expect(d.versions.map(v => [v.version, v.reason])).toEqual([[1, 'create']])
+    expect(d.versions.map(v => [v.version, v.semver, v.reason])).toEqual([[1, '1.0.0', 'create']])
     expect(d.publishedVersion).toBeNull()
     expect(fs.existsSync(live('report-kit'))).toBe(false)
 
@@ -244,6 +260,10 @@ describe('自定义技能:在线编写、草稿与发布、版本', () => {
 
     const v4 = app.customSkills.restore(id, 2)
     expect([v4.latestVersion, v4.publishedVersion, v4.versions[0]!.reason]).toEqual([4, 4, 'restore'])
+    // 版本号默认补丁升级;恢复记录恢复自哪个版本
+    expect(v4.versions.map(v => v.semver)).toEqual(['1.0.3', '1.0.2', '1.0.1', '1.0.0'])
+    expect(v4.versions[0]!.note).toEqual({ restoredFrom: '1.0.1' })
+    expect([v4.latestSemver, v4.publishedSemver]).toEqual(['1.0.3', '1.0.3'])
     expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3])
   })
 
@@ -259,8 +279,26 @@ describe('自定义技能:在线编写、草稿与发布、版本', () => {
     expect(app.customSkills.deleteFile(id, 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'templates/a.pptx'])
 
     const z = app.customSkills.exportZip(id)
-    expect(z.name).toBe('report-kit-v4.zip')
+    expect(z.name).toBe('report-kit-v1.0.3.zip')
     expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['report-kit/SKILL.md', 'report-kit/templates/a.pptx'])
+    expect(app.customSkills.exportZip(id, 1).name).toBe('report-kit-v1.0.0.zip')
+  })
+
+  it('版本号:保存时选择升级主版本 / 次版本 / 补丁,或填写更大的版本号', () => {
+    const edit = (body: string) => app.customSkills.writeFile(id, 'notes.md', Buffer.from(body))
+    const base = () => app.customSkills.detail(id).latestVersion
+    edit('a')
+    expect(app.customSkills.save(id, { baseVersion: base(), publish: false, bump: 'minor' }).latestSemver).toBe('1.1.0')
+    edit('b')
+    expect(app.customSkills.save(id, { baseVersion: base(), publish: false, bump: 'major' }).latestSemver).toBe('2.0.0')
+    edit('c')
+    for (const bad of ['2.0.0', '1.9.9', 'abc', '2.0', '02.1.0']) {
+      expect(() => app.customSkills.save(id, { baseVersion: base(), publish: false, semver: bad })).toThrow(/必须大于|格式不正确/)
+    }
+    const d = app.customSkills.save(id, { baseVersion: base(), publish: true, semver: 'v2.3.4' })
+    expect([d.latestSemver, d.publishedSemver]).toEqual(['2.3.4', '2.3.4'])
+    expect(app.customSkills.restore(id, 1, { bump: 'minor' }).latestSemver).toBe('2.4.0')
+    expect(app.customSkills.diff(id, d.latestVersion, d.latestVersion + 1).find(f => f.path === 'notes.md')!.patch).toContain('v2.3.4')
   })
 })
 

+ 1 - 1
apps/server/test/skills-cli.test.ts

@@ -164,7 +164,7 @@ describe('推送工具:推送与查询', () => {
     })
     expect(ok.code).toBe(0)
     expect(ok.out).toContain('审核结果')
-    expect(ok.out).toContain('alpha  已批准:新建 alpha v1,已发布')
+    expect(ok.out).toContain('alpha  已批准:新建 alpha v1.0.0,已发布')
 
     const rejected = await run(['push', '--only', 'beta', '--wait'], {
       onSleep: () => {

+ 12 - 0
docs/design/12-custom-skills.md

@@ -102,6 +102,18 @@
 - 查看任意版本、与当前版本对比(文本文件逐行差异)。
 - 恢复:把旧版本另存为新版本并发布(与 Profile 的“恢复保存为新版本”一致)。
 
+#### 版本号(2026-11-04)
+
+- 每个版本有一个版本号 `主.次.补丁`(如 `1.2.0`),由管理员决定升级哪一段:
+  - 保存(编辑):在"新版本"中选择补丁 / 次版本 / 主版本(默认补丁),或自定义;
+  - 恢复:填写新版本号(默认补丁升级),版本历史中注明"恢复自 vX";
+  - 审核推送(18-skill-registry.md):填写版本号,默认取 SKILL.md 声明的合法且更大的版本号,否则新建为 `1.0.0`、覆盖为补丁升级;
+  - 新建技能为 `1.0.0`。
+- 规则:只接受三段数字(可带前缀 `v`),同一技能内唯一,且必须大于最新版本。
+- 内部仍保留递增的整数修订号(`custom_skill_versions.version`),用于主键、保存时的并发检查(`baseVersion` / `expectedVersion`)与生效标记;页面与接口显示版本号(`semver`),接口同时返回修订号以兼容。
+- 版本号只保存在平台中,**不写入 SKILL.md**;导出的 zip 文件名带版本号(`<技能名>-v1.2.0.zip`)。
+- 迁移 `0023_skill_semver`:已有的修订号 N 换算为 `N.0.0`(审核记录中的结果同样补上)。实现见 `custom-skills/semver.ts`。
+
 ### FR-4 使用
 
 - Profile 的技能选择中出现 `custom-skills:<name>`,归为“自定义”分组。

+ 27 - 9
packages/shared/src/api.ts

@@ -714,16 +714,28 @@ export interface FileChangesDto {
   removed: string[]
 }
 
+/**
+ * 新版本的版本号(主.次.补丁):semver 为填写的版本号(必须大于最新版本);否则按 bump 升级(默认补丁)。
+ * 内部的整数修订号(version)仍用于并发检查与指定版本
+ */
+export interface SkillVersionChoice {
+  bump?: 'major' | 'minor' | 'patch'
+  semver?: string | null
+}
+
 export interface CustomSkillVersionDto {
+  /** 内部修订号(递增整数) */
   version: number
+  /** 版本号(主.次.补丁) */
+  semver: string
   createdAt: string
   /** create 新建;edit 编辑保存;restore 恢复;submission 审核通过的推送;import / sync 为早期从技能源导入的版本 */
   reason: 'create' | 'edit' | 'import' | 'sync' | 'restore' | 'submission'
   changed: FileChangesDto
   fileCount: number
   size: number
-  /** 来自推送时:提交 ID、密钥名称、提交说明 */
-  note: { submissionId: string; keyName: string; message: string } | null
+  /** 来自推送时:提交 ID、密钥名称、提交说明;恢复时:恢复自的版本号 */
+  note: { submissionId?: string; keyName?: string; message?: string; restoredFrom?: string } | null
 }
 
 export interface CustomSkillDto {
@@ -734,9 +746,11 @@ export interface CustomSkillDto {
   enabled: boolean
   /** 包含在 Profile 的"全部技能"中(默认否,需要单独勾选) */
   includeInAll: boolean
-  /** 运行时生效的版本;null 表示还没有发布 */
+  /** 运行时生效的版本(修订号);null 表示还没有发布 */
   publishedVersion: number | null
+  publishedSemver: string | null
   latestVersion: number
+  latestSemver: string
   /** 编辑中、尚未保存为版本的改动 */
   hasUnsavedChanges: boolean
   profileNames: string[]
@@ -837,6 +851,8 @@ export interface SkillSubmissionResultDto {
   skillId: string
   skillName: string
   version: number
+  /** 版本号;早期记录可能没有 */
+  semver?: string
   published: boolean
 }
 
@@ -858,7 +874,7 @@ export interface SkillSubmissionItemDto {
   meta: Record<string, string>
   createdAt: string
   /** 同名技能(如有) */
-  existing: { id: string; version: number; declaredVersion: string | null } | null
+  existing: { id: string; version: number; semver: string; declaredVersion: string | null } | null
   reviewNote: string | null
   reviewedAt: string | null
   result: SkillSubmissionResultDto | null
@@ -870,15 +886,17 @@ export interface SkillSubmissionItemDetailDto extends SkillSubmissionItemDto {
 
 /** 审核时的差异:新建(target 为 null,与空技能对比)或覆盖目标技能的最新版本 */
 export interface SkillSubmissionDiffDto {
-  target: { id: string; name: string; version: number; declaredVersion: string | null; unsaved: boolean; updatedAt: string } | null
+  target: { id: string; name: string; version: number; semver: string; declaredVersion: string | null; unsaved: boolean; updatedAt: string } | null
+  /** 建议的版本号:SKILL.md 声明了更大的合法版本号时用它,否则新建为 1.0.0、覆盖为补丁升级 */
+  suggestedSemver: string
   /** 技能名与 SKILL.md 中的 name 不同时,批准时会改写 SKILL.md */
   renamed: boolean
   files: FileDiffDto[]
 }
 
 export type SkillApproveInput =
-  | { action: 'create'; name: string; publish: boolean }
-  | { action: 'overwrite'; targetId: string; expectedVersion: number; publish: boolean }
+  | { action: 'create'; name: string; publish: boolean; semver?: string | null }
+  | { action: 'overwrite'; targetId: string; expectedVersion: number; publish: boolean; semver?: string | null }
 
 /** 推送方(发布接口)看到的提交项 */
 export interface RegistryItemDto {
@@ -887,11 +905,11 @@ export interface RegistryItemDto {
   status: SkillSubmissionStatus
   reason: string | null
   declaredVersion: string | null
-  existing: { version: number; declaredVersion: string | null } | null
+  existing: { version: number; semver: string; declaredVersion: string | null } | null
   changes: FileChangesDto | null
   checks: SkillCheckDto[]
   reviewNote: string | null
-  result: { action: 'create' | 'overwrite'; skill: string; version: number; published: boolean } | null
+  result: { action: 'create' | 'overwrite'; skill: string; version: number; semver: string; published: boolean } | null
 }
 
 export interface RegistrySubmissionDto {

+ 1 - 1
packages/skills-cli/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/skills-cli",
-  "version": "0.3.0",
+  "version": "0.4.0",
   "private": true,
   "type": "module",
   "description": "AgentServer 技能推送工具(agent-skills)",

+ 2 - 2
packages/skills-cli/src/main.ts

@@ -426,9 +426,9 @@ class Ctx {
     for (const i of items) {
       const color = i.status === 'pending' ? '33' : i.status === 'approved' ? '32' : i.status === 'rejected' || i.status === 'invalid' ? '31' : '2'
       let line = `  ${i.name}  ${this.c(color, STATUS[i.status] ?? i.status)}`
-      if (i.status === 'pending' && i.existing) line += `(覆盖候选:平台上为 v${i.existing.version}${i.existing.declaredVersion ? `,版本 ${i.existing.declaredVersion}` : ''})`
+      if (i.status === 'pending' && i.existing) line += `(覆盖候选:平台上为 v${i.existing.semver ?? i.existing.version}${i.existing.declaredVersion ? `,SKILL.md 声明 ${i.existing.declaredVersion}` : ''})`
       else if (i.status === 'pending') line += '(新技能)'
-      if (i.result) line += `:${i.result.action === 'create' ? '新建' : '覆盖'} ${i.result.skill} v${i.result.version}${i.result.published ? ',已发布' : ',未发布'}`
+      if (i.result) line += `:${i.result.action === 'create' ? '新建' : '覆盖'} ${i.result.skill} v${i.result.semver ?? i.result.version}${i.result.published ? ',已发布' : ',未发布'}`
       this.io.out(line)
       if (i.changes && i.status === 'pending') {
         const c = i.changes