Browse Source

feat: 资料库分为托管目录、本机目录、远程存储、网络链接四种类型(第一期:托管目录与网络链接)——托管目录由平台创建、可设为 Agent 可写;网络链接可测试访问,同源内网地址对勾选它的会话放行;新增资料集合,Profile 可同时选择集合与单个资料库;不再登记任意服务器路径,旧记录迁移为只读本机目录

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 11 hours ago
parent
commit
7581da0099
35 changed files with 4700 additions and 252 deletions
  1. 8 4
      apps/admin/src/lib/api.ts
  2. 196 43
      apps/admin/src/views/KnowledgeView.vue
  3. 50 16
      apps/admin/src/views/ProfilesView.vue
  4. 1 1
      apps/admin/src/views/SettingsView.vue
  5. 30 0
      apps/server/drizzle/0024_knowledge_sources.sql
  6. 3273 0
      apps/server/drizzle/meta/0024_snapshot.json
  7. 7 0
      apps/server/drizzle/meta/_journal.json
  8. 3 2
      apps/server/src/app.ts
  9. 8 0
      apps/server/src/config.ts
  10. 22 2
      apps/server/src/db/schema.ts
  11. 33 15
      apps/server/src/http/routes/admin.ts
  12. 36 0
      apps/server/src/knowledge/describe.ts
  13. 268 0
      apps/server/src/knowledge/service.ts
  14. 1 1
      apps/server/src/lib/ids.ts
  15. 5 4
      apps/server/src/mcp-gateway/builtin.ts
  16. 1 0
      apps/server/src/providers/claude/adapter.ts
  17. 11 1
      apps/server/src/providers/claude/policy.ts
  18. 2 0
      apps/server/src/providers/types.ts
  19. 14 39
      apps/server/src/services/access-policy.ts
  20. 25 59
      apps/server/src/services/catalog.ts
  21. 7 3
      apps/server/src/services/file-manager.ts
  22. 3 5
      apps/server/src/services/prompt.ts
  23. 8 5
      apps/server/src/services/runs.ts
  24. 12 5
      apps/server/src/services/web-fetch.ts
  25. 3 2
      apps/server/src/teams/cards.ts
  26. 32 21
      apps/server/test/access-policy.test.ts
  27. 0 7
      apps/server/test/api.test.ts
  28. 3 5
      apps/server/test/file-manager.test.ts
  29. 17 3
      apps/server/test/gateway.test.ts
  30. 125 0
      apps/server/test/knowledge.test.ts
  31. 4 3
      docs/design/05-data-model.md
  32. 2 0
      docs/design/07-files-and-knowledge.md
  33. 2 2
      docs/design/09-admin-ui.md
  34. 401 0
      docs/design/21-knowledge-sources.md
  35. 87 4
      packages/shared/src/api.ts

+ 8 - 4
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient, BotClient } from '@agent-server/client'
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
 import type {
-  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillDeviceLoginDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpCredentialDto, McpCredentialInput, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillDeviceLoginDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeRootDto, McpCredentialDto, McpCredentialInput, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
 } from '@agent-server/shared'
 } from '@agent-server/shared'
 
 
@@ -12,7 +12,6 @@ const req = <T>(method: string, path: string, body?: unknown) => client.request<
 const fsq = (root: string, path: string) => `root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`
 const fsq = (root: string, path: string) => `root=${encodeURIComponent(root)}&path=${encodeURIComponent(path)}`
 
 
 export interface AuditRow { id: number; ts: string; actor: string; sessionId: string | null; kind: string; summary: string; detail: unknown }
 export interface AuditRow { id: number; ts: string; actor: string; sessionId: string | null; kind: string; summary: string; detail: unknown }
-export type KnowledgeInput = Pick<KnowledgeRootDto, 'name' | 'path' | 'description' | 'access' | 'enabled'>
 
 
 export const admin = {
 export const admin = {
   me: () => req<{ loggedIn: boolean }>('GET', '/auth/me'),
   me: () => req<{ loggedIn: boolean }>('GET', '/auth/me'),
@@ -41,7 +40,7 @@ export const admin = {
   overview: () => req<{ providers: ProviderStatusDto[]; runs: { running: number; queued: number; active: number }; version: string }>('GET', '/overview'),
   overview: () => req<{ providers: ProviderStatusDto[]; runs: { running: number; queued: number; active: number }; version: string }>('GET', '/overview'),
   settings: () => req<{ settings: AppSettings; defaults: AppSettings; builtinGlobalDeny: string[] }>('GET', '/settings'),
   settings: () => req<{ settings: AppSettings; defaults: AppSettings; builtinGlobalDeny: string[] }>('GET', '/settings'),
   updateSettings: (patch: DeepPartial<AppSettings>) => req<{ settings: AppSettings }>('PATCH', '/settings', patch),
   updateSettings: (patch: DeepPartial<AppSettings>) => req<{ settings: AppSettings }>('PATCH', '/settings', patch),
-  checkPath: (path: string, purpose: 'knowledge' | 'generic' = 'knowledge') => req<PathCheck>('POST', '/paths/check', { path, purpose }),
+  checkPath: (path: string) => req<PathCheck>('POST', '/paths/check', { path }),
   skills: (engine: 'claude' | 'codex' = 'claude') => req<SkillInfo[]>('GET', `/skills?engine=${engine}`),
   skills: (engine: 'claude' | 'codex' = 'claude') => req<SkillInfo[]>('GET', `/skills?engine=${engine}`),
   codexSkills: {
   codexSkills: {
     catalog: () => req<CodexSkillCatalogDto>('GET', '/codex-skills'),
     catalog: () => req<CodexSkillCatalogDto>('GET', '/codex-skills'),
@@ -161,11 +160,16 @@ export const admin = {
   deleteProfile: (id: string) => req<void>('DELETE', `/profiles/${id}`),
   deleteProfile: (id: string) => req<void>('DELETE', `/profiles/${id}`),
   profileVersions: (id: string) => req<ProfileVersionDto[]>('GET', `/profiles/${id}/versions`),
   profileVersions: (id: string) => req<ProfileVersionDto[]>('GET', `/profiles/${id}/versions`),
   restoreProfile: (id: string, version: number) => req<ProfileDto>('POST', `/profiles/${id}/versions/${version}/restore`),
   restoreProfile: (id: string, version: number) => req<ProfileDto>('POST', `/profiles/${id}/versions/${version}/restore`),
-  previewAccess: (fileAccess: FileAccessPolicy, knowledgeRootIds: string[]) => req<ResolvedAccessDto>('POST', '/access/preview', { fileAccess, knowledgeRootIds }),
+  previewAccess: (fileAccess: FileAccessPolicy, knowledgeRootIds: string[], knowledgeCollectionIds: string[]) => req<ResolvedAccessDto>('POST', '/access/preview', { fileAccess, knowledgeRootIds, knowledgeCollectionIds }),
   knowledge: () => req<KnowledgeRootDto[]>('GET', '/knowledge'),
   knowledge: () => req<KnowledgeRootDto[]>('GET', '/knowledge'),
   createKnowledge: (k: KnowledgeInput) => req<KnowledgeRootDto>('POST', '/knowledge', k),
   createKnowledge: (k: KnowledgeInput) => req<KnowledgeRootDto>('POST', '/knowledge', k),
   updateKnowledge: (id: string, k: KnowledgeInput) => req<KnowledgeRootDto>('PUT', `/knowledge/${id}`, k),
   updateKnowledge: (id: string, k: KnowledgeInput) => req<KnowledgeRootDto>('PUT', `/knowledge/${id}`, k),
   deleteKnowledge: (id: string) => req<void>('DELETE', `/knowledge/${id}`),
   deleteKnowledge: (id: string) => req<void>('DELETE', `/knowledge/${id}`),
+  testKnowledgeLink: (url: string) => req<KnowledgeLinkTestDto>('POST', '/knowledge/test-link', { url }),
+  knowledgeCollections: () => req<KnowledgeCollectionDto[]>('GET', '/knowledge-collections'),
+  createKnowledgeCollection: (c: KnowledgeCollectionInput) => req<KnowledgeCollectionDto>('POST', '/knowledge-collections', c),
+  updateKnowledgeCollection: (id: string, c: KnowledgeCollectionInput) => req<KnowledgeCollectionDto>('PUT', `/knowledge-collections/${id}`, c),
+  deleteKnowledgeCollection: (id: string) => req<void>('DELETE', `/knowledge-collections/${id}`),
   bots: () => req<BotDto[]>('GET', '/bots'),
   bots: () => req<BotDto[]>('GET', '/bots'),
   createBot: (input: BotInput) => req<BotDto>('POST', '/bots', input),
   createBot: (input: BotInput) => req<BotDto>('POST', '/bots', input),
   updateBot: (id: string, input: BotInput) => req<BotDto>('PUT', `/bots/${id}`, input),
   updateBot: (id: string, input: BotInput) => req<BotDto>('PUT', `/bots/${id}`, input),

+ 196 - 43
apps/admin/src/views/KnowledgeView.vue

@@ -1,36 +1,56 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { onMounted, ref, watch } from 'vue'
-import type { KnowledgeRootDto, PathCheck } from '@agent-server/shared'
-import DirPathInput from '../components/DirPathInput.vue'
+// 资料库与资料集合(docs/design/21-knowledge-sources.md):托管目录、本机目录、远程存储、网络链接;集合供 Profile 整体勾选
+import { computed, onMounted, ref } from 'vue'
+import type { KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeKind, KnowledgeLinkTestDto, KnowledgeRootDto, KnowledgeState } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import PageHeader from '../components/PageHeader.vue'
-import { admin, errorMessage, type KnowledgeInput } from '../lib/api'
-import { toast } from '../lib/ui'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
 
 
 const roots = ref<KnowledgeRootDto[]>([])
 const roots = ref<KnowledgeRootDto[]>([])
+const collections = ref<KnowledgeCollectionDto[]>([])
 const editing = ref<(KnowledgeInput & { id?: string }) | null>(null)
 const editing = ref<(KnowledgeInput & { id?: string }) | null>(null)
-const check = ref<PathCheck | null>(null)
+const editingCol = ref<(KnowledgeCollectionInput & { id?: string }) | null>(null)
 const saving = ref(false)
 const saving = ref(false)
+const linkTest = ref<KnowledgeLinkTestDto | null>(null)
+const testing = ref(false)
+
+const KINDS: { kind: KnowledgeKind; label: string; icon: string; desc: string; available: boolean }[] = [
+  { kind: 'managed', label: '托管目录', icon: 'folder', desc: '平台自己的目录,在"文件管理"中上传、编辑文件;可以允许 Agent 写入', available: true },
+  { kind: 'local', label: '本机目录', icon: 'device-desktop', desc: '/home、/mnt、/media 下的目录(含外接硬盘),只读挂载,不显示隐藏文件', available: false },
+  { kind: 'remote', label: '远程存储', icon: 'cloud', desc: 'S3 兼容存储(MinIO、OSS、COS 等)、WebDAV,只读挂载,按需读取', available: false },
+  { kind: 'link', label: '网络链接', icon: 'link', desc: '网页或文件地址,Agent 用时自己读取;同一站点下的内网地址也可以访问', available: true },
+]
+const kindOf = (k: KnowledgeKind) => KINDS.find(x => x.kind === k)!
+
+const STATE_LABEL: Record<KnowledgeState, string> = {
+  ok: '正常', path_missing: '路径不存在', device_missing: '设备未连接', not_allowed: '位置不允许',
+  unreachable: '无法连接', auth_failed: '认证失败', mounter_down: '挂载服务不可用', pending: '正在挂载',
+}
+
+const rootName = (id: string) => roots.value.find(r => r.id === id)?.name ?? id
+const colName = (id: string) => collections.value.find(c => c.id === id)?.name ?? id
+const where = (k: KnowledgeRootDto) => k.kind === 'link' ? k.url : k.kind === 'local' ? k.local?.path : k.path
 
 
 async function load() {
 async function load() {
-  roots.value = await admin.knowledge().catch(err => (toast(errorMessage(err), 'error'), []))
+  ;[roots.value, collections.value] = await Promise.all([
+    admin.knowledge().catch(err => (toast(errorMessage(err), 'error'), [])),
+    admin.knowledgeCollections().catch(err => (toast(errorMessage(err), 'error'), [])),
+  ])
 }
 }
 
 
 function edit(k?: KnowledgeRootDto) {
 function edit(k?: KnowledgeRootDto) {
+  linkTest.value = null
   editing.value = k
   editing.value = k
-    ? { id: k.id, name: k.name, path: k.path, description: k.description, access: k.access, enabled: k.enabled }
-    : { name: '', path: '/srv/agent-server/knowledge/', description: '', access: 'read', enabled: true }
+    ? { id: k.id, kind: k.kind, name: k.name, description: k.description, access: k.access, enabled: k.enabled, url: k.url ?? '' }
+    : { kind: 'managed', name: '', description: '', access: 'read', enabled: true, url: '' }
 }
 }
 
 
-let timer: ReturnType<typeof setTimeout> | undefined
-watch(() => editing.value?.path, p => {
-  clearTimeout(timer)
-  check.value = null
-  if (!p?.trim()) return
-  timer = setTimeout(async () => (check.value = await admin.checkPath(p).catch(() => null)), 300)
+const canSave = computed(() => {
+  const e = editing.value
+  if (!e || !e.name.trim() || !kindOf(e.kind).available) return false
+  return e.kind !== 'link' || !!e.url?.trim()
 })
 })
 
 
-const valid = (c: PathCheck | null) => !!c && c.exists && c.readable && c.isDirectory && !c.denied
-
 async function save() {
 async function save() {
   const e = editing.value
   const e = editing.value
   if (!e) return
   if (!e) return
@@ -49,66 +69,199 @@ async function save() {
   }
   }
 }
 }
 
 
+async function testLink(url: string | null | undefined) {
+  if (!url?.trim()) return
+  testing.value = true
+  linkTest.value = null
+  try {
+    linkTest.value = await admin.testKnowledgeLink(url.trim())
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    testing.value = false
+  }
+}
+
+async function testListed(k: KnowledgeRootDto) {
+  const r = await admin.testKnowledgeLink(k.url ?? '').catch(err => (toast(errorMessage(err), 'error'), null))
+  if (r) toast(r.ok ? `可以访问(HTTP ${r.status}${r.title ? `,${r.title}` : ''})` : `无法访问:${r.error}`, r.ok ? 'success' : 'error')
+}
+
 async function remove(k: KnowledgeRootDto) {
 async function remove(k: KnowledgeRootDto) {
-  if (!confirm(`删除资料库「${k.name}」?(不会删除目录中的文件)`)) return
+  const tail = k.kind === 'managed' ? '\n\n这是托管目录,其中的文件会一起删除,不能恢复。' : k.kind === 'link' ? '' : '\n\n只取消登记,不会删除原来的文件。'
+  const inCols = k.collectionIds.length ? `\n\n它属于集合:${k.collectionIds.map(colName).join('、')},会从这些集合中移除。` : ''
+  if (!confirm(`删除资料库「${k.name}」?${inCols}${tail}`)) return
   await admin.deleteKnowledge(k.id).catch(err => toast(errorMessage(err), 'error'))
   await admin.deleteKnowledge(k.id).catch(err => toast(errorMessage(err), 'error'))
   await load()
   await load()
 }
 }
 
 
+function editCol(c?: KnowledgeCollectionDto) {
+  editingCol.value = c ? { id: c.id, name: c.name, description: c.description, rootIds: [...c.rootIds] } : { name: '', description: '', rootIds: [] }
+}
+
+function toggleMember(id: string) {
+  const e = editingCol.value!
+  e.rootIds = e.rootIds.includes(id) ? e.rootIds.filter(x => x !== id) : [...e.rootIds, id]
+}
+
+async function saveCol() {
+  const e = editingCol.value
+  if (!e) return
+  const { id, ...body } = e
+  const prev = id ? collections.value.find(c => c.id === id) : undefined
+  const added = prev ? body.rootIds.filter(r => !prev.rootIds.includes(r)) : []
+  if (prev?.usedBy.length && added.length
+    && !confirm(`新增的资料库(${added.map(rootName).join('、')})会立即对以下 Profile 生效:${prev.usedBy.map(p => p.name).join('、')}。继续?`)) return
+  saving.value = true
+  try {
+    if (id) await admin.updateKnowledgeCollection(id, body)
+    else await admin.createKnowledgeCollection(body)
+    editingCol.value = null
+    toast('已保存', 'success')
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    saving.value = false
+  }
+}
+
+async function removeCol(c: KnowledgeCollectionDto) {
+  if (!confirm(`删除资料集合「${c.name}」?(不会删除其中的资料库)`)) return
+  await admin.deleteKnowledgeCollection(c.id).catch(err => toast(errorMessage(err), 'error'))
+  await load()
+}
+
 onMounted(load)
 onMounted(load)
 </script>
 </script>
 
 
 <template>
 <template>
   <div class="h-full flex flex-col">
   <div class="h-full flex flex-col">
-    <PageHeader title="资料库" subtitle="登记服务器上的资料目录,在 Profile 中启用后 Agent 才能访问">
+    <PageHeader title="资料库" subtitle="Agent 可以查找的资料;在 Profile 中勾选资料库或资料集合后生效">
+      <button type="button" class="btn-secondary" @click="editCol()"><span class="codicon codicon-folder-library" />新建集合</button>
       <button type="button" class="btn-primary" @click="edit()"><span class="codicon codicon-add" />添加资料库</button>
       <button type="button" class="btn-primary" @click="edit()"><span class="codicon codicon-add" />添加资料库</button>
     </PageHeader>
     </PageHeader>
-    <div class="flex-1 overflow-y-auto p-6">
-      <div v-if="!roots.length" class="hint">还没有资料库。建议把资料放在 home 目录以外(例如 /srv/agent-server/knowledge),以 systemd 加固方式部署时无需额外配置。</div>
-      <div class="flex flex-col gap-2 max-w-240">
+    <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
+      <section class="max-w-240 flex flex-col gap-2">
+        <div class="section-title">资料库</div>
+        <div v-if="!roots.length" class="hint">还没有资料库。</div>
         <div v-for="k in roots" :key="k.id" class="card p-3 flex items-start gap-3" :class="{ 'opacity-60': !k.enabled }">
         <div v-for="k in roots" :key="k.id" class="card p-3 flex items-start gap-3" :class="{ 'opacity-60': !k.enabled }">
-          <span class="codicon codicon-library text-[20px] mt-0.5" :class="valid(k.check) ? 'text-accent' : 'text-error'" />
+          <span class="codicon text-[20px] mt-0.5" :class="[`codicon-${kindOf(k.kind).icon}`, k.status.state === 'ok' ? 'text-accent' : 'text-error']" />
           <div class="flex-1 min-w-0">
           <div class="flex-1 min-w-0">
-            <div class="flex items-center gap-2">
+            <div class="flex items-center gap-2 flex-wrap">
               <span class="font-600">{{ k.name }}</span>
               <span class="font-600">{{ k.name }}</span>
-              <span class="badge bg-code">{{ k.access === 'write' ? '读写' : '只读' }}</span>
+              <span class="badge bg-code">{{ kindOf(k.kind).label }}</span>
+              <span class="badge bg-code">{{ k.access === 'write' ? 'Agent 可写' : '只读' }}</span>
               <span v-if="!k.enabled" class="badge bg-code">已停用</span>
               <span v-if="!k.enabled" class="badge bg-code">已停用</span>
+              <span v-if="k.status.state !== 'ok'" class="badge text-error" :title="k.status.since ? `自 ${formatTime(k.status.since)}` : ''">
+                {{ STATE_LABEL[k.status.state] }}
+              </span>
+              <span v-for="c in k.collectionIds" :key="c" class="badge bg-code"><span class="codicon codicon-folder-library text-[11px]" />{{ colName(c) }}</span>
             </div>
             </div>
-            <div class="font-mono text-[12px] text-fg-muted break-all">{{ k.path }}</div>
+            <div class="font-mono text-[12px] text-fg-muted break-all">{{ where(k) }}</div>
             <div v-if="k.description" class="mt-1">{{ k.description }}</div>
             <div v-if="k.description" class="mt-1">{{ k.description }}</div>
-            <div v-if="k.check.message" class="mt-1 text-[12px]" :class="valid(k.check) ? 'text-warning' : 'text-error'">{{ k.check.message }}</div>
+            <div v-if="k.status.state !== 'ok' && k.status.message" class="mt-1 text-[12px] text-error">{{ k.status.message }}</div>
           </div>
           </div>
+          <RouterLink v-if="k.kind !== 'link'" :to="{ path: '/files', query: { root: `kb:${k.id}` } }" class="icon-btn" title="在文件管理中打开"><span class="codicon codicon-folder-opened" /></RouterLink>
+          <button v-else type="button" class="icon-btn" title="测试访问" @click="testListed(k)"><span class="codicon codicon-debug-start" /></button>
           <button type="button" class="icon-btn" title="编辑" @click="edit(k)"><span class="codicon codicon-edit" /></button>
           <button type="button" class="icon-btn" title="编辑" @click="edit(k)"><span class="codicon codicon-edit" /></button>
           <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
           <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
         </div>
         </div>
-      </div>
+      </section>
+
+      <section class="max-w-240 flex flex-col gap-2">
+        <div class="section-title">资料集合</div>
+        <div class="hint">把若干资料库归为一组,Profile 可以整体勾选。集合是引用关系:修改成员后,所有勾选了这个集合的 Profile 立即生效。</div>
+        <div v-if="!collections.length" class="hint">还没有资料集合。</div>
+        <div v-for="c in collections" :key="c.id" class="card p-3 flex items-start gap-3">
+          <span class="codicon codicon-folder-library text-[20px] mt-0.5 text-accent" />
+          <div class="flex-1 min-w-0">
+            <div class="font-600">{{ c.name }}</div>
+            <div v-if="c.description" class="mt-0.5">{{ c.description }}</div>
+            <div class="mt-1 flex flex-wrap gap-1">
+              <span v-if="!c.rootIds.length" class="hint">没有成员</span>
+              <span v-for="r in c.rootIds" :key="r" class="badge bg-code">{{ rootName(r) }}</span>
+            </div>
+            <div class="mt-1 text-[12px] text-fg-muted">
+              {{ c.usedBy.length ? `被 Profile 使用:${c.usedBy.map(p => p.name).join('、')}` : '没有 Profile 使用' }}
+            </div>
+          </div>
+          <button type="button" class="icon-btn" title="编辑" @click="editCol(c)"><span class="codicon codicon-edit" /></button>
+          <button type="button" class="icon-btn" title="删除" :disabled="!!c.usedBy.length" @click="removeCol(c)"><span class="codicon codicon-trash" /></button>
+        </div>
+      </section>
     </div>
     </div>
 
 
     <div v-if="editing" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editing = null">
     <div v-if="editing" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editing = null">
-      <form class="w-[520px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl" @submit.prevent="save">
+      <form class="w-[560px] max-h-[90vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl" @submit.prevent="save">
         <div class="font-600">{{ editing.id ? '编辑资料库' : '添加资料库' }}</div>
         <div class="font-600">{{ editing.id ? '编辑资料库' : '添加资料库' }}</div>
-        <div><label class="label" for="k-name">名称</label><input id="k-name" v-model="editing.name" class="field" placeholder="例如:家庭账单"></div>
-        <div>
-          <label class="label" for="k-path">目录路径(服务器上的绝对路径,支持 ~;可以从下拉框中逐级选择)</label>
-          <DirPathInput id="k-path" v-model="editing.path" placeholder="/srv/agent-server/knowledge/…" />
-          <div v-if="check" class="mt-1 text-[12px] flex items-center gap-1" :class="valid(check) ? (check.message ? 'text-warning' : 'text-success') : 'text-error'">
-            <span class="codicon" :class="valid(check) ? 'codicon-pass' : 'codicon-error'" />
-            {{ check.message ?? '路径可用' }}
+        <div v-if="!editing.id" class="grid grid-cols-2 gap-2">
+          <label
+            v-for="t in KINDS" :key="t.kind" class="card p-2 flex gap-2 cursor-pointer"
+            :class="[editing.kind === t.kind ? 'border-accent' : '', t.available ? '' : 'opacity-50 cursor-not-allowed']"
+          >
+            <input v-model="editing.kind" type="radio" :value="t.kind" :disabled="!t.available" class="mt-1">
+            <span class="flex flex-col gap-0.5">
+              <span class="font-600 flex items-center gap-1"><span class="codicon" :class="`codicon-${t.icon}`" />{{ t.label }}<span v-if="!t.available" class="hint">(即将提供)</span></span>
+              <span class="text-[12px] text-fg-muted">{{ t.desc }}</span>
+            </span>
+          </label>
+        </div>
+        <div v-else class="hint">类型:{{ kindOf(editing.kind).label }}(不能修改)</div>
+        <div><label class="label" for="k-name">名称</label><input id="k-name" v-model="editing.name" class="field" placeholder="例如:产品手册"></div>
+        <div v-if="editing.kind === 'link'">
+          <label class="label" for="k-url">链接</label>
+          <div class="flex gap-2">
+            <input id="k-url" v-model="editing.url" class="field flex-1 font-mono" placeholder="https://wiki.example.com/docs/">
+            <button type="button" class="btn-secondary" :disabled="testing || !editing.url?.trim()" @click="testLink(editing.url)">测试访问</button>
+          </div>
+          <div v-if="linkTest" class="mt-1 text-[12px] flex items-center gap-1" :class="linkTest.ok ? 'text-success' : 'text-error'">
+            <span class="codicon" :class="linkTest.ok ? 'codicon-pass' : 'codicon-error'" />
+            <template v-if="linkTest.ok">可以访问(HTTP {{ linkTest.status }}{{ linkTest.contentType ? `,${linkTest.contentType}` : '' }}{{ linkTest.title ? `,${linkTest.title}` : '' }})</template>
+            <template v-else>无法访问:{{ linkTest.error }}</template>
           </div>
           </div>
+          <div class="hint mt-1">Agent 用 web_fetch 读取;与这个链接同一站点(协议、主机、端口相同)的地址都可以访问,包括内网地址。暂不支持需要登录的地址。</div>
         </div>
         </div>
         <div>
         <div>
           <label class="label" for="k-desc">说明(会告诉 Agent 这里有什么资料)</label>
           <label class="label" for="k-desc">说明(会告诉 Agent 这里有什么资料)</label>
-          <textarea id="k-desc" v-model="editing.description" class="field-area h-20 font-sans" placeholder="例如:2020 年以来的银行流水 CSV 和发票 PDF,按年份分目录" />
+          <textarea id="k-desc" v-model="editing.description" class="field-area h-20 font-sans" placeholder="例如:各产品线的用户手册与规格书 PDF,按产品型号分目录" />
         </div>
         </div>
-        <div class="flex gap-4">
-          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="read">只读</label>
-          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="write">可读写</label>
-          <span class="flex-1" />
-          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.enabled" type="checkbox">启用</label>
+        <div v-if="editing.kind === 'managed'" class="flex flex-col gap-1">
+          <div class="flex gap-4">
+            <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="read">Agent 只读</label>
+            <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="write">允许 Agent 写入</label>
+          </div>
+          <div v-if="editing.access === 'write'" class="text-[12px] text-warning">可写的托管目录由所有使用它的会话共享,一个会话写入的内容会影响其他会话。</div>
+          <div v-if="!editing.id" class="hint">保存后在"文件管理"中上传、编辑文件。</div>
         </div>
         </div>
+        <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.enabled" type="checkbox">启用</label>
         <div class="flex justify-end gap-2 mt-2">
         <div class="flex justify-end gap-2 mt-2">
           <button type="button" class="btn-secondary" @click="editing = null">取消</button>
           <button type="button" class="btn-secondary" @click="editing = null">取消</button>
-          <button type="submit" class="btn-primary" :disabled="saving || !editing.name.trim() || !valid(check)">保存</button>
+          <button type="submit" class="btn-primary" :disabled="saving || !canSave">保存</button>
+        </div>
+      </form>
+    </div>
+
+    <div v-if="editingCol" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editingCol = null">
+      <form class="w-[520px] max-h-[90vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl" @submit.prevent="saveCol">
+        <div class="font-600">{{ editingCol.id ? '编辑资料集合' : '新建资料集合' }}</div>
+        <div><label class="label" for="c-name">名称</label><input id="c-name" v-model="editingCol.name" class="field" placeholder="例如:售后资料"></div>
+        <div><label class="label" for="c-desc">说明</label><input id="c-desc" v-model="editingCol.description" class="field" placeholder="可选"></div>
+        <div>
+          <div class="label">成员</div>
+          <div v-if="!roots.length" class="hint">还没有资料库</div>
+          <div class="flex flex-col gap-1">
+            <label v-for="k in roots" :key="k.id" class="flex items-center gap-2 cursor-pointer" :class="{ 'opacity-50': !k.enabled }">
+              <input type="checkbox" :checked="editingCol.rootIds.includes(k.id)" @change="toggleMember(k.id)">
+              <span class="codicon" :class="`codicon-${kindOf(k.kind).icon}`" />
+              <span>{{ k.name }}</span>
+              <span class="hint">{{ kindOf(k.kind).label }}</span>
+            </label>
+          </div>
+        </div>
+        <div class="flex justify-end gap-2 mt-2">
+          <button type="button" class="btn-secondary" @click="editingCol = null">取消</button>
+          <button type="submit" class="btn-primary" :disabled="saving || !editingCol.name.trim()">保存</button>
         </div>
         </div>
       </form>
       </form>
     </div>
     </div>

+ 50 - 16
apps/admin/src/views/ProfilesView.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
 import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useRoute, useRouter } from 'vue-router'
-import type { CodexPluginDto, KnowledgeRootDto, McpServerDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
+import type { CodexPluginDto, KnowledgeCollectionDto, KnowledgeRootDto, McpServerDto, PathRule, ProfileDto, ProfileInput, ProfileVersionDto, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import PageHeader from '../components/PageHeader.vue'
 import SidePane from '../components/SidePane.vue'
 import SidePane from '../components/SidePane.vue'
 import { admin, client, errorMessage } from '../lib/api'
 import { admin, client, errorMessage } from '../lib/api'
@@ -14,6 +14,7 @@ const route = useRoute()
 const router = useRouter()
 const router = useRouter()
 const profiles = ref<ProfileDto[]>([])
 const profiles = ref<ProfileDto[]>([])
 const knowledge = ref<KnowledgeRootDto[]>([])
 const knowledge = ref<KnowledgeRootDto[]>([])
+const collections = ref<KnowledgeCollectionDto[]>([])
 const skills = ref<SkillInfo[]>([])
 const skills = ref<SkillInfo[]>([])
 const mcpServers = ref<McpServerDto[]>([])
 const mcpServers = ref<McpServerDto[]>([])
 function toggleMcp(id: string) {
 function toggleMcp(id: string) {
@@ -56,7 +57,7 @@ const modelLabels = computed(() => providers.value.find(p => p.id === draft.valu
 const blank = (): ProfileInput => ({
 const blank = (): ProfileInput => ({
   name: '', description: '', provider: 'claude', model: providers.value.find(p => p.id === 'claude')?.defaultModel ?? 'sonnet',
   name: '', description: '', provider: 'claude', model: providers.value.find(p => p.id === 'claude')?.defaultModel ?? 'sonnet',
   systemPrompt: '', permissionMode: 'ask', allowedTools: [], disallowedTools: [], skills: 'all',
   systemPrompt: '', permissionMode: 'ask', allowedTools: [], disallowedTools: [], skills: 'all',
-  fileAccess: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRootIds: [], mcpServerIds: [], codexPluginIds: [], isDefault: false,
+  fileAccess: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRootIds: [], knowledgeCollectionIds: [], mcpServerIds: [], codexPluginIds: [], isDefault: false,
 })
 })
 
 
 function toInput(p: ProfileDto): ProfileInput {
 function toInput(p: ProfileDto): ProfileInput {
@@ -65,8 +66,8 @@ function toInput(p: ProfileDto): ProfileInput {
 }
 }
 
 
 async function load() {
 async function load() {
-  ;[profiles.value, knowledge.value, skills.value, providers.value, mcpServers.value] = await Promise.all([
-    admin.profiles(), admin.knowledge(), admin.skills(), client.providers(), admin.mcp.state().then(s => s.servers).catch(() => []),
+  ;[profiles.value, knowledge.value, collections.value, skills.value, providers.value, mcpServers.value] = await Promise.all([
+    admin.profiles(), admin.knowledge(), admin.knowledgeCollections().catch(() => []), admin.skills(), client.providers(), admin.mcp.state().then(s => s.servers).catch(() => []),
   ])
   ])
 }
 }
 
 
@@ -106,11 +107,11 @@ watch(() => draft.value?.provider, async p => {
 
 
 // 文件访问策略生效预览
 // 文件访问策略生效预览
 let previewTimer: ReturnType<typeof setTimeout> | undefined
 let previewTimer: ReturnType<typeof setTimeout> | undefined
-watch(() => draft.value && JSON.stringify([draft.value.fileAccess, draft.value.knowledgeRootIds]), () => {
+watch(() => draft.value && JSON.stringify([draft.value.fileAccess, draft.value.knowledgeRootIds, draft.value.knowledgeCollectionIds]), () => {
   clearTimeout(previewTimer)
   clearTimeout(previewTimer)
   if (!draft.value) return
   if (!draft.value) return
   previewTimer = setTimeout(async () => {
   previewTimer = setTimeout(async () => {
-    preview.value = await admin.previewAccess(draft.value!.fileAccess, draft.value!.knowledgeRootIds).catch(() => null)
+    preview.value = await admin.previewAccess(draft.value!.fileAccess, draft.value!.knowledgeRootIds, draft.value!.knowledgeCollectionIds ?? []).catch(() => null)
   }, 300)
   }, 300)
 }, { immediate: true })
 }, { immediate: true })
 
 
@@ -130,6 +131,20 @@ function toggleKnowledge(kid: string) {
   const d = draft.value!
   const d = draft.value!
   d.knowledgeRootIds = d.knowledgeRootIds.includes(kid) ? d.knowledgeRootIds.filter(k => k !== kid) : [...d.knowledgeRootIds, kid]
   d.knowledgeRootIds = d.knowledgeRootIds.includes(kid) ? d.knowledgeRootIds.filter(k => k !== kid) : [...d.knowledgeRootIds, kid]
 }
 }
+function toggleCollection(cid: string) {
+  const d = draft.value!
+  const ids = d.knowledgeCollectionIds ?? []
+  d.knowledgeCollectionIds = ids.includes(cid) ? ids.filter(c => c !== cid) : [...ids, cid]
+}
+/** 实际可用的资料库:单独勾选的 ∪ 集合的成员(停用的不算),与服务端一致 */
+const effectiveKnowledge = computed(() => {
+  const d = draft.value
+  if (!d) return []
+  const ids = new Set(d.knowledgeRootIds)
+  for (const c of collections.value) if (d.knowledgeCollectionIds?.includes(c.id)) c.rootIds.forEach(r => ids.add(r))
+  return knowledge.value.filter(k => k.enabled && ids.has(k.id))
+})
+const KB_KIND: Record<KnowledgeRootDto['kind'], string> = { managed: '托管目录', local: '本机目录', remote: '远程存储', link: '网络链接' }
 const listText = (list: string[]) => list.join(', ')
 const listText = (list: string[]) => list.join(', ')
 const parseList = (s: string) => s.split(/[,,\n]/).map(x => x.trim()).filter(Boolean)
 const parseList = (s: string) => s.split(/[,,\n]/).map(x => x.trim()).filter(Boolean)
 const rulePaths = (rules: PathRule[]) => rules.filter((r): r is { path: string } => 'path' in r).map(r => r.path).join('\n')
 const rulePaths = (rules: PathRule[]) => rules.filter((r): r is { path: string } => 'path' in r).map(r => r.path).join('\n')
@@ -155,7 +170,7 @@ async function save() {
 // 历史版本
 // 历史版本
 const FIELD_LABELS: Record<string, string> = {
 const FIELD_LABELS: Record<string, string> = {
   name: '名称', description: '说明', provider: '引擎', model: '模型', systemPrompt: '系统提示词', permissionMode: '权限模式',
   name: '名称', description: '说明', provider: '引擎', model: '模型', systemPrompt: '系统提示词', permissionMode: '权限模式',
-  allowedTools: '允许的工具', disallowedTools: '禁用的工具', skills: '技能', fileAccess: '文件访问策略', knowledgeRootIds: '资料库', mcpServerIds: '第三方 MCP 服务', codexPluginIds: 'Codex 插件',
+  allowedTools: '允许的工具', disallowedTools: '禁用的工具', skills: '技能', fileAccess: '文件访问策略', knowledgeRootIds: '资料库', knowledgeCollectionIds: '资料集合', mcpServerIds: '第三方 MCP 服务', codexPluginIds: 'Codex 插件',
 }
 }
 const versions = ref<ProfileVersionDto[] | null>(null)
 const versions = ref<ProfileVersionDto[] | null>(null)
 async function openVersions() {
 async function openVersions() {
@@ -328,15 +343,34 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
               <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="restricted">受限(只能读列出的路径,推荐)</label>
               <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="restricted">受限(只能读列出的路径,推荐)</label>
               <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="broad">宽松(全盘可读,排除禁止项)</label>
               <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="draft.fileAccess.mode" type="radio" value="broad">宽松(全盘可读,排除禁止项)</label>
             </div>
             </div>
-            <div>
-              <div class="label">资料库</div>
-              <div v-if="!knowledge.length" class="hint">还没有资料库,可以在"资料库"页面添加</div>
-              <div class="flex flex-col gap-1">
-                <label v-for="k in knowledge" :key="k.id" class="flex items-center gap-2 cursor-pointer" :class="{ 'opacity-50': !k.enabled }">
-                  <input type="checkbox" :checked="draft.knowledgeRootIds.includes(k.id)" @change="toggleKnowledge(k.id)">
-                  <span>{{ k.name }}</span><span class="font-mono hint">{{ k.path }}</span>
-                  <span class="badge bg-code">{{ k.access === 'write' ? '读写' : '只读' }}</span>
-                </label>
+            <div class="flex flex-col gap-2">
+              <div class="label">资料库(在"资料库"页面添加;勾选集合等于勾选它的全部成员,集合成员变化时自动生效)</div>
+              <div v-if="!knowledge.length && !collections.length" class="hint">还没有资料库</div>
+              <div v-if="collections.length">
+                <div class="text-[12px] text-fg-muted mb-1">资料集合</div>
+                <div class="flex flex-col gap-1">
+                  <label v-for="c in collections" :key="c.id" class="flex items-center gap-2 cursor-pointer">
+                    <input type="checkbox" :checked="draft.knowledgeCollectionIds?.includes(c.id)" @change="toggleCollection(c.id)">
+                    <span class="codicon codicon-folder-library" /><span>{{ c.name }}</span>
+                    <span class="hint">{{ c.rootIds.length }} 个资料库</span>
+                  </label>
+                </div>
+              </div>
+              <div v-if="knowledge.length">
+                <div class="text-[12px] text-fg-muted mb-1">单个资料库</div>
+                <div class="flex flex-col gap-1">
+                  <label v-for="k in knowledge" :key="k.id" class="flex items-center gap-2 cursor-pointer" :class="{ 'opacity-50': !k.enabled }">
+                    <input type="checkbox" :checked="draft.knowledgeRootIds.includes(k.id)" @change="toggleKnowledge(k.id)">
+                    <span>{{ k.name }}</span><span class="hint">{{ KB_KIND[k.kind] }}</span>
+                    <span class="badge bg-code">{{ k.access === 'write' ? 'Agent 可写' : '只读' }}</span>
+                    <span v-if="!k.enabled" class="badge bg-code">已停用</span>
+                  </label>
+                </div>
+              </div>
+              <div v-if="draft.knowledgeRootIds.length || draft.knowledgeCollectionIds?.length" class="text-[12px]">
+                <span class="text-fg-muted">实际可用({{ effectiveKnowledge.length }}):</span>
+                <span v-if="!effectiveKnowledge.length" class="hint">无</span>
+                {{ effectiveKnowledge.map(k => k.name).join('、') }}
               </div>
               </div>
             </div>
             </div>
             <div class="grid grid-cols-3 gap-3">
             <div class="grid grid-cols-3 gap-3">

+ 1 - 1
apps/admin/src/views/SettingsView.vue

@@ -34,7 +34,7 @@ watch(() => draft.value && JSON.stringify(draft.value.paths), () => {
   timer = setTimeout(async () => {
   timer = setTimeout(async () => {
     for (const k of PATH_KEYS) {
     for (const k of PATH_KEYS) {
       const p = draft.value?.paths[k]
       const p = draft.value?.paths[k]
-      pathChecks.value[k] = p ? await admin.checkPath(p, 'generic').catch(() => null) : null
+      pathChecks.value[k] = p ? await admin.checkPath(p).catch(() => null) : null
     }
     }
   }, 400)
   }, 400)
 })
 })

+ 30 - 0
apps/server/drizzle/0024_knowledge_sources.sql

@@ -0,0 +1,30 @@
+CREATE TABLE `knowledge_collections` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`root_ids` text DEFAULT '[]' NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `knowledge_collections_name_unique` ON `knowledge_collections` (`name`);--> statement-breakpoint
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_knowledge_roots` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`kind` text DEFAULT 'managed' NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`access` text DEFAULT 'read' NOT NULL,
+	`enabled` integer DEFAULT true NOT NULL,
+	`source` text,
+	`secrets` text DEFAULT '{}' NOT NULL,
+	`url` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+INSERT INTO `__new_knowledge_roots`("id", "name", "kind", "description", "access", "enabled", "source", "secrets", "url", "created_at", "updated_at") SELECT "id", "name", 'local', "description", 'read', "enabled", json_object('path', "path", 'uuid', NULL, 'relPath', NULL), '{}', NULL, "created_at", "updated_at" FROM `knowledge_roots`;--> statement-breakpoint
+DROP TABLE `knowledge_roots`;--> statement-breakpoint
+ALTER TABLE `__new_knowledge_roots` RENAME TO `knowledge_roots`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+ALTER TABLE `profiles` ADD `knowledge_collection_ids` text DEFAULT '[]' NOT NULL;

+ 3273 - 0
apps/server/drizzle/meta/0024_snapshot.json

@@ -0,0 +1,3273 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "e5e66a9e-5fb8-42c7-9575-9e54d823ea5f",
+  "prevId": "0f31da2a-5eb1-441e-83b8-3b4e619e7176",
+  "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": "'\ud83e\udd16'"
+        },
+        "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_collections": {
+      "name": "knowledge_collections",
+      "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": "''"
+        },
+        "root_ids": {
+          "name": "root_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'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "knowledge_collections_name_unique": {
+          "name": "knowledge_collections_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "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
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'managed'"
+        },
+        "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
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "secrets": {
+          "name": "secrets",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "url": {
+          "name": "url",
+          "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": {},
+      "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": "'[]'"
+        },
+        "knowledge_collection_ids": {
+          "name": "knowledge_collection_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

@@ -169,6 +169,13 @@
       "when": 1793782887650,
       "when": 1793782887650,
       "tag": "0023_skill_semver",
       "tag": "0023_skill_semver",
       "breakpoints": true
       "breakpoints": true
+    },
+    {
+      "idx": 24,
+      "version": "6",
+      "when": 1793935751356,
+      "tag": "0024_knowledge_sources",
+      "breakpoints": true
     }
     }
   ]
   ]
 }
 }

+ 3 - 2
apps/server/src/app.ts

@@ -8,7 +8,8 @@ import { ClaudeProvider } from './providers/claude/adapter'
 import { CodexProvider } from './providers/codex/adapter'
 import { CodexProvider } from './providers/codex/adapter'
 import type { AgentProvider } from './providers/types'
 import type { AgentProvider } from './providers/types'
 import { AdminAuthService } from './services/admin-auth'
 import { AdminAuthService } from './services/admin-auth'
-import { ApiKeyService, AuditService, KnowledgeService, ProfileService } from './services/catalog'
+import { KnowledgeService } from './knowledge/service'
+import { ApiKeyService, AuditService, ProfileService } from './services/catalog'
 import { EventStore } from './services/event-store'
 import { EventStore } from './services/event-store'
 import { FileService } from './services/files'
 import { FileService } from './services/files'
 import { InteractionService } from './services/interactions'
 import { InteractionService } from './services/interactions'
@@ -60,7 +61,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const events = new EventStore(db)
   const events = new EventStore(db)
   const files = new FileService(db)
   const files = new FileService(db)
   const profiles = new ProfileService(db, settings)
   const profiles = new ProfileService(db, settings)
-  const knowledge = new KnowledgeService(db, settings)
+  const knowledge = new KnowledgeService(db)
   const apiKeys = new ApiKeyService(db, profiles)
   const apiKeys = new ApiKeyService(db, profiles)
   const audit = new AuditService(db)
   const audit = new AuditService(db)
   const usage = new UsageService(db)
   const usage = new UsageService(db)

+ 8 - 0
apps/server/src/config.ts

@@ -56,6 +56,14 @@ export const customSkillStoreDir = () => path.join(config.dataDir, 'data', 'cust
 /** 回归用例的附件(docs/design/13-bot-teams.md §12.2) */
 /** 回归用例的附件(docs/design/13-bot-teams.md §12.2) */
 export const teamCasesDir = () => path.join(config.dataDir, 'data', 'team-cases')
 export const teamCasesDir = () => path.join(config.dataDir, 'data', 'team-cases')
 
 
+/**
+ * 资料库(docs/design/21-knowledge-sources.md):托管目录在 files/<id>(平台创建、可在后台编辑);
+ * 本机目录与远程存储由挂载服务挂到 mnt/<id>(只读);挂载服务的 socket、密钥与状态在 mounter/(Agent 不可见)
+ */
+export const knowledgeFilesDir = () => path.join(config.dataDir, 'knowledge', 'files')
+export const knowledgeMountDir = () => path.join(config.dataDir, 'knowledge', 'mnt')
+export const mounterDir = () => path.join(config.dataDir, 'mounter')
+
 function parseTrustProxy(v: string | undefined): boolean | string[] {
 function parseTrustProxy(v: string | undefined): boolean | string[] {
   if (!v || v === 'false') return false
   if (!v || v === 'false') return false
   if (v === 'true') return true
   if (v === 'true') return true

+ 22 - 2
apps/server/src/db/schema.ts

@@ -1,7 +1,7 @@
 import { sql } from 'drizzle-orm'
 import { sql } from 'drizzle-orm'
 import { index, integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
 import { index, integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
 import type {
 import type {
-  FileAccessPolicy, InteractionRequest, InteractionResponse, McpToolHints, McpToolPolicy, PermissionMode, ProfileSnapshot, ScheduleOutcome, ScheduleSpec, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
+  FileAccessPolicy, InteractionRequest, InteractionResponse, KnowledgeLocalSource, KnowledgeRemoteType, McpToolHints, McpToolPolicy, PermissionMode, ProfileSnapshot, ScheduleOutcome, ScheduleSpec, TeamDebug, TeamFileRef, TeamLimitKey, TeamLimits,
 } from '@agent-server/shared'
 } from '@agent-server/shared'
 
 
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
 const now = sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`
@@ -35,6 +35,7 @@ export const profiles = sqliteTable('profiles', {
   skills: text('skills', { mode: 'json' }).$type<string[] | 'all'>().notNull().default(sql`'"all"'`),
   skills: text('skills', { mode: 'json' }).$type<string[] | 'all'>().notNull().default(sql`'"all"'`),
   fileAccess: text('file_access', { mode: 'json' }).$type<FileAccessPolicy>().notNull(),
   fileAccess: text('file_access', { mode: 'json' }).$type<FileAccessPolicy>().notNull(),
   knowledgeRootIds: text('knowledge_root_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   knowledgeRootIds: text('knowledge_root_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
+  knowledgeCollectionIds: text('knowledge_collection_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   mcpServerIds: text('mcp_server_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   mcpServerIds: text('mcp_server_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   codexPluginIds: text('codex_plugin_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   codexPluginIds: text('codex_plugin_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
   isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
@@ -50,10 +51,29 @@ export const profiles = sqliteTable('profiles', {
 export const knowledgeRoots = sqliteTable('knowledge_roots', {
 export const knowledgeRoots = sqliteTable('knowledge_roots', {
   id: text('id').primaryKey(),
   id: text('id').primaryKey(),
   name: text('name').notNull(),
   name: text('name').notNull(),
-  path: text('path').notNull(),
+  kind: text('kind', { enum: ['managed', 'local', 'remote', 'link'] }).notNull().default('managed'),
   description: text('description').notNull().default(''),
   description: text('description').notNull().default(''),
+  /** 只有托管目录可以是 write */
   access: text('access', { enum: ['read', 'write'] }).notNull().default('read'),
   access: text('access', { enum: ['read', 'write'] }).notNull().default('read'),
   enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
   enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  /** 本机目录:{ path, uuid, relPath };远程存储:{ type, params }(不含凭据) */
+  source: text('source', { mode: 'json' }).$type<KnowledgeSourceRow | null>(),
+  /** 远程存储的凭据(不出现在 DTO 中) */
+  secrets: text('secrets', { mode: 'json' }).$type<Record<string, string>>().notNull().default(sql`'{}'`),
+  /** 网络链接 */
+  url: text('url'),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})
+
+export type KnowledgeSourceRow = KnowledgeLocalSource | { type: KnowledgeRemoteType; params: Record<string, string> }
+
+/** 资料集合(docs/design/21-knowledge-sources.md §8) */
+export const knowledgeCollections = sqliteTable('knowledge_collections', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull().unique(),
+  description: text('description').notNull().default(''),
+  rootIds: text('root_ids', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   createdAt: createdAt(),
   createdAt: createdAt(),
   updatedAt: updatedAt(),
   updatedAt: updatedAt(),
 })
 })

+ 33 - 15
apps/server/src/http/routes/admin.ts

@@ -3,13 +3,13 @@ import fs from 'node:fs'
 import type { FastifyInstance, FastifyRequest } from 'fastify'
 import type { FastifyInstance, FastifyRequest } from 'fastify'
 import mime from 'mime-types'
 import mime from 'mime-types'
 import { z } from 'zod'
 import { z } from 'zod'
-import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpCredentialInput, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
+import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpCredentialInput, type McpServerInput, type DeepPartial, type FileAccessPolicy, type KnowledgeCollectionInput, type KnowledgeInput, type KnowledgeRootDto, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
 import type { App } from '../../app'
 import { AppError, badRequest, notFound } from '../../lib/errors'
 import { AppError, badRequest, notFound } from '../../lib/errors'
 import { runClaudeCli } from '../../providers/claude/cli'
 import { runClaudeCli } from '../../providers/claude/cli'
 import { CodexProvider } from '../../providers/codex/adapter'
 import { CodexProvider } from '../../providers/codex/adapter'
-import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
-import { parse, type AuditKind, type KnowledgeInput } from '../../services/catalog'
+import { checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
+import { parse, type AuditKind } from '../../services/catalog'
 import { listServerDirs } from '../../services/server-dirs'
 import { listServerDirs } from '../../services/server-dirs'
 import { defaultSettings } from '../../services/settings'
 import { defaultSettings } from '../../services/settings'
 import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
 import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
@@ -415,10 +415,7 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     g.get<{ Querystring: { refresh?: string } }>('/plugins', async req => app.plugins.state(req.query.refresh === '1'))
     g.get<{ Querystring: { refresh?: string } }>('/plugins', async req => app.plugins.state(req.query.refresh === '1'))
     g.post('/plugins/actions', async req => app.plugins.act(parse(pluginActionSchema, req.body)))
     g.post('/plugins/actions', async req => app.plugins.act(parse(pluginActionSchema, req.body)))
 
 
-    g.post('/paths/check', async req => {
-      const { path, purpose } = parse(z.object({ path: z.string().min(1), purpose: z.enum(['knowledge', 'generic']).default('knowledge') }), req.body)
-      return purpose === 'generic' ? checkPathBasic(path) : checkKnowledgePath(path, app.settings.get())
-    })
+    g.post('/paths/check', async req => checkPathBasic(parse(z.object({ path: z.string().min(1) }), req.body).path))
 
 
     // Profile
     // Profile
     g.get('/profiles', async () => app.profiles.list())
     g.get('/profiles', async () => app.profiles.list())
@@ -451,33 +448,54 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     })
     /** 文件访问策略生效预览(06 §4.3) */
     /** 文件访问策略生效预览(06 §4.3) */
     g.post('/access/preview', async req => {
     g.post('/access/preview', async req => {
-      const body = req.body as { fileAccess: FileAccessPolicy; knowledgeRootIds: string[] }
+      const body = req.body as { fileAccess: FileAccessPolicy; knowledgeRootIds: string[]; knowledgeCollectionIds?: string[] }
       const settings = app.settings.get()
       const settings = app.settings.get()
+      const roots = app.knowledge.effective({ knowledgeRootIds: body.knowledgeRootIds ?? [], knowledgeCollectionIds: body.knowledgeCollectionIds ?? [] })
       return toResolvedDto(resolveAccess({
       return toResolvedDto(resolveAccess({
         policy: body.fileAccess,
         policy: body.fileAccess,
-        knowledgeRoots: app.knowledge.list(),
-        selectedKnowledgeRootIds: body.knowledgeRootIds ?? [],
+        knowledgeRoots: roots,
+        selectedKnowledgeRootIds: roots.map(k => k.id),
         workspaceDir: `${settings.paths.workspaceRoot}/<会话ID>`,
         workspaceDir: `${settings.paths.workspaceRoot}/<会话ID>`,
         settings,
         settings,
       }))
       }))
     })
     })
 
 
-    // 资料库
+    // 资料库与资料集合(docs/design/21-knowledge-sources.md)
+    const kbKind = { managed: '托管目录', local: '本机目录', remote: '远程存储', link: '网络链接' } as const
+    const kbWhere = (k: KnowledgeRootDto) => k.kind === 'link' ? k.url : k.kind === 'local' ? k.local?.path : k.kind === 'remote' ? k.remote?.type : k.path
     g.get('/knowledge', async () => app.knowledge.list())
     g.get('/knowledge', async () => app.knowledge.list())
     g.post('/knowledge', async (req, reply) => {
     g.post('/knowledge', async (req, reply) => {
       reply.code(201)
       reply.code(201)
       const k = app.knowledge.create(req.body as KnowledgeInput)
       const k = app.knowledge.create(req.body as KnowledgeInput)
-      app.audit.log({ actor: 'admin', kind: 'admin', summary: `添加资料库:${k.name}(${k.path})` })
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `添加资料库:${k.name}(${kbKind[k.kind]}:${kbWhere(k)})` })
       return k
       return k
     })
     })
     g.put<IdParams>('/knowledge/:id', async req => {
     g.put<IdParams>('/knowledge/:id', async req => {
       const k = app.knowledge.update(req.params.id, req.body as KnowledgeInput)
       const k = app.knowledge.update(req.params.id, req.body as KnowledgeInput)
-      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改资料库:${k.name}(${k.path})` })
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改资料库:${k.name}(${kbKind[k.kind]}:${kbWhere(k)})` })
       return k
       return k
     })
     })
     g.delete<IdParams>('/knowledge/:id', async (req, reply) => {
     g.delete<IdParams>('/knowledge/:id', async (req, reply) => {
-      app.knowledge.delete(req.params.id)
-      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除资料库:${req.params.id}` })
+      const k = app.knowledge.delete(req.params.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除资料库:${k.name}(${kbKind[k.kind]}${k.kind === 'managed' ? ',连同其中的文件' : ''})` })
+      reply.code(204)
+    })
+    g.post('/knowledge/test-link', async req => app.knowledge.testLink(parse(z.object({ url: z.string() }), req.body).url))
+    g.get('/knowledge-collections', async () => app.knowledge.collections())
+    g.post('/knowledge-collections', async (req, reply) => {
+      reply.code(201)
+      const c = app.knowledge.createCollection(req.body as KnowledgeCollectionInput)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `添加资料集合:${c.name}(${c.rootIds.length} 个资料库)` })
+      return c
+    })
+    g.put<IdParams>('/knowledge-collections/:id', async req => {
+      const c = app.knowledge.updateCollection(req.params.id, req.body as KnowledgeCollectionInput)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改资料集合:${c.name}(${c.rootIds.length} 个资料库)` })
+      return c
+    })
+    g.delete<IdParams>('/knowledge-collections/:id', async (req, reply) => {
+      const c = app.knowledge.deleteCollection(req.params.id)
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除资料集合:${c.name}` })
       reply.code(204)
       reply.code(204)
     })
     })
 
 

+ 36 - 0
apps/server/src/knowledge/describe.ts

@@ -0,0 +1,36 @@
+// 资料库清单:系统提示词与 kb_list_roots 共用;网络链接的放行范围(docs/design/21-knowledge-sources.md §7)
+import type { KnowledgeRootDto, KnowledgeState } from '@agent-server/shared'
+
+const KIND_LABEL: Record<KnowledgeRootDto['kind'], string> = { managed: '托管目录', local: '本机目录', remote: '远程存储', link: '网络链接' }
+
+const STATE_LABEL: Record<KnowledgeState, string> = {
+  ok: '正常', path_missing: '路径不存在', device_missing: '设备未连接', not_allowed: '位置不允许',
+  unreachable: '无法连接', auth_failed: '认证失败', mounter_down: '挂载服务不可用', pending: '正在挂载',
+}
+
+export const knowledgeStateLabel = (s: KnowledgeState) => STATE_LABEL[s]
+
+export function describeKnowledge(roots: KnowledgeRootDto[]): string {
+  return roots.map(k => {
+    const tags = [KIND_LABEL[k.kind], k.access === 'write' ? '可读写' : '只读']
+    if (k.status.state !== 'ok') tags.push(`暂不可用:${k.status.message ?? STATE_LABEL[k.status.state]}`)
+    const lines = [`- ${k.name}(${tags.join(',')}):${k.kind === 'link' ? k.url : k.path}`]
+    if (k.description) lines.push(`  ${k.description}`)
+    if (k.kind === 'link') lines.push(`  用 web_fetch 读取;同一站点(${originOf(k.url)})下的地址都可以访问,包括内网地址`)
+    if (k.kind === 'remote') lines.push('  远程资料:先按文件名和目录结构查找,再读取具体文件;不要对整个目录 grep -r(相当于下载全部文件)')
+    return lines.join('\n')
+  }).join('\n')
+}
+
+/** 当前会话可以访问的链接站点(同源放行内网) */
+export function linkOrigins(roots: KnowledgeRootDto[]): string[] {
+  return [...new Set(roots.filter(k => k.kind === 'link' && k.url).map(k => originOf(k.url)))].filter(Boolean)
+}
+
+function originOf(url: string | null): string {
+  try {
+    return new URL(url ?? '').origin
+  } catch {
+    return ''
+  }
+}

+ 268 - 0
apps/server/src/knowledge/service.ts

@@ -0,0 +1,268 @@
+// 资料库与资料集合(docs/design/21-knowledge-sources.md):
+// 托管目录(平台自己的目录)、本机目录与远程存储(挂载服务提供,只读)、网络链接(Agent 用 web_fetch 读取)
+import fs from 'node:fs'
+import path from 'node:path'
+import { eq } from 'drizzle-orm'
+import { z } from 'zod'
+import type {
+  KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeLocalSource, KnowledgeRootDto, KnowledgeStatus,
+} from '@agent-server/shared'
+import { knowledgeFilesDir, knowledgeMountDir } from '../config'
+import type { DB } from '../db'
+import { knowledgeCollections, knowledgeRoots, profiles } from '../db/schema'
+import { AppError, badRequest, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { parse } from '../services/catalog'
+
+type Row = typeof knowledgeRoots.$inferSelect
+
+const inputSchema = z.object({
+  kind: z.enum(['managed', 'local', 'remote', 'link']),
+  name: z.string().trim().min(1, '名称不能为空').max(60, '名称不能超过 60 个字'),
+  description: z.string().max(1000).default(''),
+  enabled: z.boolean().default(true),
+  access: z.enum(['read', 'write']).default('read'),
+  url: z.string().trim().optional(),
+  localPath: z.string().trim().optional(),
+})
+
+const collectionSchema = z.object({
+  name: z.string().trim().min(1, '名称不能为空').max(60, '名称不能超过 60 个字'),
+  description: z.string().max(1000).default(''),
+  rootIds: z.array(z.string()).max(500),
+})
+
+const OK: KnowledgeStatus = { state: 'ok', message: null, since: null }
+
+/** 挂载服务提供的本机目录、远程存储状态;挂载服务不可用时为 null */
+export type MountStatusSource = (id: string) => KnowledgeStatus | null
+
+export class KnowledgeService {
+  private mountStatus: MountStatusSource = () => null
+
+  constructor(private db: DB, private onChanged: () => void = () => {}) {}
+
+  setMountStatusSource(source: MountStatusSource) {
+    this.mountStatus = source
+  }
+
+  list(): KnowledgeRootDto[] {
+    const memberOf = this.memberships()
+    return this.db.select().from(knowledgeRoots).orderBy(knowledgeRoots.name).all().map(r => this.toDto(r, memberOf.get(r.id) ?? []))
+  }
+
+  get(id: string): KnowledgeRootDto {
+    const row = this.db.select().from(knowledgeRoots).where(eq(knowledgeRoots.id, id)).get()
+    if (!row) throw notFound('资料库')
+    return this.toDto(row, this.memberships().get(id) ?? [])
+  }
+
+  create(input: KnowledgeInput): KnowledgeRootDto {
+    const data = this.validate(input)
+    this.assertNameFree(data.name)
+    const id = newId('kb')
+    this.db.insert(knowledgeRoots).values({ id, ...data }).run()
+    if (data.kind === 'managed') fs.mkdirSync(managedDir(id), { recursive: true })
+    this.onChanged()
+    return this.get(id)
+  }
+
+  update(id: string, input: KnowledgeInput): KnowledgeRootDto {
+    const prev = this.get(id)
+    const data = this.validate(input)
+    if (data.kind !== prev.kind) throw badRequest('资料库类型不能修改,请新建一个资料库')
+    this.assertNameFree(data.name, id)
+    this.db.update(knowledgeRoots).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeRoots.id, id)).run()
+    if (data.kind === 'managed') fs.mkdirSync(managedDir(id), { recursive: true })
+    this.onChanged()
+    return this.get(id)
+  }
+
+  /** 删除资料库:从各集合中移除;托管目录连同其中的文件一起删除 */
+  delete(id: string): KnowledgeRootDto {
+    const k = this.get(id)
+    this.db.transaction(tx => {
+      for (const c of tx.select().from(knowledgeCollections).all()) {
+        if (c.rootIds.includes(id)) tx.update(knowledgeCollections).set({ rootIds: c.rootIds.filter(r => r !== id), updatedAt: nowIso() }).where(eq(knowledgeCollections.id, c.id)).run()
+      }
+      tx.delete(knowledgeRoots).where(eq(knowledgeRoots.id, id)).run()
+    })
+    if (k.kind === 'managed') fs.rmSync(managedDir(id), { recursive: true, force: true })
+    this.onChanged()
+    return k
+  }
+
+  /** Profile 实际可用的资料库:单独勾选的 ∪ 所勾选集合的成员,去掉停用和已删除的 */
+  effective(p: { knowledgeRootIds: string[]; knowledgeCollectionIds?: string[] }): KnowledgeRootDto[] {
+    const ids = new Set(p.knowledgeRootIds)
+    if (p.knowledgeCollectionIds?.length) {
+      for (const c of this.db.select().from(knowledgeCollections).all()) {
+        if (p.knowledgeCollectionIds.includes(c.id)) c.rootIds.forEach(id => ids.add(id))
+      }
+    }
+    return this.list().filter(k => k.enabled && ids.has(k.id))
+  }
+
+  /** 后台"测试访问":管理员登记的地址,可以是内网 */
+  async testLink(url: string): Promise<KnowledgeLinkTestDto> {
+    const u = parseLink(url)
+    try {
+      const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(10_000), headers: { 'user-agent': 'Mozilla/5.0 (compatible; AgentServer/0.1; +knowledge-link-test)' } })
+      const contentType = res.headers.get('content-type')?.split(';')[0]?.trim() ?? null
+      let title: string | null = null
+      if (contentType === 'text/html') {
+        const html = (await readHead(res, 256 * 1024)).toString('utf8')
+        title = /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim().replace(/\s+/g, ' ').slice(0, 200) || null
+      } else {
+        await res.body?.cancel()
+      }
+      return { ok: res.ok, status: res.status, url: res.url, contentType, title, error: res.ok ? null : `HTTP ${res.status}` }
+    } catch (err) {
+      const cause = (err as { cause?: { message?: string } }).cause?.message
+      return { ok: false, status: null, url: u.toString(), contentType: null, title: null, error: cause ?? (err as Error).message }
+    }
+  }
+
+  // ---- 资料集合 ----
+
+  collections(): KnowledgeCollectionDto[] {
+    const users = this.profileUsers()
+    return this.db.select().from(knowledgeCollections).orderBy(knowledgeCollections.name).all()
+      .map(c => ({ id: c.id, name: c.name, description: c.description, rootIds: c.rootIds, usedBy: users.get(c.id) ?? [], updatedAt: c.updatedAt }))
+  }
+
+  collection(id: string): KnowledgeCollectionDto {
+    const c = this.collections().find(x => x.id === id)
+    if (!c) throw notFound('资料集合')
+    return c
+  }
+
+  createCollection(input: KnowledgeCollectionInput): KnowledgeCollectionDto {
+    const data = this.validateCollection(input)
+    const id = newId('kbc')
+    this.db.insert(knowledgeCollections).values({ id, ...data }).run()
+    this.onChanged()
+    return this.collection(id)
+  }
+
+  updateCollection(id: string, input: KnowledgeCollectionInput): KnowledgeCollectionDto {
+    this.collection(id)
+    const data = this.validateCollection(input, id)
+    this.db.update(knowledgeCollections).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeCollections.id, id)).run()
+    this.onChanged()
+    return this.collection(id)
+  }
+
+  deleteCollection(id: string): KnowledgeCollectionDto {
+    const c = this.collection(id)
+    if (c.usedBy.length) throw new AppError(400, 'collection_in_use', `这个集合正被 Profile 使用:${c.usedBy.map(p => p.name).join('、')},请先在 Profile 中取消勾选`)
+    this.db.delete(knowledgeCollections).where(eq(knowledgeCollections.id, id)).run()
+    this.onChanged()
+    return c
+  }
+
+  // ---- 内部 ----
+
+  private toDto(r: Row, collectionIds: string[]): KnowledgeRootDto {
+    const local = r.kind === 'local' ? r.source as KnowledgeLocalSource : null
+    const remote = r.kind === 'remote' && r.source && 'type' in r.source
+      ? { type: r.source.type, params: r.source.params, secrets: Object.entries(r.secrets).map(([name, v]) => ({ name, isSet: !!v })) }
+      : null
+    return {
+      id: r.id, name: r.name, kind: r.kind, description: r.description, enabled: r.enabled,
+      access: r.kind === 'managed' ? r.access : 'read',
+      path: agentPath(r.kind, r.id), url: r.kind === 'link' ? r.url : null, local, remote,
+      status: this.statusOf(r), collectionIds,
+    }
+  }
+
+  private statusOf(r: Row): KnowledgeStatus {
+    switch (r.kind) {
+      case 'managed': return fs.existsSync(managedDir(r.id)) ? OK : { state: 'path_missing', message: '目录不存在', since: null }
+      case 'link': return OK
+      default: return this.mountStatus(r.id) ?? { state: 'mounter_down', message: '挂载服务不可用', since: null }
+    }
+  }
+
+  private validate(input: KnowledgeInput) {
+    const d = parse(inputSchema, input)
+    const base = { name: d.name, kind: d.kind, description: d.description, enabled: d.enabled, access: 'read' as const, source: null, url: null }
+    switch (d.kind) {
+      case 'managed':
+        return { ...base, access: d.access }
+      case 'link':
+        return { ...base, url: parseLink(d.url ?? '').toString() }
+      default:
+        throw badRequest('本机目录和远程存储需要挂载服务,尚未提供')
+    }
+  }
+
+  private assertNameFree(name: string, self?: string) {
+    const dup = this.db.select({ id: knowledgeRoots.id }).from(knowledgeRoots).where(eq(knowledgeRoots.name, name)).all().find(r => r.id !== self)
+    if (dup) throw badRequest(`名称"${name}"已被使用`)
+  }
+
+  private validateCollection(input: KnowledgeCollectionInput, self?: string) {
+    const d = parse(collectionSchema, input)
+    const dup = this.db.select({ id: knowledgeCollections.id }).from(knowledgeCollections).where(eq(knowledgeCollections.name, d.name)).all().find(r => r.id !== self)
+    if (dup) throw badRequest(`名称"${d.name}"已被使用`)
+    const known = new Set(this.db.select({ id: knowledgeRoots.id }).from(knowledgeRoots).all().map(r => r.id))
+    const missing = d.rootIds.filter(id => !known.has(id))
+    if (missing.length) throw badRequest(`资料库不存在:${missing.join('、')}`)
+    return { ...d, rootIds: [...new Set(d.rootIds)] }
+  }
+
+  /** 资料库 id → 所属集合 */
+  private memberships(): Map<string, string[]> {
+    const m = new Map<string, string[]>()
+    for (const c of this.db.select({ id: knowledgeCollections.id, rootIds: knowledgeCollections.rootIds }).from(knowledgeCollections).all()) {
+      for (const id of c.rootIds) m.set(id, [...(m.get(id) ?? []), c.id])
+    }
+    return m
+  }
+
+  /** 集合 id → 勾选了它的 Profile */
+  private profileUsers(): Map<string, { id: string; name: string }[]> {
+    const m = new Map<string, { id: string; name: string }[]>()
+    for (const p of this.db.select({ id: profiles.id, name: profiles.name, ids: profiles.knowledgeCollectionIds }).from(profiles).all()) {
+      for (const id of p.ids) m.set(id, [...(m.get(id) ?? []), { id: p.id, name: p.name }])
+    }
+    return m
+  }
+}
+
+export const managedDir = (id: string) => path.join(knowledgeFilesDir(), id)
+
+/** Agent 看到的目录:托管目录在 files/<id>,本机目录与远程存储挂载在 mnt/<id>,网络链接没有目录 */
+export function agentPath(kind: KnowledgeRootDto['kind'], id: string): string {
+  if (kind === 'managed') return managedDir(id)
+  if (kind === 'link') return ''
+  return path.join(knowledgeMountDir(), id)
+}
+
+function parseLink(raw: string): URL {
+  let u: URL
+  try {
+    u = new URL(raw)
+  } catch {
+    throw badRequest(`链接格式不正确:${raw || '(空)'}`)
+  }
+  if (!['http:', 'https:'].includes(u.protocol)) throw badRequest('链接只支持 http 和 https')
+  if (u.username || u.password) throw badRequest('链接中不能包含用户名或密码')
+  return u
+}
+
+async function readHead(res: Response, max: number): Promise<Buffer> {
+  if (!res.body) return Buffer.alloc(0)
+  const chunks: Buffer[] = []
+  let size = 0
+  const reader = res.body.getReader()
+  while (size < max) {
+    const { done, value } = await reader.read()
+    if (done) break
+    chunks.push(Buffer.from(value))
+    size += value.byteLength
+  }
+  await reader.cancel().catch(() => {})
+  return Buffer.concat(chunks)
+}

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 import { ulid } from 'ulid'
 
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'spk' | 'sub' | 'sbi' | 'sdl' | 'mcc'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg' | 'spk' | 'sub' | 'sbi' | 'sdl' | 'mcc' | 'kbc'
 
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 
 

+ 5 - 4
apps/server/src/mcp-gateway/builtin.ts

@@ -3,6 +3,7 @@ import path from 'node:path'
 import { z } from 'zod'
 import { z } from 'zod'
 import type { McpBuiltinToolDto } from '@agent-server/shared'
 import type { McpBuiltinToolDto } from '@agent-server/shared'
 import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../services/office'
 import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../services/office'
+import { describeKnowledge, linkOrigins } from '../knowledge/describe'
 import { FetchBlockedError, webFetch } from '../services/web-fetch'
 import { FetchBlockedError, webFetch } from '../services/web-fetch'
 import type { GatewayRunContext } from './tokens'
 import type { GatewayRunContext } from './tokens'
 
 
@@ -63,13 +64,13 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
   {
   {
     name: 'web_fetch',
     name: 'web_fetch',
     description: '抓取网页并转换为 Markdown(HTML),或原样返回文本/JSON;PDF、图片、表格等二进制文件会下载到工作区并返回路径。'
     description: '抓取网页并转换为 Markdown(HTML),或原样返回文本/JSON;PDF、图片、表格等二进制文件会下载到工作区并返回路径。'
-      + '内容过长时只返回前一部分,完整内容保存在工作区。不能访问内网地址。',
+      + '内容过长时只返回前一部分,完整内容保存在工作区。不能访问内网地址(网络链接资料库所在站点除外)。',
     schema: fetchSchema,
     schema: fetchSchema,
     readOnly: true,
     readOnly: true,
     async handler(raw, ctx) {
     async handler(raw, ctx) {
       const args = raw as z.infer<typeof fetchSchema>
       const args = raw as z.infer<typeof fetchSchema>
       try {
       try {
-        const r = await webFetch({ url: args.url, workspaceDir: ctx.workspaceDir, blockPrivate: ctx.settings.network.blockPrivateFetch })
+        const r = await webFetch({ url: args.url, workspaceDir: ctx.workspaceDir, blockPrivate: ctx.settings.network.blockPrivateFetch, allowOrigins: linkOrigins(ctx.knowledgeRoots) })
         const head = `URL: ${r.url}\n状态: ${r.status}\n类型: ${r.contentType}${r.savedPath ? `\n${r.truncated ? '完整内容' : '文件'}已保存到: ${r.savedPath}` : ''}${r.truncated ? '\n(内容过长,以下为前一部分)' : ''}\n\n`
         const head = `URL: ${r.url}\n状态: ${r.status}\n类型: ${r.contentType}${r.savedPath ? `\n${r.truncated ? '完整内容' : '文件'}已保存到: ${r.savedPath}` : ''}${r.truncated ? '\n(内容过长,以下为前一部分)' : ''}\n\n`
         return r.status >= 400 ? fail(head + r.text) : ok(head + r.text)
         return r.status >= 400 ? fail(head + r.text) : ok(head + r.text)
       } catch (err) {
       } catch (err) {
@@ -80,12 +81,12 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
   },
   },
   {
   {
     name: 'kb_list_roots',
     name: 'kb_list_roots',
-    description: '列出当前可用的本地资料库(名称、路径、说明、读写属性)。查找本地资料前先调用它。',
+    description: '列出当前可用的资料库(名称、类型、路径或链接、说明、读写属性、是否可用)。查找资料前先调用它。',
     schema: z.object({}),
     schema: z.object({}),
     readOnly: true,
     readOnly: true,
     async handler(_args, ctx) {
     async handler(_args, ctx) {
       if (!ctx.knowledgeRoots.length) return ok('当前没有可用的资料库。')
       if (!ctx.knowledgeRoots.length) return ok('当前没有可用的资料库。')
-      return ok(ctx.knowledgeRoots.map(k => `- ${k.name}:${k.path}(${k.access === 'write' ? '可读写' : '只读'})${k.description ? `\n  ${k.description}` : ''}`).join('\n'))
+      return ok(describeKnowledge(ctx.knowledgeRoots))
     },
     },
   },
   },
 ]
 ]

+ 1 - 0
apps/server/src/providers/claude/adapter.ts

@@ -76,6 +76,7 @@ export class ClaudeProvider implements AgentProvider {
       access: ctx.access,
       access: ctx.access,
       permissionMode: ctx.permissionMode,
       permissionMode: ctx.permissionMode,
       blockPrivateFetch: ctx.settings.network.blockPrivateFetch,
       blockPrivateFetch: ctx.settings.network.blockPrivateFetch,
+      allowOrigins: ctx.fetchAllowOrigins ?? [],
       cwd: ctx.workspaceDir,
       cwd: ctx.workspaceDir,
       sessionAllowed: allowed,
       sessionAllowed: allowed,
     }
     }

+ 11 - 1
apps/server/src/providers/claude/policy.ts

@@ -43,6 +43,8 @@ export interface PolicyContext {
   access: ResolvedAccess
   access: ResolvedAccess
   permissionMode: PermissionMode
   permissionMode: PermissionMode
   blockPrivateFetch: boolean
   blockPrivateFetch: boolean
+  /** 网络链接资料库所在站点:WebFetch 不拦截内网 */
+  allowOrigins: string[]
   cwd: string
   cwd: string
   /** 本会话中被"本会话允许"过的工具 */
   /** 本会话中被"本会话允许"过的工具 */
   sessionAllowed: Set<string>
   sessionAllowed: Set<string>
@@ -75,7 +77,7 @@ export async function decideTool(tool: string, input: Record<string, unknown>, c
   }
   }
 
 
   if (tool === 'WebFetch') {
   if (tool === 'WebFetch') {
-    if (ctx.blockPrivateFetch) {
+    if (ctx.blockPrivateFetch && !ctx.allowOrigins.includes(originOf(String(input.url ?? '')))) {
       const blocked = await privateUrlReason(String(input.url ?? ''))
       const blocked = await privateUrlReason(String(input.url ?? ''))
       if (blocked) return { kind: 'deny', reason: blocked, audit: 'network_denied' }
       if (blocked) return { kind: 'deny', reason: blocked, audit: 'network_denied' }
     }
     }
@@ -93,6 +95,14 @@ function needsApproval(tool: string, ctx: PolicyContext): ToolDecision {
   return { kind: 'ask' }
   return { kind: 'ask' }
 }
 }
 
 
+function originOf(raw: string): string {
+  try {
+    return new URL(raw).origin
+  } catch {
+    return ''
+  }
+}
+
 /** URL 指向私网、回环或链路本地地址时返回拒绝原因 */
 /** URL 指向私网、回环或链路本地地址时返回拒绝原因 */
 export async function privateUrlReason(raw: string): Promise<string | null> {
 export async function privateUrlReason(raw: string): Promise<string | null> {
   let url: URL
   let url: URL

+ 2 - 0
apps/server/src/providers/types.ts

@@ -28,6 +28,8 @@ export interface RunContext {
   plugins: PluginRef[]
   plugins: PluginRef[]
   input: RunInput
   input: RunInput
   access: ResolvedAccess
   access: ResolvedAccess
+  /** 网络链接资料库所在站点:网页抓取不拦截内网(docs/design/21-knowledge-sources.md §7) */
+  fetchAllowOrigins?: string[]
   settings: AppSettings
   settings: AppSettings
   signal: AbortSignal
   signal: AbortSignal
   emit<T extends AgentEventType>(type: T, payload: EventPayloads[T]): void
   emit<T extends AgentEventType>(type: T, payload: EventPayloads[T]): void

+ 14 - 39
apps/server/src/services/access-policy.ts

@@ -11,7 +11,7 @@ import {
   type PathCheck,
   type PathCheck,
   type ResolvedAccessDto,
   type ResolvedAccessDto,
 } from '@agent-server/shared'
 } from '@agent-server/shared'
-import { communitySkillsDir, config, customSkillsDir } from '../config'
+import { communitySkillsDir, config, customSkillsDir, mounterDir } from '../config'
 import { expandPath, hasGlob, isWithin, realpathLoose } from '../lib/paths'
 import { expandPath, hasGlob, isWithin, realpathLoose } from '../lib/paths'
 import { codexHome } from '../providers/codex/cli'
 import { codexHome } from '../providers/codex/cli'
 
 
@@ -34,8 +34,9 @@ export type AccessDecision = { allowed: true } | { allowed: false; reason: strin
 const RESTRICTED_BASH_DENY = () => [config.home, '/srv', '/mnt', '/media', '/root']
 const RESTRICTED_BASH_DENY = () => [config.home, '/srv', '/mnt', '/media', '/root']
 
 
 export function globalDenyList(settings: AppSettings): string[] {
 export function globalDenyList(settings: AppSettings): string[] {
-  // Codex 的登录凭证位于服务专用的 CODEX_HOME(开发环境不在 ~ 下,需要单独列出)
-  return [...BUILTIN_GLOBAL_DENY, ...settings.security.globalDeny, path.join(codexHome(), 'auth.json')].map(expandPath)
+  // Codex 的登录凭证位于服务专用的 CODEX_HOME(开发环境不在 ~ 下,需要单独列出);
+  // 挂载服务的 socket、密钥与远程存储凭据(docs/design/21-knowledge-sources.md §4.5)
+  return [...BUILTIN_GLOBAL_DENY, ...settings.security.globalDeny, path.join(codexHome(), 'auth.json'), mounterDir()].map(expandPath)
 }
 }
 
 
 export function resolveAccess(opts: {
 export function resolveAccess(opts: {
@@ -61,18 +62,19 @@ export function resolveAccess(opts: {
   // 只开放生效目录;版本库(custom-skill-store)在服务数据目录中,不可见
   // 只开放生效目录;版本库(custom-skill-store)在服务数据目录中,不可见
   readable.add(customSkillsDir())
   readable.add(customSkillsDir())
 
 
+  // 网络链接没有路径;只有托管目录可以写
   for (const id of opts.selectedKnowledgeRootIds) {
   for (const id of opts.selectedKnowledgeRootIds) {
     const root = roots.get(id)
     const root = roots.get(id)
-    if (!root?.enabled) continue
-    const target = root.access === 'write' ? writable : readable
+    if (!root?.enabled || !root.path) continue
+    const target = knowledgeWritable(root) ? writable : readable
     target.add(expandPath(root.path))
     target.add(expandPath(root.path))
   }
   }
   for (const rule of policy.read) {
   for (const rule of policy.read) {
-    const p = ruleToPath(rule, roots)
+    const p = ruleToPath(rule, roots, 'read')
     if (p) readable.add(p)
     if (p) readable.add(p)
   }
   }
   for (const rule of policy.write) {
   for (const rule of policy.write) {
-    const p = ruleToPath(rule, roots)
+    const p = ruleToPath(rule, roots, 'write')
     if (p) writable.add(p)
     if (p) writable.add(p)
   }
   }
   if (policy.mode === 'broad') readable.add('/')
   if (policy.mode === 'broad') readable.add('/')
@@ -92,10 +94,13 @@ export function claudeScratchDir(workspaceDir: string): string {
   return path.join(os.tmpdir(), `claude-${process.getuid?.() ?? 0}`, workspaceDir.replace(/[^a-zA-Z0-9]/g, '-'))
   return path.join(os.tmpdir(), `claude-${process.getuid?.() ?? 0}`, workspaceDir.replace(/[^a-zA-Z0-9]/g, '-'))
 }
 }
 
 
-function ruleToPath(rule: FileAccessPolicy['read'][number], roots: Map<string, KnowledgeRootDto>): string | null {
+const knowledgeWritable = (root: KnowledgeRootDto) => root.kind === 'managed' && root.access === 'write'
+
+function ruleToPath(rule: FileAccessPolicy['read'][number], roots: Map<string, KnowledgeRootDto>, op: AccessOp): string | null {
   if ('path' in rule) return expandPath(rule.path)
   if ('path' in rule) return expandPath(rule.path)
   const root = roots.get(rule.knowledgeRootId)
   const root = roots.get(rule.knowledgeRootId)
-  return root?.enabled ? expandPath(root.path) : null
+  if (!root?.enabled || !root.path || (op === 'write' && !knowledgeWritable(root))) return null
+  return expandPath(root.path)
 }
 }
 
 
 const matcherCache = new Map<string, (p: string) => boolean>()
 const matcherCache = new Map<string, (p: string) => boolean>()
@@ -211,33 +216,3 @@ export function checkPathBasic(raw: string): PathCheck {
   }
   }
   return result
   return result
 }
 }
-
-export function checkKnowledgePath(raw: string, settings: AppSettings): PathCheck {
-  const p = expandPath(raw)
-  const result: PathCheck = { exists: false, readable: false, isDirectory: false, denied: null, underHome: isWithin(p, config.home), message: null }
-  for (const entry of globalDenyList(settings)) {
-    if (matchesEntry(p, entry) || (!hasGlob(entry) && isWithin(entry, p))) {
-      result.denied = entry
-      result.message = `与全局禁止项冲突:${entry}`
-      return result
-    }
-  }
-  if (isWithin(p, realpathLoose(settings.paths.workspaceRoot))) {
-    result.message = '不能把会话工作区目录设为资料库'
-    return result
-  }
-  try {
-    const st = fs.statSync(p)
-    result.exists = true
-    result.isDirectory = st.isDirectory()
-    fs.accessSync(p, fs.constants.R_OK)
-    result.readable = true
-  } catch {
-    result.message ??= result.exists ? '服务进程没有读取权限' : '路径不存在'
-  }
-  if (result.exists && !result.isDirectory) result.message = '资料库必须是目录'
-  if (!result.message && result.underHome) {
-    result.message = `位于 home 目录下;以 systemd 加固方式部署时需要在 unit 中追加 BindReadOnlyPaths=${p}`
-  }
-  return result
-}

+ 25 - 59
apps/server/src/services/catalog.ts

@@ -1,13 +1,12 @@
-// Profile、资料库、Agent Key、审计:后台管理的配置类数据
+// Profile、Agent Key、审计:后台管理的配置类数据(资料库见 knowledge/service.ts)
 import crypto from 'node:crypto'
 import crypto from 'node:crypto'
 import { and, desc, eq, isNull, ne } from 'drizzle-orm'
 import { and, desc, eq, isNull, ne } from 'drizzle-orm'
 import { z } from 'zod'
 import { z } from 'zod'
-import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProfileSnapshot, ProfileVersionDto, ProviderId } from '@agent-server/shared'
+import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, ProfileDto, ProfileInput, ProfileSnapshot, ProfileVersionDto, ProviderId } from '@agent-server/shared'
 import type { DB } from '../db'
 import type { DB } from '../db'
-import { apiKeys, auditLogs, knowledgeRoots, mcpServers, profiles, profileVersions } from '../db/schema'
+import { apiKeys, auditLogs, knowledgeCollections, mcpServers, profiles, profileVersions } from '../db/schema'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { newId, nowIso } from '../lib/ids'
-import { checkKnowledgePath } from './access-policy'
 import type { SettingsService } from './settings'
 import type { SettingsService } from './settings'
 
 
 type ProfileRow = typeof profiles.$inferSelect
 type ProfileRow = typeof profiles.$inferSelect
@@ -32,6 +31,7 @@ export const profileInputSchema: z.ZodType<ProfileInput> = z.object({
   skills: z.union([z.literal('all'), z.array(z.string())]),
   skills: z.union([z.literal('all'), z.array(z.string())]),
   fileAccess: fileAccessSchema,
   fileAccess: fileAccessSchema,
   knowledgeRootIds: z.array(z.string()),
   knowledgeRootIds: z.array(z.string()),
+  knowledgeCollectionIds: z.array(z.string()).default([]),
   mcpServerIds: z.array(z.string()).default([]),
   mcpServerIds: z.array(z.string()).default([]),
   codexPluginIds: z.array(z.string().regex(/^[\w.@-]{1,200}$/, '插件 ID 格式不正确')).max(100).default([]),
   codexPluginIds: z.array(z.string().regex(/^[\w.@-]{1,200}$/, '插件 ID 格式不正确')).max(100).default([]),
   isDefault: z.boolean(),
   isDefault: z.boolean(),
@@ -40,14 +40,14 @@ export const profileInputSchema: z.ZodType<ProfileInput> = z.object({
 const toProfileDto = (r: ProfileRow): ProfileDto => ({
 const toProfileDto = (r: ProfileRow): ProfileDto => ({
   id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt,
   id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt,
   permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills,
   permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills,
-  fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, mcpServerIds: r.mcpServerIds, codexPluginIds: r.codexPluginIds, isDefault: r.isDefault, builtin: r.builtin, builtinKey: r.builtinKey ?? null, version: r.version, updatedAt: r.updatedAt,
+  fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, knowledgeCollectionIds: r.knowledgeCollectionIds, mcpServerIds: r.mcpServerIds, codexPluginIds: r.codexPluginIds, isDefault: r.isDefault, builtin: r.builtin, builtinKey: r.builtinKey ?? null, version: r.version, updatedAt: r.updatedAt,
 })
 })
 
 
-const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds', 'mcpServerIds', 'codexPluginIds'] as const satisfies readonly (keyof ProfileSnapshot)[]
+const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds', 'knowledgeCollectionIds', 'mcpServerIds', 'codexPluginIds'] as const satisfies readonly (keyof ProfileSnapshot)[]
 
 
 function snapshotOf(p: ProfileSnapshot): ProfileSnapshot {
 function snapshotOf(p: ProfileSnapshot): ProfileSnapshot {
-  // 早期版本的快照没有 mcpServerIds、codexPluginIds,按空列表比较
-  return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, k === 'mcpServerIds' || k === 'codexPluginIds' ? p[k] ?? [] : p[k]])) as ProfileSnapshot
+  // 早期版本的快照没有 mcpServerIds、codexPluginIds、knowledgeCollectionIds,按空列表比较
+  return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, k === 'mcpServerIds' || k === 'codexPluginIds' || k === 'knowledgeCollectionIds' ? p[k] ?? [] : p[k]])) as ProfileSnapshot
 }
 }
 
 
 function changedFields(a: ProfileSnapshot, b: ProfileSnapshot): (keyof ProfileSnapshot)[] {
 function changedFields(a: ProfileSnapshot, b: ProfileSnapshot): (keyof ProfileSnapshot)[] {
@@ -117,10 +117,22 @@ export class ProfileService {
     if (missing.length) throw badRequest(`MCP 服务不存在:${missing.join('、')}`)
     if (missing.length) throw badRequest(`MCP 服务不存在:${missing.join('、')}`)
   }
   }
 
 
+  private knownCollections(): Set<string> {
+    return new Set(this.db.select({ id: knowledgeCollections.id }).from(knowledgeCollections).all().map(r => r.id))
+  }
+
+  /** Profile 只能引用已有的资料集合 */
+  private checkCollections(ids: string[]) {
+    const known = this.knownCollections()
+    const missing = ids.filter(id => !known.has(id))
+    if (missing.length) throw badRequest(`资料集合不存在:${missing.join('、')}`)
+  }
+
   create(input: ProfileInput, builtin = false, builtinKey: 'lead' | null = null): ProfileDto {
   create(input: ProfileInput, builtin = false, builtinKey: 'lead' | null = null): ProfileDto {
     const data = parse(profileInputSchema, input)
     const data = parse(profileInputSchema, input)
     if (data.provider !== 'codex') data.codexPluginIds = []
     if (data.provider !== 'codex') data.codexPluginIds = []
     this.checkMcp(data.mcpServerIds)
     this.checkMcp(data.mcpServerIds)
+    this.checkCollections(data.knowledgeCollectionIds ?? [])
     const id = newId('prf')
     const id = newId('prf')
     this.db.transaction(tx => {
     this.db.transaction(tx => {
       if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
       if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
@@ -136,6 +148,7 @@ export class ProfileService {
     const data = parse(profileInputSchema, input)
     const data = parse(profileInputSchema, input)
     if (data.provider !== 'codex') data.codexPluginIds = []
     if (data.provider !== 'codex') data.codexPluginIds = []
     this.checkMcp(data.mcpServerIds)
     this.checkMcp(data.mcpServerIds)
+    this.checkCollections(data.knowledgeCollectionIds ?? [])
     if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
     if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
     if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
     if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
     const snapshot = snapshotOf(data)
     const snapshot = snapshotOf(data)
@@ -164,7 +177,10 @@ export class ProfileService {
     const current = this.get(id)
     const current = this.get(id)
     const row = this.db.select().from(profileVersions).where(and(eq(profileVersions.profileId, id), eq(profileVersions.version, version))).get()
     const row = this.db.select().from(profileVersions).where(and(eq(profileVersions.profileId, id), eq(profileVersions.version, version))).get()
     if (!row) throw notFound(`Profile 的版本 ${version}`)
     if (!row) throw notFound(`Profile 的版本 ${version}`)
-    return this.update(id, { ...row.snapshot, provider: current.builtin ? current.provider : row.snapshot.provider, isDefault: current.isDefault })
+    // 之后删除了的资料集合不再引用
+    const known = this.knownCollections()
+    const knowledgeCollectionIds = (row.snapshot.knowledgeCollectionIds ?? []).filter(c => known.has(c))
+    return this.update(id, { ...row.snapshot, knowledgeCollectionIds, provider: current.builtin ? current.provider : row.snapshot.provider, isDefault: current.isDefault })
   }
   }
 
 
   getBuiltin(provider: ProviderId = 'claude'): ProfileDto {
   getBuiltin(provider: ProviderId = 'claude'): ProfileDto {
@@ -189,56 +205,6 @@ export class ProfileService {
   }
   }
 }
 }
 
 
-const knowledgeInputSchema = z.object({
-  name: z.string().trim().min(1).max(60),
-  path: z.string().trim().min(1),
-  description: z.string().max(1000).default(''),
-  access: z.enum(['read', 'write']).default('read'),
-  enabled: z.boolean().default(true),
-})
-export type KnowledgeInput = z.input<typeof knowledgeInputSchema>
-
-export class KnowledgeService {
-  constructor(private db: DB, private settings: SettingsService) {}
-
-  list(): KnowledgeRootDto[] {
-    return this.db.select().from(knowledgeRoots).orderBy(knowledgeRoots.name).all()
-      .map(r => ({ id: r.id, name: r.name, path: r.path, description: r.description, access: r.access, enabled: r.enabled, check: checkKnowledgePath(r.path, this.settings.get()) }))
-  }
-
-  get(id: string): KnowledgeRootDto {
-    const k = this.list().find(r => r.id === id)
-    if (!k) throw notFound('资料库')
-    return k
-  }
-
-  create(input: KnowledgeInput): KnowledgeRootDto {
-    const data = this.validate(input)
-    const id = newId('kb')
-    this.db.insert(knowledgeRoots).values({ id, ...data }).run()
-    return this.get(id)
-  }
-
-  update(id: string, input: KnowledgeInput): KnowledgeRootDto {
-    this.get(id)
-    const data = this.validate(input)
-    this.db.update(knowledgeRoots).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeRoots.id, id)).run()
-    return this.get(id)
-  }
-
-  delete(id: string) {
-    this.get(id)
-    this.db.delete(knowledgeRoots).where(eq(knowledgeRoots.id, id)).run()
-  }
-
-  private validate(input: KnowledgeInput) {
-    const data = parse(knowledgeInputSchema, input)
-    const check = checkKnowledgePath(data.path, this.settings.get())
-    if (check.denied || !check.exists || !check.isDirectory || !check.readable) throw badRequest(check.message ?? '路径不可用', check)
-    return data
-  }
-}
-
 export const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex')
 export const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex')
 
 
 export class ApiKeyService {
 export class ApiKeyService {

+ 7 - 3
apps/server/src/services/file-manager.ts

@@ -1,4 +1,4 @@
-// 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、资料库(可读写),整个服务目录(只读浏览)。
+// 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、托管目录资料库(可读写)、挂载的资料库与整个服务目录(只读浏览)。
 // 登录凭证与密钥一律不可见;数据库文件(含 MCP 服务密钥等)可见但不能读取或下载。
 // 登录凭证与密钥一律不可见;数据库文件(含 MCP 服务密钥等)可见但不能读取或下载。
 // 路径以"位置 + 相对路径"表示;解析符号链接后必须仍在该位置之内
 // 路径以"位置 + 相对路径"表示;解析符号链接后必须仍在该位置之内
 import fs from 'node:fs'
 import fs from 'node:fs'
@@ -38,7 +38,7 @@ export class FileManagerService {
   shortcuts(): FsShortcutDto[] {
   shortcuts(): FsShortcutDto[] {
     const s = this.settings()
     const s = this.settings()
     const out: FsShortcutDto[] = []
     const out: FsShortcutDto[] = []
-    for (const k of this.knowledge()) if (k.enabled) out.push({ label: k.name, rootId: `kb:${k.id}`, path: '', icon: 'library' })
+    for (const k of this.knowledge()) if (k.enabled && k.kind !== 'link') out.push({ label: k.name, rootId: `kb:${k.id}`, path: '', icon: 'library' })
     const server = realpathLoose(config.dataDir)
     const server = realpathLoose(config.dataDir)
     const dirs: [string, string, string][] = [
     const dirs: [string, string, string][] = [
       [s.paths.skillsDir, '本地技能目录', 'symbol-misc'],
       [s.paths.skillsDir, '本地技能目录', 'symbol-misc'],
@@ -198,8 +198,12 @@ export class FileManagerService {
     const roots: Root[] = [
     const roots: Root[] = [
       { id: 'workspaces', name: '会话工作区', kind: 'workspaces', path: s.paths.workspaceRoot, writable: true, description: '每个会话一个目录:uploads 上传的文件、outputs Agent 产出' },
       { id: 'workspaces', name: '会话工作区', kind: 'workspaces', path: s.paths.workspaceRoot, writable: true, description: '每个会话一个目录:uploads 上传的文件、outputs Agent 产出' },
     ]
     ]
+    // 托管目录可以在这里维护;本机目录与远程存储经挂载服务只读浏览;网络链接没有目录
     for (const k of this.knowledge()) {
     for (const k of this.knowledge()) {
-      roots.push({ id: `kb:${k.id}`, name: `资料库:${k.name}`, kind: 'knowledge', path: k.path, writable: true, description: `${k.description}${k.access === 'read' ? '(Agent 只读)' : '(Agent 可读写)'}` })
+      if (k.kind === 'link') continue
+      const managed = k.kind === 'managed'
+      const note = managed ? (k.access === 'read' ? '(Agent 只读)' : '(Agent 可读写)') : `(${k.kind === 'local' ? '本机目录' : '远程存储'},只读)`
+      roots.push({ id: `kb:${k.id}`, name: `资料库:${k.name}`, kind: 'knowledge', path: k.path, writable: managed, description: `${k.description}${note}` })
     }
     }
     roots.push({ id: 'server', name: '服务目录', kind: 'readonly', path: config.dataDir, writable: false, description: '整个服务目录(程序、运行环境、技能、数据、日志),只读浏览;要修改文件请到会话工作区或资料库' })
     roots.push({ id: 'server', name: '服务目录', kind: 'readonly', path: config.dataDir, writable: false, description: '整个服务目录(程序、运行环境、技能、数据、日志),只读浏览;要修改文件请到会话工作区或资料库' })
     // 开发环境中技能目录与运行环境不在服务目录之下,单独列出
     // 开发环境中技能目录与运行环境不在服务目录之下,单独列出

+ 3 - 5
apps/server/src/services/prompt.ts

@@ -1,6 +1,7 @@
 // 系统提示词追加部分:运行环境说明 + 资料库清单 + Profile/会话提示词
 // 系统提示词追加部分:运行环境说明 + 资料库清单 + Profile/会话提示词
 import path from 'node:path'
 import path from 'node:path'
 import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
 import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
+import { describeKnowledge } from '../knowledge/describe'
 
 
 export function buildSystemPrompt(opts: {
 export function buildSystemPrompt(opts: {
   workspaceDir: string
   workspaceDir: string
@@ -23,7 +24,7 @@ export function buildSystemPrompt(opts: {
 - LibreOffice 相关操作必须使用专用工具,不要在命令行里调用 soffice/libreoffice(包括技能自带的 recalc.py 等脚本),它们在沙箱中无法运行:
 - LibreOffice 相关操作必须使用专用工具,不要在命令行里调用 soffice/libreoffice(包括技能自带的 recalc.py 等脚本),它们在沙箱中无法运行:
   - 格式转换(docx/xlsx/pptx → pdf 等):\`convert_document\`
   - 格式转换(docx/xlsx/pptx → pdf 等):\`convert_document\`
   - Excel 公式重算与错误检查:\`recalc_workbook\`
   - Excel 公式重算与错误检查:\`recalc_workbook\`
-- 抓取网页内容用 \`web_fetch\`(返回 Markdown;PDF 等文件会下载到工作区);查找本地资料前用 \`kb_list_roots\` 查看可用的资料库。
+- 抓取网页内容用 \`web_fetch\`(返回 Markdown;PDF 等文件会下载到工作区);查找资料前用 \`kb_list_roots\` 查看可用的资料库。
 - 用户要求定期执行或在将来某个时间执行("每天 9 点…""两小时后…")时,用 \`schedule_create\` 创建定时任务:到点时系统会把指令作为新消息发到当前会话(工作组中发到工作组),由你执行。不要用 sleep、后台进程等方式等待。时间按 ${opts.settings.schedules.timezone} 时区;查看、修改、暂停、删除用 \`schedule_list\`、\`schedule_update\`、\`schedule_delete\`。
 - 用户要求定期执行或在将来某个时间执行("每天 9 点…""两小时后…")时,用 \`schedule_create\` 创建定时任务:到点时系统会把指令作为新消息发到当前会话(工作组中发到工作组),由你执行。不要用 sleep、后台进程等方式等待。时间按 ${opts.settings.schedules.timezone} 时区;查看、修改、暂停、删除用 \`schedule_list\`、\`schedule_update\`、\`schedule_delete\`。
 - 外部工具的调用耗时较长时会转为后台执行:工具会返回"已转为后台执行",这时结束本轮并告诉用户,结果到达时系统会发来【后台调用完成】消息,再继续处理;不要等待或轮询。
 - 外部工具的调用耗时较长时会转为后台执行:工具会返回"已转为后台执行",这时结束本轮并告诉用户,结果到达时系统会发来【后台调用完成】消息,再继续处理;不要等待或轮询。
 - Node 脚本可以直接 require 预装的包:docx、pptxgenjs、sharp、react、react-dom、react-icons(不要 npm install)。
 - Node 脚本可以直接 require 预装的包:docx、pptxgenjs、sharp、react、react-dom、react-icons(不要 npm install)。
@@ -33,10 +34,7 @@ export function buildSystemPrompt(opts: {
   ]
   ]
 
 
   if (opts.knowledgeRoots.length) {
   if (opts.knowledgeRoots.length) {
-    const list = opts.knowledgeRoots
-      .map(k => `- ${k.name}:${k.path}(${k.access === 'write' ? '可读写' : '只读'})${k.description ? `\n  ${k.description}` : ''}`)
-      .join('\n')
-    parts.push(`# 可用资料库\n查找资料时优先检索以下目录:\n${list}`)
+    parts.push(`# 可用资料库\n查找资料时优先检索以下资料库:\n${describeKnowledge(opts.knowledgeRoots)}`)
   }
   }
   if (opts.profilePrompt.trim()) parts.push(opts.profilePrompt.trim())
   if (opts.profilePrompt.trim()) parts.push(opts.profilePrompt.trim())
   if (opts.sessionPrompt.trim()) parts.push(opts.sessionPrompt.trim())
   if (opts.sessionPrompt.trim()) parts.push(opts.sessionPrompt.trim())

+ 8 - 5
apps/server/src/services/runs.ts

@@ -13,13 +13,15 @@ import { newId, nowIso } from '../lib/ids'
 import type { AgentProvider, RunInput } from '../providers/types'
 import type { AgentProvider, RunInput } from '../providers/types'
 import { NO_CODEX_PLUGINS, ProviderRunError } from '../providers/types'
 import { NO_CODEX_PLUGINS, ProviderRunError } from '../providers/types'
 import { resolveAccess } from './access-policy'
 import { resolveAccess } from './access-policy'
-import { parse, type AuditService, type KnowledgeService, type ProfileService } from './catalog'
+import { parse, type AuditService, type ProfileService } from './catalog'
 import type { EventStore } from './event-store'
 import type { EventStore } from './event-store'
 import type { FileService } from './files'
 import type { FileService } from './files'
 import type { InteractionService, NewApproval, NewForm, NewQuestion } from './interactions'
 import type { InteractionService, NewApproval, NewForm, NewQuestion } from './interactions'
 import type { CommunitySkillService } from './community-skills'
 import type { CommunitySkillService } from './community-skills'
 import type { McpServerService } from './mcp-servers'
 import type { McpServerService } from './mcp-servers'
 import type { CustomSkillService } from '../custom-skills/service'
 import type { CustomSkillService } from '../custom-skills/service'
+import { linkOrigins } from '../knowledge/describe'
+import type { KnowledgeService } from '../knowledge/service'
 import type { McpServerRow } from '../mcp-gateway/upstream'
 import type { McpServerRow } from '../mcp-gateway/upstream'
 import type { PluginService } from './plugins'
 import type { PluginService } from './plugins'
 import type { CodexPluginService } from './codex-plugins'
 import type { CodexPluginService } from './codex-plugins'
@@ -186,11 +188,11 @@ export class RunManager {
       session = deps.sessions.getRow(sessionId)
       session = deps.sessions.getRow(sessionId)
       const settings = deps.settings.get()
       const settings = deps.settings.get()
       const { profile, permissionMode } = deps.sessions.effectiveProfile(session)
       const { profile, permissionMode } = deps.sessions.effectiveProfile(session)
-      const knowledgeRoots = deps.knowledge.list()
+      const knowledgeRoots = deps.knowledge.effective(profile)
       const access = resolveAccess({
       const access = resolveAccess({
         policy: profile.fileAccess,
         policy: profile.fileAccess,
         knowledgeRoots,
         knowledgeRoots,
-        selectedKnowledgeRootIds: profile.knowledgeRootIds,
+        selectedKnowledgeRootIds: knowledgeRoots.map(k => k.id),
         workspaceDir: session.workspaceDir,
         workspaceDir: session.workspaceDir,
         settings,
         settings,
       })
       })
@@ -211,7 +213,7 @@ export class RunManager {
       }
       }
       gatewayToken = deps.gatewayTokens.issue({
       gatewayToken = deps.gatewayTokens.issue({
         sessionId, runId, provider: session.provider, workspaceDir: session.workspaceDir, access, permissionMode, settings,
         sessionId, runId, provider: session.provider, workspaceDir: session.workspaceDir, access, permissionMode, settings,
-        knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
+        knowledgeRoots,
         mcpServerIds: profile.mcpServerIds,
         mcpServerIds: profile.mcpServerIds,
         // 后台发起的会话(工作组按其来源)为测试会话;外部 API、Bot API 与定时任务不是
         // 后台发起的会话(工作组按其来源)为测试会话;外部 API、Bot API 与定时任务不是
         adminTest: !unattended && this.usageSourceOf(sessionRow) === 'admin',
         adminTest: !unattended && this.usageSourceOf(sessionRow) === 'admin',
@@ -228,10 +230,11 @@ export class RunManager {
         permissionMode,
         permissionMode,
         providerSessionId: session.providerSessionId,
         providerSessionId: session.providerSessionId,
         workspaceDir: session.workspaceDir,
         workspaceDir: session.workspaceDir,
+        fetchAllowOrigins: linkOrigins(knowledgeRoots),
         systemPromptAppend: buildSystemPrompt({
         systemPromptAppend: buildSystemPrompt({
           workspaceDir: session.workspaceDir,
           workspaceDir: session.workspaceDir,
           settings,
           settings,
-          knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
+          knowledgeRoots,
           profilePrompt: profile.systemPrompt,
           profilePrompt: profile.systemPrompt,
           sessionPrompt: session.systemPromptAppend,
           sessionPrompt: session.systemPromptAppend,
           permissionMode,
           permissionMode,

+ 12 - 5
apps/server/src/services/web-fetch.ts

@@ -1,5 +1,6 @@
 // 网页抓取:两个引擎共用(经 MCP Gateway 提供)。
 // 网页抓取:两个引擎共用(经 MCP Gateway 提供)。
 // 在连接层校验目标 IP(undici 自定义 lookup),防止 DNS 重绑定绕过内网拦截;每一跳重定向都重新校验。
 // 在连接层校验目标 IP(undici 自定义 lookup),防止 DNS 重绑定绕过内网拦截;每一跳重定向都重新校验。
+// 会话可用的网络链接资料库所在站点(同源)不拦截内网(docs/design/21-knowledge-sources.md §7)。
 import crypto from 'node:crypto'
 import crypto from 'node:crypto'
 import dns from 'node:dns'
 import dns from 'node:dns'
 import fs from 'node:fs'
 import fs from 'node:fs'
@@ -47,18 +48,24 @@ export interface FetchResult {
   savedPath: string | null
   savedPath: string | null
 }
 }
 
 
-export async function webFetch(opts: { url: string; workspaceDir: string; blockPrivate: boolean; signal?: AbortSignal }): Promise<FetchResult> {
+export async function webFetch(opts: { url: string; workspaceDir: string; blockPrivate: boolean; allowOrigins?: string[]; signal?: AbortSignal }): Promise<FetchResult> {
   let url = parseUrl(opts.url)
   let url = parseUrl(opts.url)
-  const agent = guardedAgent(opts.blockPrivate)
   const timeout = AbortSignal.timeout(TIMEOUT_MS)
   const timeout = AbortSignal.timeout(TIMEOUT_MS)
   const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout
   const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout
+  const agents = new Map<boolean, Agent>()
+  const agentFor = (block: boolean) => {
+    let a = agents.get(block)
+    if (!a) agents.set(block, (a = guardedAgent(block)))
+    return a
+  }
   try {
   try {
     for (let hop = 0; ; hop++) {
     for (let hop = 0; ; hop++) {
-      if (opts.blockPrivate && isPrivateAddress(url.hostname.replace(/^\[|\]$/g, ''))) {
+      const block = opts.blockPrivate && !opts.allowOrigins?.includes(url.origin)
+      if (block && isPrivateAddress(url.hostname.replace(/^\[|\]$/g, ''))) {
         throw new FetchBlockedError(`不允许访问内网地址:${url.hostname}`)
         throw new FetchBlockedError(`不允许访问内网地址:${url.hostname}`)
       }
       }
       const res = await undiciFetch(url, {
       const res = await undiciFetch(url, {
-        dispatcher: agent, redirect: 'manual', signal,
+        dispatcher: agentFor(block), redirect: 'manual', signal,
         headers: { 'user-agent': 'Mozilla/5.0 (compatible; AgentServer/0.1; +web_fetch)', accept: 'text/html,application/xhtml+xml,application/json,text/plain,*/*;q=0.5' },
         headers: { 'user-agent': 'Mozilla/5.0 (compatible; AgentServer/0.1; +web_fetch)', accept: 'text/html,application/xhtml+xml,application/json,text/plain,*/*;q=0.5' },
       })
       })
       if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
       if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
@@ -77,7 +84,7 @@ export async function webFetch(opts: { url: string; workspaceDir: string; blockP
     if (cause instanceof FetchBlockedError) throw cause
     if (cause instanceof FetchBlockedError) throw cause
     throw err
     throw err
   } finally {
   } finally {
-    await agent.close().catch(() => {})
+    for (const a of agents.values()) await a.close().catch(() => {})
   }
   }
 }
 }
 
 

+ 3 - 2
apps/server/src/teams/cards.ts

@@ -1,6 +1,7 @@
 // 能力卡片(docs/design/13-bot-teams.md §5.1):根据机器人的配置生成,组长据此了解组员会什么
 // 能力卡片(docs/design/13-bot-teams.md §5.1):根据机器人的配置生成,组长据此了解组员会什么
 import type { BotCard, BotDto, ProviderId, SkillInfo } from '@agent-server/shared'
 import type { BotCard, BotDto, ProviderId, SkillInfo } from '@agent-server/shared'
-import type { KnowledgeService, ProfileService } from '../services/catalog'
+import type { KnowledgeService } from '../knowledge/service'
+import type { ProfileService } from '../services/catalog'
 import type { McpServerService } from '../services/mcp-servers'
 import type { McpServerService } from '../services/mcp-servers'
 import type { BotService } from './bots'
 import type { BotService } from './bots'
 
 
@@ -30,7 +31,7 @@ export class CardService {
     // "全部技能"不包含需要单独勾选的技能
     // "全部技能"不包含需要单独勾选的技能
     const skills = (profile.skills === 'all' ? catalog.filter(s => !s.optIn) : catalog.filter(s => (profile.skills as string[]).includes(s.id)))
     const skills = (profile.skills === 'all' ? catalog.filter(s => !s.optIn) : catalog.filter(s => (profile.skills as string[]).includes(s.id)))
       .map(s => ({ name: s.name, description: s.description }))
       .map(s => ({ name: s.name, description: s.description }))
-    const knowledge = this.deps.knowledge.list().filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)).map(k => k.name)
+    const knowledge = this.deps.knowledge.effective(profile).map(k => k.name)
     const mcpServers = this.deps.mcpServers.list().filter(m => m.enabled && profile.mcpServerIds.includes(m.id))
     const mcpServers = this.deps.mcpServers.list().filter(m => m.enabled && profile.mcpServerIds.includes(m.id))
       .map(m => ({ name: m.name, title: m.title, description: m.description }))
       .map(m => ({ name: m.name, title: m.title, description: m.description }))
     return { ...summary, skills, mcpServers, knowledge, permissionMode: profile.permissionMode }
     return { ...summary, skills, mcpServers, knowledge, permissionMode: profile.permissionMode }

+ 32 - 21
apps/server/test/access-policy.test.ts

@@ -3,8 +3,8 @@ import os from 'node:os'
 import path from 'node:path'
 import path from 'node:path'
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import type { FileAccessPolicy, KnowledgeRootDto } from '@agent-server/shared'
 import type { FileAccessPolicy, KnowledgeRootDto } from '@agent-server/shared'
-import { config } from '../src/config'
-import { checkAccess, checkKnowledgePath, resolveAccess, toClaudeSandbox } from '../src/services/access-policy'
+import { config, mounterDir } from '../src/config'
+import { checkAccess, resolveAccess, toClaudeSandbox } from '../src/services/access-policy'
 import { defaultSettings } from '../src/services/settings'
 import { defaultSettings } from '../src/services/settings'
 
 
 const settings = defaultSettings()
 const settings = defaultSettings()
@@ -13,14 +13,22 @@ const other = path.join(settings.paths.workspaceRoot, 'ses_b')
 fs.mkdirSync(path.join(ws, 'uploads'), { recursive: true })
 fs.mkdirSync(path.join(ws, 'uploads'), { recursive: true })
 fs.mkdirSync(other, { recursive: true })
 fs.mkdirSync(other, { recursive: true })
 const kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-'))
 const kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-'))
-const kb: KnowledgeRootDto = {
-  id: 'kb_1', name: '资料', path: kbDir, description: '', access: 'read', enabled: true,
-  check: { exists: true, readable: true, isDirectory: true, denied: null, underHome: false, message: null },
-}
+const kbRoot = (over: Partial<KnowledgeRootDto>): KnowledgeRootDto => ({
+  id: 'kb_1', name: '资料', kind: 'managed', path: kbDir, description: '', access: 'read', enabled: true, url: null, local: null, remote: null,
+  status: { state: 'ok', message: null, since: null }, collectionIds: [], ...over,
+})
+const mountDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-mnt-'))
+const roots = [
+  kbRoot({}),
+  kbRoot({ id: 'kb_rw', path: path.join(kbDir, 'rw'), access: 'write' }),
+  // 本机目录即使数据被改成 write 也只读
+  kbRoot({ id: 'kb_local', kind: 'local', path: mountDir, access: 'write', local: { path: '/home/u/docs', uuid: null, relPath: null } }),
+  kbRoot({ id: 'kb_link', kind: 'link', path: '', url: 'http://wiki.local/a' }),
+]
 
 
 const policy = (p: Partial<FileAccessPolicy> = {}): FileAccessPolicy => ({ mode: 'restricted', read: [], write: [], deny: [], ...p })
 const policy = (p: Partial<FileAccessPolicy> = {}): FileAccessPolicy => ({ mode: 'restricted', read: [], write: [], deny: [], ...p })
 const resolve = (p: Partial<FileAccessPolicy> = {}, kbIds: string[] = []) =>
 const resolve = (p: Partial<FileAccessPolicy> = {}, kbIds: string[] = []) =>
-  resolveAccess({ policy: policy(p), knowledgeRoots: [kb], selectedKnowledgeRootIds: kbIds, workspaceDir: ws, settings })
+  resolveAccess({ policy: policy(p), knowledgeRoots: roots, selectedKnowledgeRootIds: kbIds, workspaceDir: ws, settings })
 
 
 describe('checkAccess', () => {
 describe('checkAccess', () => {
   it('允许读写自己的工作区', () => {
   it('允许读写自己的工作区', () => {
@@ -60,6 +68,23 @@ describe('checkAccess', () => {
     expect(checkAccess(resolve({}, ['kb_1']), path.join(kbDir, 'a.pdf'), 'write').allowed).toBe(false)
     expect(checkAccess(resolve({}, ['kb_1']), path.join(kbDir, 'a.pdf'), 'write').allowed).toBe(false)
   })
   })
 
 
+  it('只有托管目录可以写;本机目录只读;网络链接不开放任何路径', () => {
+    const a = resolve({}, ['kb_rw', 'kb_local', 'kb_link'])
+    expect(checkAccess(a, path.join(kbDir, 'rw/new.md'), 'write').allowed).toBe(true)
+    expect(checkAccess(a, path.join(mountDir, 'a.md'), 'read').allowed).toBe(true)
+    expect(checkAccess(a, path.join(mountDir, 'a.md'), 'write').allowed).toBe(false)
+    expect(a.readable).not.toContain('')
+    // 按资料库设置的写规则也只对可写的托管目录生效
+    const rules = resolve({ write: [{ knowledgeRootId: 'kb_local' }, { knowledgeRootId: 'kb_1' }, { knowledgeRootId: 'kb_rw' }], read: [{ knowledgeRootId: 'kb_link' }] })
+    expect(checkAccess(rules, path.join(mountDir, 'a.md'), 'write').allowed).toBe(false)
+    expect(checkAccess(rules, path.join(kbDir, 'a.md'), 'write').allowed).toBe(false)
+    expect(checkAccess(rules, path.join(kbDir, 'rw/x.md'), 'write').allowed).toBe(true)
+  })
+
+  it('挂载服务的目录(socket、密钥、凭据)始终禁止,即使是 broad 模式', () => {
+    expect(checkAccess(resolve({ mode: 'broad' }), path.join(mounterDir(), 'token'), 'read').allowed).toBe(false)
+  })
+
   it('Profile 的通配禁止项生效', () => {
   it('Profile 的通配禁止项生效', () => {
     const a = resolve({ deny: ['**/*.env'] })
     const a = resolve({ deny: ['**/*.env'] })
     expect(checkAccess(a, path.join(ws, 'config/.env'), 'read').allowed).toBe(false)
     expect(checkAccess(a, path.join(ws, 'config/.env'), 'read').allowed).toBe(false)
@@ -89,17 +114,3 @@ describe('toClaudeSandbox', () => {
     expect(sb.filesystem.allowWrite).not.toContain(config.home)
     expect(sb.filesystem.allowWrite).not.toContain(config.home)
   })
   })
 })
 })
-
-describe('checkKnowledgePath', () => {
-  it('拒绝包含凭证的目录', () => {
-    expect(checkKnowledgePath('~', settings).denied).not.toBeNull()
-    expect(checkKnowledgePath('~/.ssh', settings).denied).not.toBeNull()
-  })
-  it('拒绝工作区目录', () => {
-    expect(checkKnowledgePath(ws, settings).message).toContain('工作区')
-  })
-  it('接受普通目录', () => {
-    const c = checkKnowledgePath(kbDir, settings)
-    expect(c.exists && c.readable && c.isDirectory && !c.denied).toBe(true)
-  })
-})

+ 0 - 7
apps/server/test/api.test.ts

@@ -162,13 +162,6 @@ describe('会话与 Run', () => {
   })
   })
 })
 })
 
 
-describe('资料库', () => {
-  it('拒绝不安全的路径', () => {
-    expect(() => app.knowledge.create({ name: 'home', path: '~' })).toThrow(/全局禁止项/)
-    expect(() => app.knowledge.create({ name: 'none', path: '/does/not/exist' })).toThrow(/不存在/)
-  })
-})
-
 describe('API Key 可用的 Profile', () => {
 describe('API Key 可用的 Profile', () => {
   const profileInput = (name: string, permissionMode: 'ask' | 'auto' | 'readonly' = 'ask') => ({
   const profileInput = (name: string, permissionMode: 'ask' | 'auto' | 'readonly' = 'ask') => ({
     name, description: '', provider: 'claude' as const, model: 'haiku', systemPrompt: '', permissionMode,
     name, description: '', provider: 'claude' as const, model: 'haiku', systemPrompt: '', permissionMode,

+ 3 - 5
apps/server/test/file-manager.test.ts

@@ -1,5 +1,4 @@
 import fs from 'node:fs'
 import fs from 'node:fs'
-import os from 'node:os'
 import path from 'node:path'
 import path from 'node:path'
 import pino from 'pino'
 import pino from 'pino'
 import { unzipSync } from 'fflate'
 import { unzipSync } from 'fflate'
@@ -20,11 +19,12 @@ beforeAll(async () => {
   sid = s.id
   sid = s.id
   ws = s.workspaceDir
   ws = s.workspaceDir
   fs.writeFileSync(path.join(ws, 'uploads', 'data.csv'), 'a,b\n1,2\n')
   fs.writeFileSync(path.join(ws, 'uploads', 'data.csv'), 'a,b\n1,2\n')
-  kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-'))
+  kbDir = app.knowledge.create({ kind: 'managed', name: '账单', description: '家庭账单' }).path
   fs.writeFileSync(path.join(kbDir, 'note.md'), '# 账单\n')
   fs.writeFileSync(path.join(kbDir, 'note.md'), '# 账单\n')
   fs.symlinkSync('/etc', path.join(kbDir, 'etc-link'))
   fs.symlinkSync('/etc', path.join(kbDir, 'etc-link'))
   fs.mkdirSync(path.join(kbDir, '.ssh-like'))
   fs.mkdirSync(path.join(kbDir, '.ssh-like'))
-  app.knowledge.create({ name: '账单', path: kbDir, description: '家庭账单', access: 'read', enabled: true })
+  // 网络链接没有目录,不出现在文件管理中
+  app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/' })
 })
 })
 
 
 const kbRoot = () => app.fileManager.roots().find(r => r.kind === 'knowledge')!.id
 const kbRoot = () => app.fileManager.roots().find(r => r.kind === 'knowledge')!.id
@@ -87,8 +87,6 @@ describe('后台文件管理', () => {
     expect(() => app.fileManager.openFile('server', 'data/agent.db')).toThrow(/数据库文件/)
     expect(() => app.fileManager.openFile('server', 'data/agent.db')).toThrow(/数据库文件/)
     expect(() => app.fileManager.writeText('server', 'workspaces/x.md', 'x', null)).toThrow(/只读/)
     expect(() => app.fileManager.writeText('server', 'workspaces/x.md', 'x', null)).toThrow(/只读/)
     expect(() => app.fileManager.zipDir('server', 'data')).not.toThrow()
     expect(() => app.fileManager.zipDir('server', 'data')).not.toThrow()
-    // 包含服务数据目录的路径不能登记为资料库
-    expect(() => app.knowledge.create({ name: '数据目录上层', path: config.dataDir, description: '', access: 'read', enabled: true })).toThrow(/全局禁止项/)
   })
   })
 
 
   it('二进制文件不能在线编辑;目录可以打包下载', () => {
   it('二进制文件不能在线编辑;目录可以打包下载', () => {

+ 17 - 3
apps/server/test/gateway.test.ts

@@ -26,7 +26,7 @@ function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
   return {
   return {
     sessionId: 's', runId: 'r', provider: 'codex', workspaceDir: ws, permissionMode: 'ask', settings,
     sessionId: 's', runId: 'r', provider: 'codex', workspaceDir: ws, permissionMode: 'ask', settings,
     access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
     access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
-    knowledgeRoots: [{ id: 'kb', name: '账单', path: '/data/bills', description: '家庭账单', access: 'read', enabled: true, check: { exists: true, readable: true, isDirectory: true, denied: null, underHome: false, message: null } }],
+    knowledgeRoots: [{ id: 'kb', name: '账单', kind: 'managed', path: '/data/bills', description: '家庭账单', access: 'read', enabled: true, url: null, local: null, remote: null, status: { state: 'ok', message: null, since: null }, collectionIds: [] }],
     mcpServerIds: [],
     mcpServerIds: [],
     audit: (kind, summary) => audits.push(`${kind}:${summary}`),
     audit: (kind, summary) => audits.push(`${kind}:${summary}`),
     interact: async () => ({ resolution: 'allowed', scope: 'once' }),
     interact: async () => ({ resolution: 'allowed', scope: 'once' }),
@@ -47,7 +47,9 @@ beforeAll(async () => {
   server = await buildServer(app, { logger: log })
   server = await buildServer(app, { logger: log })
   await server.listen({ host: '127.0.0.1', port: 0 })
   await server.listen({ host: '127.0.0.1', port: 0 })
   base = `http://127.0.0.1:${(server.server.address() as AddressInfo).port}`
   base = `http://127.0.0.1:${(server.server.address() as AddressInfo).port}`
-  site = http.createServer((_req, res) => {
+  site = http.createServer((req, res) => {
+    // 跳转到另一个站点(127.0.0.1 与 localhost 不同源)
+    if (req.url === '/jump') return res.writeHead(302, { location: `http://127.0.0.1:${(site.address() as AddressInfo).port}/page` }).end()
     res.setHeader('content-type', 'text/html; charset=utf-8')
     res.setHeader('content-type', 'text/html; charset=utf-8')
     res.end('<html><head><script>evil()</script></head><body><h1>标题</h1><p>正文 <b>加粗</b></p></body></html>')
     res.end('<html><head><script>evil()</script></head><body><h1>标题</h1><p>正文 <b>加粗</b></p></body></html>')
   })
   })
@@ -71,7 +73,7 @@ describe('MCP Gateway', () => {
     const client = await connect(app.gatewayTokens.issue(ctx))
     const client = await connect(app.gatewayTokens.issue(ctx))
     const tools = (await client.listTools()).tools.map(t => t.name).sort()
     const tools = (await client.listTools()).tools.map(t => t.name).sort()
     expect(tools).toEqual(['convert_document', 'kb_list_roots', 'recalc_workbook', 'web_fetch'])
     expect(tools).toEqual(['convert_document', 'kb_list_roots', 'recalc_workbook', 'web_fetch'])
-    expect(text(await client.callTool({ name: 'kb_list_roots', arguments: {} }))).toContain('账单:/data/bills(只读)')
+    expect(text(await client.callTool({ name: 'kb_list_roots', arguments: {} }))).toContain('账单(托管目录,只读):/data/bills')
 
 
     fs.writeFileSync(path.join(ctx.workspaceDir, 'a.txt'), '你好')
     fs.writeFileSync(path.join(ctx.workspaceDir, 'a.txt'), '你好')
     const r = await client.callTool({ name: 'convert_document', arguments: { input: 'a.txt', format: 'pdf' } })
     const r = await client.callTool({ name: 'convert_document', arguments: { input: 'a.txt', format: 'pdf' } })
@@ -113,6 +115,18 @@ describe('MCP Gateway', () => {
     await c2.close()
     await c2.close()
   })
   })
 
 
+  it('web_fetch:网络链接资料库所在站点(同源)放行内网,跳转到其他站点仍拦截;没有这个资料库的会话不放行', async () => {
+    const origin = new URL(siteUrl).origin
+    const link = { id: 'kb_wiki', name: '内部文档', kind: 'link' as const, path: '', description: '', access: 'read' as const, enabled: true, url: `${origin}/docs/`, local: null, remote: null, status: { state: 'ok' as const, message: null, since: null }, collectionIds: [] }
+    const client = await connect(app.gatewayTokens.issue(runCtx({ knowledgeRoots: [link] })))
+    expect(text(await client.callTool({ name: 'web_fetch', arguments: { url: siteUrl } }))).toContain('# 标题')
+    const jump = await client.callTool({ name: 'web_fetch', arguments: { url: `${origin}/jump` } })
+    expect(jump.isError).toBe(true)
+    expect(text(jump)).toMatch(/不允许访问内网地址:127\.0\.0\.1/)
+    expect(text(await client.callTool({ name: 'kb_list_roots', arguments: {} }))).toContain(`同一站点(${origin})下的地址都可以访问`)
+    await client.close()
+  })
+
   it('令牌作废后不能再调用', async () => {
   it('令牌作废后不能再调用', async () => {
     const token = app.gatewayTokens.issue(runCtx())
     const token = app.gatewayTokens.issue(runCtx())
     app.gatewayTokens.revoke(token)
     app.gatewayTokens.revoke(token)

+ 125 - 0
apps/server/test/knowledge.test.ts

@@ -0,0 +1,125 @@
+// 资料库类型与资料集合(docs/design/21-knowledge-sources.md)
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { ProfileInput } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { knowledgeFilesDir } from '../src/config'
+import { describeKnowledge, linkOrigins } from '../src/knowledge/describe'
+
+let app: App
+let site: http.Server
+let siteOrigin: string
+
+beforeAll(async () => {
+  app = createApp(pino({ level: 'silent' }), { providers: {} })
+  site = http.createServer((req, res) => {
+    if (req.url === '/missing') return res.writeHead(404).end()
+    res.setHeader('content-type', 'text/html; charset=utf-8')
+    res.end('<html><head><title> 产品 手册 </title></head><body>x</body></html>')
+  })
+  await new Promise<void>(r => site.listen(0, '127.0.0.1', r))
+  siteOrigin = `http://127.0.0.1:${(site.address() as AddressInfo).port}`
+})
+afterAll(() => {
+  site.close()
+})
+
+const profile = (over: Partial<ProfileInput> = {}): ProfileInput => ({
+  name: '资料测试', description: '', provider: 'claude', model: 'haiku', systemPrompt: '', permissionMode: 'ask',
+  allowedTools: [], disallowedTools: [], skills: 'all', fileAccess: { mode: 'restricted', read: [], write: [], deny: [] },
+  knowledgeRootIds: [], isDefault: false, ...over,
+})
+
+describe('资料库类型', () => {
+  it('托管目录:平台在 files/<id> 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', () => {
+    const k = app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' })
+    expect(k.path).toBe(path.join(knowledgeFilesDir(), k.id))
+    expect(fs.statSync(k.path).isDirectory()).toBe(true)
+    expect(k).toMatchObject({ kind: 'managed', access: 'write', url: null, status: { state: 'ok' } })
+    fs.writeFileSync(path.join(k.path, 'a.md'), 'x')
+    expect(app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' }).access).toBe('read')
+    app.knowledge.delete(k.id)
+    expect(fs.existsSync(k.path)).toBe(false)
+  })
+
+  it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', () => {
+    const k = app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' })
+    expect(k).toMatchObject({ path: '', url: 'http://wiki.local/docs/', access: 'read', status: { state: 'ok' } })
+    expect(() => app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).toThrow(/http/)
+    expect(() => app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).toThrow(/格式不正确/)
+    expect(() => app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).toThrow(/用户名或密码/)
+  })
+
+  it('名称不能重复;类型不能修改;本机目录与远程存储需要挂载服务', () => {
+    const k = app.knowledge.list().find(x => x.name === '内部文档')!
+    expect(() => app.knowledge.create({ kind: 'managed', name: '内部文档' })).toThrow(/已被使用/)
+    expect(() => app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).toThrow(/类型不能修改/)
+    expect(() => app.knowledge.create({ kind: 'local', name: '本机', localPath: '/home/x/docs' })).toThrow(/挂载服务/)
+  })
+
+  it('测试访问:返回状态码、标题;失败时给出原因', async () => {
+    expect(await app.knowledge.testLink(`${siteOrigin}/docs`)).toMatchObject({ ok: true, status: 200, contentType: 'text/html', title: '产品 手册' })
+    expect(await app.knowledge.testLink(`${siteOrigin}/missing`)).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' })
+    expect((await app.knowledge.testLink('http://127.0.0.1:1/')).ok).toBe(false)
+  })
+
+  it('提示词清单:标出类型、读写、链接的放行站点与不可用原因', () => {
+    const roots = [
+      ...app.knowledge.list(),
+      { ...app.knowledge.list()[0]!, id: 'kb_x', name: '移动硬盘', kind: 'local' as const, path: '/srv/agent-server/knowledge/mnt/kb_x', status: { state: 'device_missing' as const, message: '设备未连接', since: null } },
+    ]
+    const text = describeKnowledge(roots)
+    expect(text).toContain('- 内部文档(网络链接,只读):http://wiki.local/docs/')
+    expect(text).toContain('同一站点(http://wiki.local)下的地址都可以访问')
+    expect(text).toContain('- 移动硬盘(本机目录,只读,暂不可用:设备未连接):/srv/agent-server/knowledge/mnt/kb_x')
+    expect(linkOrigins(roots)).toEqual(['http://wiki.local'])
+  })
+})
+
+describe('资料集合', () => {
+  it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', () => {
+    const a = app.knowledge.create({ kind: 'managed', name: '合同' })
+    const b = app.knowledge.create({ kind: 'managed', name: '发票' })
+    const c = app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' })
+    const col = app.knowledge.createCollection({ name: '财务', description: '财务相关', rootIds: [a.id, b.id, a.id] })
+    expect(col.rootIds).toEqual([a.id, b.id])
+    expect(app.knowledge.get(a.id).collectionIds).toEqual([col.id])
+
+    const p = app.profiles.create(profile({ knowledgeRootIds: [c.id], knowledgeCollectionIds: [col.id] }))
+    expect(p.knowledgeCollectionIds).toEqual([col.id])
+    const names = () => app.knowledge.effective(app.profiles.get(p.id)).map(k => k.name).sort()
+    expect(names()).toEqual(['发票', '合同', '报价单'])
+
+    app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false })
+    expect(names()).toEqual(['合同', '报价单'])
+    app.knowledge.updateCollection(col.id, { name: '财务', rootIds: [a.id] })
+    app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true })
+    expect(names()).toEqual(['合同', '报价单'])
+
+    // 被 Profile 使用的集合不能删除;删除资料库时从集合中移除
+    expect(app.knowledge.collection(col.id).usedBy).toEqual([{ id: p.id, name: '资料测试' }])
+    expect(() => app.knowledge.deleteCollection(col.id)).toThrow(/正被 Profile 使用/)
+    app.knowledge.delete(a.id)
+    expect(app.knowledge.collection(col.id).rootIds).toEqual([])
+    expect(names()).toEqual(['报价单'])
+  })
+
+  it('校验:名称不能重复,成员必须存在;Profile 只能引用已有的集合,恢复旧版本时忽略已删除的集合', () => {
+    const col = app.knowledge.collections().find(x => x.name === '财务')!
+    expect(() => app.knowledge.createCollection({ name: '财务', rootIds: [] })).toThrow(/已被使用/)
+    expect(() => app.knowledge.createCollection({ name: '空', rootIds: ['kb_nope'] })).toThrow(/不存在/)
+    expect(() => app.profiles.create(profile({ name: '坏引用', knowledgeCollectionIds: ['kbc_nope'] }))).toThrow(/资料集合不存在/)
+
+    const temp = app.knowledge.createCollection({ name: '临时', rootIds: [] })
+    const p = app.profiles.create(profile({ name: '版本测试', knowledgeCollectionIds: [temp.id, col.id] }))
+    const v2 = app.profiles.update(p.id, profile({ name: '版本测试', knowledgeCollectionIds: [col.id] }))
+    expect(app.profiles.versions(p.id)[0]!.changed).toEqual(['knowledgeCollectionIds'])
+    app.knowledge.deleteCollection(temp.id)
+    expect(app.profiles.restore(p.id, 1).knowledgeCollectionIds).toEqual([col.id])
+    expect(v2.version).toBe(2)
+  })
+})

+ 4 - 3
docs/design/05-data-model.md

@@ -19,8 +19,9 @@ settings   audit_logs   usage_daily   profile_versions   admin_sessions
 | 表 | 关键字段 | 说明 |
 | 表 | 关键字段 | 说明 |
 |---|---|---|
 |---|---|---|
 | `api_keys` | id, name, key_hash, key_prefix, enabled, allowed_profile_ids(json), default_profile_id, created_at, last_used_at | 每个上层平台一个 Key;只存哈希;只能使用勾选的 Profile(ADR-0015) |
 | `api_keys` | id, name, key_hash, key_prefix, enabled, allowed_profile_ids(json), default_profile_id, created_at, last_used_at | 每个上层平台一个 Key;只存哈希;只能使用勾选的 Profile(ADR-0015) |
-| `profiles` | id, name, provider, model, system_prompt, permission_mode, allowed_tools(json), disallowed_tools(json), skills(json), file_access(json), knowledge_root_ids(json), is_default, builtin, version, updated_at | Agent 预设;`file_access` 见 06 §4;`builtin` 的通用助手不可删除 |
-| `knowledge_roots` | id, name, path, description, access('read'\|'write'), enabled, visible(检测结果), checked_at | 资料库,**在后台设定** |
+| `profiles` | id, name, provider, model, system_prompt, permission_mode, allowed_tools(json), disallowed_tools(json), skills(json), file_access(json), knowledge_root_ids(json), knowledge_collection_ids(json), is_default, builtin, version, updated_at | Agent 预设;`file_access` 见 06 §4;`builtin` 的通用助手不可删除 |
+| `knowledge_roots` | id, name, kind('managed'\|'local'\|'remote'\|'link'), description, access('read'\|'write',只有托管目录可写), enabled, source(json), secrets(json), url | 资料库,**在后台设定**;类型与目录见 [21](21-knowledge-sources.md) |
+| `knowledge_collections` | id, name, description, root_ids(json) | 资料集合,Profile 可整体勾选 |
 | `profile_knowledge` | profile_id, knowledge_root_id | Profile 启用的资料库 |
 | `profile_knowledge` | profile_id, knowledge_root_id | Profile 启用的资料库 |
 | `sessions` | id, source, profile_id, provider, model, permission_mode, provider_session_id, workspace_dir, title, last_seq, created_at, updated_at, archived | `permission_mode` 为空表示跟随 Profile(只有后台可以覆盖) |
 | `sessions` | id, source, profile_id, provider, model, permission_mode, provider_session_id, workspace_dir, title, last_seq, created_at, updated_at, archived | `permission_mode` 为空表示跟随 Profile(只有后台可以覆盖) |
 | `runs` | id, session_id, status, started_at, ended_at, input_tokens, output_tokens, cost_usd, error | |
 | `runs` | id, session_id, status, started_at, ended_at, input_tokens, output_tokens, cost_usd, error | |
@@ -66,7 +67,7 @@ settings   audit_logs   usage_daily   profile_versions   admin_sessions
 │   ├─ uploads/                     用户上传(数据文件、截图)
 │   ├─ uploads/                     用户上传(数据文件、截图)
 │   ├─ outputs/                     最终产出
 │   ├─ outputs/                     最终产出
 │   └─ .agent/                      工具大输出等内部文件
 │   └─ .agent/                      工具大输出等内部文件
-├─ knowledge/                       默认资料库位置(也可以在后台添加其他路径)
+├─ knowledge/files/<id>             托管目录资料库;knowledge/mnt/<id> 为挂载服务提供的本机目录与远程存储(21)
 ├─ skills/                          共享技能
 ├─ skills/                          共享技能
 ├─ runtime/py/                      Python venv(已创建)
 ├─ runtime/py/                      Python venv(已创建)
 ├─ runtime/matplotlibrc             中文字体配置(已创建)
 ├─ runtime/matplotlibrc             中文字体配置(已创建)

+ 2 - 0
docs/design/07-files-and-knowledge.md

@@ -62,6 +62,8 @@ Agent 不允许 `pip install` 到共享环境;需要临时依赖时,在工
 ## 4. 资料检索
 ## 4. 资料检索
 
 
 ### 4.1 本地资料
 ### 4.1 本地资料
+
+> **2026-11-06 起**:资料库改为托管目录、本机目录、远程存储、网络链接四种类型,并增加资料集合,不再登记任意服务器路径,见 [21-knowledge-sources](21-knowledge-sources.md)。下文"在后台设定路径"仅作为历史记录。
 - **资料库(Knowledge Root)**:**在后台"资料库"页设定**,字段包括名称、路径、说明(会告诉 Agent 这里放的是什么)、读写属性(默认只读)、启用状态。
 - **资料库(Knowledge Root)**:**在后台"资料库"页设定**,字段包括名称、路径、说明(会告诉 Agent 这里放的是什么)、读写属性(默认只读)、启用状态。
 - 保存时检测:路径是否存在、服务进程能否看到(home 下的路径受 ProtectHome 影响,见 06 §5.2)、是否命中全局禁止项。
 - 保存时检测:路径是否存在、服务进程能否看到(home 下的路径受 ProtectHome 影响,见 06 §5.2)、是否命中全局禁止项。
 - Profile 勾选启用哪些资料库;它们会并入该 Profile 的文件访问策略(06 §4),分别映射为 Claude 的 `additionalDirectories` 加沙箱 `allowRead`,以及 Codex 权限配置中的 `read` 条目。
 - Profile 勾选启用哪些资料库;它们会并入该 Profile 的文件访问策略(06 §4),分别映射为 Claude 的 `additionalDirectories` 加沙箱 `allowRead`,以及 Codex 权限配置中的 `read` 条目。

+ 2 - 2
docs/design/09-admin-ui.md

@@ -39,8 +39,8 @@
 | 💬 Playground | 会话列表(搜索、按引擎/使用者筛选) | 对话面板 + 右侧可收起的调试面板 |
 | 💬 Playground | 会话列表(搜索、按引擎/使用者筛选) | 对话面板 + 右侧可收起的调试面板 |
 | 📊 概览 | — | 运行中任务、今日用量、7 天趋势、错误列表、凭证状态 |
 | 📊 概览 | — | 运行中任务、今日用量、7 天趋势、错误列表、凭证状态 |
 | 🗂 会话 | 全部会话(所有平台/使用者) | 只读回放(时间线 + 原始事件 + 文件),可"在 Playground 中继续" |
 | 🗂 会话 | 全部会话(所有平台/使用者) | 只读回放(时间线 + 原始事件 + 文件),可"在 Playground 中继续" |
-| 🧩 Profiles | Profile 列表 | 表单编辑:引擎、模型(下拉选择当前引擎的模型)、提示词(Monaco 编辑器)、权限、工具、技能、资料库、第三方 MCP 服务、Codex 插件(仅 Codex,默认不开放,ADR-0018 修订)、**文件访问策略**(模式、可读/可写/禁止路径,右侧显示合并后的生效预览,见 06 §4.3);版本历史 |
-| 📚 资料库 | 资料目录列表 | 名称、路径(带目录选择器)、说明、读写属性、启用;可见性检测结果;被哪些 Profile 使用;索引状态(M3) |
+| 🧩 Profiles | Profile 列表 | 表单编辑:引擎、模型(下拉选择当前引擎的模型)、提示词(Monaco 编辑器)、权限、工具、技能、资料库(资料集合与单个资料库,显示实际可用的列表)、第三方 MCP 服务、Codex 插件(仅 Codex,默认不开放,ADR-0018 修订)、**文件访问策略**(模式、可读/可写/禁止路径,右侧显示合并后的生效预览,见 06 §4.3);版本历史 |
+| 📚 资料库 | 资料库列表、资料集合 | 四种类型(托管目录、本机目录、远程存储、网络链接,见 [21](21-knowledge-sources.md)):名称、说明、启用、状态;托管目录可设 Agent 可写并在文件管理中打开;网络链接可测试访问。资料集合:成员、被哪些 Profile 使用 |
 | 🗃 文件管理 | 位置 + 常用目录(各资料库、本地技能目录、已安装技能、Python 运行环境、服务数据、日志) | 以管理员身份浏览与管理文件:会话工作区与资料库可写(新建文件/目录、上传与拖放、重命名/移动、删除、目录打包下载);**整个服务目录**(`/srv/agent-server`:程序、运行环境、技能、数据、日志、引擎 home)只读浏览。文本文件用 Monaco 编辑或查看(Ctrl/⌘+S 保存,打开后被改动则拒绝覆盖),图片预览,其他文件下载。登录凭证与密钥(内置全局禁止项中的 `~/.claude/.credentials.json`、`~/.codex/auth.json`、`~/.ssh` 等)不显示;数据库及其备份能看到但不能读取或下载(含 MCP 服务密钥等只写不读的配置)。路径不能越出位置,外部符号链接不跟随;会话目录本身不能在这里删除或移动;修改会话文件后同步该会话的文件记录。实现:`apps/server/src/services/file-manager.ts`、`/admin/v1/fs/*` |
 | 🗃 文件管理 | 位置 + 常用目录(各资料库、本地技能目录、已安装技能、Python 运行环境、服务数据、日志) | 以管理员身份浏览与管理文件:会话工作区与资料库可写(新建文件/目录、上传与拖放、重命名/移动、删除、目录打包下载);**整个服务目录**(`/srv/agent-server`:程序、运行环境、技能、数据、日志、引擎 home)只读浏览。文本文件用 Monaco 编辑或查看(Ctrl/⌘+S 保存,打开后被改动则拒绝覆盖),图片预览,其他文件下载。登录凭证与密钥(内置全局禁止项中的 `~/.claude/.credentials.json`、`~/.codex/auth.json`、`~/.ssh` 等)不显示;数据库及其备份能看到但不能读取或下载(含 MCP 服务密钥等只写不读的配置)。路径不能越出位置,外部符号链接不跟随;会话目录本身不能在这里删除或移动;修改会话文件后同步该会话的文件记录。实现:`apps/server/src/services/file-manager.ts`、`/admin/v1/fs/*` |
 | 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
 | 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
 | 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |
 | 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |

+ 401 - 0
docs/design/21-knowledge-sources.md

@@ -0,0 +1,401 @@
+# 21 资料库:托管目录、本机目录、远程存储、网络链接与资料集合
+
+> 状态:**实施中(2026-11-06)**:第一期(资料库类型重构、网络链接、资料集合)已完成,见 §15。取代 [07 §4.1](07-files-and-knowledge.md) 中"登记服务器路径"的资料库。
+
+## 1. 背景
+
+现有资料库只能登记**平台服务进程看得到的任意路径**。平台以 agentsvc 运行,systemd 配置了 `ProtectHome=yes`、`ProtectSystem=strict`(06 §5、ADR-0012)。这带来四个问题:
+
+- **看不到需要的资料**:home 下的资料无法登记;外接设备上的资料只有权限恰好允许时才能读。
+- **范围过宽**:除禁止项外,任何路径都能登记,包括 `/etc` 等系统文件(已验证 agentsvc 能读 `/etc/hostname`)。隐藏文件(`.git`、`.env`)也不会被过滤。
+- **来源单一**:网络上的资料(内网文档站、对象存储、NAS、网盘)没有登记方式。
+- **选择麻烦**:Profile 只能一个个勾选资料库。
+
+## 2. 目标与非目标
+
+目标:
+1. 资料库分为四种类型:**托管目录、本机目录、远程存储、网络链接**;去掉"任意服务器路径"。
+2. 本机目录和远程存储由一个**独立的挂载进程**以部署用户的身份提供:
+   - **只读**;
+   - **过滤隐藏文件**;
+   - 访问范围由挂载进程强制检查。
+   平台自身的 systemd 限制保持不变。
+3. 移动设备、远程存储不可用时,资料库保留,状态显示为失效;恢复后**自动可用**。
+4. 资料集合:Profile 可以勾选集合,也可以勾选单个资料库。
+
+非目标:
+- 本机目录和远程存储不支持写入;
+- 不做索引检索(`kb_search`);
+- 网络链接不支持需要登录的地址;
+- 不做集合嵌套;
+- 设备断开、恢复不写审计日志;
+- 不支持需要 OAuth 的网盘和国内网盘(§12)。
+
+## 3. 资料库类型
+
+| 类型 `kind` | 登记内容 | Agent 看到的路径 | Agent 读写 | 由谁提供 |
+|---|---|---|---|---|
+| `managed` 托管目录 | 名称、说明 | `/srv/agent-server/knowledge/files/<id>` | 只读或可写 | 平台自己的目录,后台"文件管理"上传、编辑 |
+| `local` 本机目录 | 源目录(`/home`、`/mnt`、`/media` 下) | `/srv/agent-server/knowledge/mnt/<id>` | 只读 | 挂载进程 |
+| `remote` 远程存储 | 存储类型、连接参数、凭据、路径 | `/srv/agent-server/knowledge/mnt/<id>` | 只读 | 挂载进程(rclone) |
+| `link` 网络链接 | 链接(http/https) | 无,提示词中给出链接 | 只读 | Agent 用 `web_fetch` 读取 |
+
+所有资料库都有:名称、说明(告诉 Agent 这里有什么)、启用状态。
+
+**迁移**:生产环境目前没有登记资料库。开发库中已有的记录迁移为 `local`,源目录取原路径;挂载进程检查不通过的,显示为"位置不允许"。Profile 中的引用保持不变。
+
+## 4. 挂载进程(agent-mounter)
+
+### 4.1 程序与运行方式
+
+- 一个 Go 程序,同时承担:
+  - 挂载管理(Unix socket 接口);
+  - 文件系统(go-fuse):每个本机目录和远程存储资料库各对应一个挂载,都在同一个进程中提供;
+  - 远程存储时,启动并管理 rclone 子进程(§6)。
+- 源码在 `apps/mounter/`,依赖 go-fuse 和 golang.org/x/sys。`deploy.sh` 编译为静态程序,放到 `/srv/agent-server/app/mounter/`(root 所有、只读)。
+- 单独的 systemd 服务 `agent-mounter.service`,**以部署用户运行**(`install.sh` 执行时的 `$SUDO_USER`,生产上是 jerry-remote,已确认可以接受它等同于 root)。
+- 通过 setuid 的 `fusermount3` 挂载,**不需要 root**。需要在 `/etc/fuse.conf` 中开启 `user_allow_other`,agentsvc 才能访问。
+- **systemd 限制**:
+  - 挂载必须发生在主机的挂载命名空间,所以不能用 `ProtectSystem`、`ProtectHome`、`PrivateTmp`、`PrivateMounts`;
+  - `fusermount3` 是 setuid 程序,所以不能开 `NoNewPrivileges`;
+  - 只能使用与挂载无关的加固,例如 `ProtectKernelModules`、`LockPersonality`。
+- 进程退出时,它提供的所有挂载随之失效。启动时先清理 `mnt/` 下失效的挂载(`fusermount3 -uz`),再按登记重新挂载。只有挂载程序本身有改动时,`deploy.sh` 才重启它,挂载中断几秒后自动恢复。
+
+### 4.2 文件系统的行为
+
+挂载参数:`ro,nosuid,nodev,noexec,allow_other`。
+
+| 行为 | 说明 |
+|---|---|
+| 只读 | 挂载层面只读,任何写入都返回"只读文件系统" |
+| 隐藏文件 | 以 `.` 开头的文件和目录**不出现在目录列表中,按路径访问也返回"不存在"**。`ls -a`、`find`、`grep -r` 都看不到(已实测) |
+| 权限 | 文件显示为所有人只读、目录为所有人可进入。实际以部署用户的身份读取,不修改原文件的权限 |
+| 符号链接 | 原样呈现,由读取方在自己的文件系统视图中解析。指向挂载范围外的链接在平台和沙箱中会失效 |
+| 防止路径替换 | 打开文件时,从源目录开始逐级打开(`openat2` + `RESOLVE_BENEATH \| RESOLVE_NO_SYMLINKS`)。即使有人在访问过程中把目录替换成符号链接,也不会读到源目录以外的内容 |
+| 不可用时 | 资料库状态不是"正常"时,挂载点根目录照常存在,读取目录或文件返回"输入/输出错误" |
+
+### 4.3 访问范围(本机目录,由挂载进程强制检查)
+
+登记、列目录、每次解析源目录时都会检查:
+
+1. 解析符号链接后的真实路径必须位于 `/home`、`/mnt`、`/media` 之下。允许的根目录**写死在代码中**,不接受平台传来的配置。
+2. 不能是这三个目录本身,也不能是某个用户的 home 目录本身(`/home/<user>`),只能是更深一层的子目录。
+3. 路径中任何一段都不能以 `.` 开头。
+4. 必须是目录,并且部署用户能读取。
+5. 挂载点由挂载进程自己拼接(`mnt/<id>`),`<id>` 必须匹配资料库 ID 的格式。
+
+后台逐级选择目录时,`list` 只返回子目录名,隐藏以 `.` 开头的目录,并标出不能选择的目录及原因。
+
+### 4.4 本机目录的设备识别与自动恢复
+
+现场情况:
+- 外接盘 `/media/jerry-remote/Data` 写在 fstab 中,开机自动挂载;
+- **它的挂载点下面本身就有文件**:开机日志提示 "Directory … to mount over is not empty"。所以硬盘不在时,这个路径仍然存在,里面是别的文件。
+
+因此,本机目录不能只按路径判断,而是按"设备加设备内路径"识别:
+
+- **登记时**挂载进程返回源目录所在的文件系统:
+  - 在系统盘(根分区所在的设备)上,例如 home 下的目录:只记路径;
+  - 在其他设备上:记录**文件系统 UUID**(来自 `/proc/self/mountinfo` 和 `/dev/disk/by-uuid`)和**设备内的相对路径**,同时保留原路径用于显示。
+- **每次访问**时,挂载进程先找到"这个 UUID 当前挂在哪里",再拼出实际路径。
+  - 实际路径同样要经过 §4.3 的检查。设备被挂到允许范围以外时,状态为"位置不允许"。
+  - 挂载位置的查找结果会缓存。挂载表有变化时(监听 `/proc/self/mountinfo` 的 `POLLPRI` 事件),立即重新计算状态,不需要定时检查。
+- 设备重新挂上后,资料**立即恢复可用**,即使挂载路径变了也可以(卷标变化、设备名从 sda 变成 sdb)。换成另一块设备时,即使路径相同,也不会被误认为是原来那块。
+
+状态:
+
+| 状态 | 判断 |
+|---|---|
+| `ok` 正常 | 设备在、路径存在、检查通过 |
+| `device_missing` 设备未连接 | 这个 UUID 当前没有挂载在任何地方 |
+| `path_missing` 路径不存在 | 设备在,但其中的目录被删除或改名 |
+| `not_allowed` 位置不允许 | 实际路径不在 §4.3 允许的范围内 |
+| `unreachable` 无法连接 / `auth_failed` 认证失败 | 远程存储(§6) |
+
+**设备由谁挂到系统上**:挂载进程只负责识别设备回来了,不负责把设备挂到系统上。
+
+| 方式 | 结论 |
+|---|---|
+| fstab 中加 `x-systemd.automount`(以及 `x-systemd.device-timeout=1s`) | **推荐用于用作资料的硬盘**。访问路径时系统自动挂载,设备不在时很快报错。由 root 为每块盘配置一次 |
+| 桌面自动挂载(udisks2) | 不可靠:需要有人登录桌面,而且只有那个用户能读。目前登录桌面的是 jerry,jerry-remote 读不到 `/media/jerry` |
+| 挂载进程自己挂载设备 | 不做:需要 root 或桌面会话授权 |
+
+后台资料库页显示这些说明。设备状态为"未连接"时,提示检查设备是否已挂载。
+
+### 4.5 与平台的通信
+
+- Unix socket:`/srv/agent-server/mounter/mounter.sock`。
+  - 目录所有者为部署用户、组为 agentsvc,权限 0750;socket 0660;
+  - 平台通过组权限连接。
+- **鉴权**:Agent 的进程也是 agentsvc 用户,所以:
+  - 挂载进程启动时生成随机密钥,写入 `/srv/agent-server/mounter/token`(0640);
+  - 平台每次请求都带上这个密钥;
+  - `/srv/agent-server/mounter/` 整个目录加入**内置全局禁止项**,两个引擎的沙箱都看不到。目录里还有远程存储的凭据和缓存(§6)。
+- 协议:一行一个 JSON 请求和响应。
+
+| 请求 | 说明 |
+|---|---|
+| `list { path }` | 列出可选择的子目录 |
+| `check { source }` | 按 §4.3 检查本机目录,返回识别结果(是否在系统盘、UUID、设备内路径) |
+| `test { remote }` | 测试远程存储的连接,列出根目录 |
+| `sync { mounts }` | 发送完整的登记列表,挂载进程挂上缺少的、卸载多余的、更新有变化的,并返回各挂载的状态 |
+| `status` | 各挂载的状态及最近一次变化的时间 |
+
+- **平台是唯一的状态来源**,登记保存在平台数据库中。
+  - 挂载进程把最近一次 `sync` 的列表保存到 `/srv/agent-server/mounter/state.json`(0600),只用于开机后不等平台就能立即恢复挂载。平台每次 `sync` 都会覆盖它,而且挂载进程对每一项都会重新检查。
+  - 远程存储的凭据随 `sync` 下发,也保存在这个文件中。
+
+调用时机:
+
+| 时机 | 动作 |
+|---|---|
+| 平台启动 | `sync`(挂载进程没启动时,按退避间隔重试) |
+| 资料库新增、修改、删除、启用、停用 | `sync` |
+| 挂载进程启动 | 读取 `state.json` 恢复挂载 |
+| 后台打开资料库页 | `status` |
+| 每次会话开始运行 | `status`(超时 1 秒),不可用的资料库在提示词中标注"暂不可用" |
+
+不需要定时同步。
+
+## 5. 托管目录
+
+- 平台在 `/srv/agent-server/knowledge/files/<id>` 下创建目录,所有者 agentsvc。用后台"文件管理"上传、新建、编辑、删除文件。
+- **Agent 读写**:
+  - 默认只读;
+  - 可以设为"允许 Agent 写入",按现有读写资料库的方式开放沙箱写权限。
+  - 页面提示:可写的托管目录由所有使用它的会话共享,写入会影响其他会话。
+- 不经过挂载进程,所以隐藏文件不会被过滤。上传 zip 解压时跳过以 `.` 开头的项。
+- 删除资料库时,需要二次确认,然后删除目录及其中的文件。
+- 以后如果要像技能那样推送、审核、做版本,作为托管目录的一种维护方式(不在本次范围)。
+
+## 6. 远程存储
+
+### 6.1 方案
+
+```
+远程存储 ──rclone mount(只读)──▶ /srv/agent-server/mounter/remote/<id>(内部,Agent 看不到)
+                                      │ 挂载进程的文件系统(隐藏文件过滤、只读、状态)
+                                      ▼
+                         /srv/agent-server/knowledge/mnt/<id>(Agent 看到的位置)
+```
+
+- **rclone 版本**:随部署放一个**固定版本的官方程序**(下载时校验 sha256),放在 `/srv/agent-server/app/mounter/rclone`。Ubuntu 自带的 1.53 太旧。
+- 挂载进程为每个远程存储资料库启动一个 rclone 子进程:
+
+  ```
+  rclone mount kb: /srv/agent-server/mounter/remote/<id> --read-only
+    --vfs-cache-mode full --cache-dir /srv/agent-server/mounter/cache/<id>
+    --vfs-cache-max-size 1G --vfs-cache-max-age 1h --dir-cache-time 1m
+  ```
+
+  - `--vfs-cache-mode full` 让 pandas 等需要随机读取的程序可以正常工作(xlsx、parquet)。
+  - 缓存有上限,过期自动清除,符合"用时读取,不长期保存"。
+- **连接参数和凭据**:通过子进程的环境变量传给 rclone(`RCLONE_CONFIG_KB_*`),**不放在命令行**里,因为命令行在进程列表中所有人可见。
+- **状态**:
+  - 根据 rclone 子进程是否存活、列挂载根目录(带超时)的结果、rclone 日志中的认证错误,得出 `ok`、`unreachable`、`auth_failed`。
+  - rclone 自己会重试连接;子进程退出时,挂载进程按退避间隔重启它。
+  - 恢复后状态自动变回正常。
+- 挂载进程需要联网,只访问管理员登记的地址。
+
+### 6.2 首期支持的存储
+
+| 存储 | 填写 | 覆盖范围 |
+|---|---|---|
+| S3 兼容 | 地址(endpoint)、区域(可选)、存储桶、路径前缀、Access Key、Secret Key | MinIO、AWS S3、阿里云 OSS、腾讯云 COS、华为云 OBS、七牛、Ceph、R2 等 |
+| WebDAV | 地址、用户名、密码、服务类型(通用 / Nextcloud / ownCloud / SharePoint) | 坚果云、Nextcloud、群晖 WebDAV 等 |
+
+SMB、SFTP 视需要在后续增加,表单字段已经确定:SMB 为服务器、共享名、用户名、密码;SFTP 为主机、端口、用户名、密码或私钥。NAS 也可以由系统用 fstab 挂到 `/mnt` 下,再按"本机目录"登记。
+
+### 6.3 使用提示与限制
+
+- 提示词中把远程存储标注为"远程资料",并提示:先按文件名和目录结构查找,再读具体文件,避免对整个目录 `grep -r`(这相当于下载全部文件,又慢,公有云还会产生流量费)。
+- 文件很多的存储桶,应该用路径前缀只挂需要的部分。
+- 远程的改动在目录缓存过期(1 分钟)后才能看到。
+- 建议使用**只读权限的密钥**。
+
+### 6.4 凭据
+
+- 凭据保存在资料库记录中。后台只写不读:已设置的显示"已设置",留空表示沿用原值。与 MCP 服务的做法相同。
+- 审计日志中不出现凭据的值。
+- 以后如有多个资料库共用同一组凭据的需要,再参照 MCP 凭据(20)增加共享凭据。
+
+## 7. 网络链接
+
+- 登记:名称、链接(http/https)、说明(可选)。后台提供"测试访问"按钮。
+- 系统提示词和 `kb_list_roots` 的清单中列出名称、链接和说明,由 Agent 自己决定:
+  - 网页用 `web_fetch` 转为 Markdown;
+  - 文件下载到工作区后再处理。
+- **内网放行**:设置 `network.blockPrivateFetch` 开启时(默认开启),网关 `web_fetch` 和 Claude 的 WebFetch 都会拦截私网地址。对当前会话可用的链接资料库:
+  - 放行与登记链接**同源**(协议、主机、端口都相同)的地址;
+  - 重定向的每一跳都要检查;
+  - 只对勾选了这个资料库的 Profile 的会话生效。
+- Codex 在沙箱中用 curl 访问网络的规则不变;提示词中建议使用 `web_fetch`。
+
+## 8. 资料集合
+
+- 集合包括:名称、说明、成员(资料库 ID 列表,可以混合各种类型)。一个资料库可以属于多个集合。不支持集合嵌套。
+- Profile 新增 `knowledgeCollectionIds`。**生效的资料库 = 勾选的单个资料库 ∪ 所勾选集合的成员**,去掉停用和已删除的。
+- 集合是**引用关系**:修改成员后,所有使用这个集合的 Profile 立即生效。集合页显示"被哪些 Profile 使用",添加成员时提示影响范围。
+- Profile 版本快照包含集合 ID。
+- 生效资料库统一由一个函数计算,用于:
+  - 运行时的文件访问和提示词(runs.ts 三处);
+  - 工作组能力卡片(teams/cards.ts);
+  - 后台的访问预览。
+
+## 9. 平台变更
+
+### 9.1 数据模型
+
+```
+knowledge_roots
+  + kind        'managed' | 'local' | 'remote' | 'link'
+  + source      json  local:{ path, uuid?, relPath? };remote:{ type, 连接参数(不含凭据) };其他为空
+  + secrets     json  remote:凭据(不出现在 DTO 中)
+  + url         text  link
+  path              派生:managed → knowledge/files/<id>;local、remote → knowledge/mnt/<id>;link 为空
+  access            只有 managed 可以是 'write'
+
+knowledge_collections (id, name, description, root_ids json, created_at, updated_at)
+
+profiles
+  + knowledge_collection_ids json 默认 '[]'
+```
+
+### 9.2 运行时
+
+- **文件访问**(access-policy):
+  - `managed` 按 access 开放读或写;
+  - `local`、`remote` 只读开放挂载点;
+  - `link` 不对应路径。
+  - `/srv/agent-server/mounter/` 加入内置全局禁止项。
+  - 文件访问策略中按资料库设置的规则(`{ knowledgeRootId }`):写规则只能指向可写的托管目录;读规则不能指向网络链接。
+- **提示词和 `kb_list_roots`**:
+  - 列出类型、路径或链接、说明;
+  - 远程存储标注"远程资料"及使用提示(§6.3);
+  - 不可用的资料库标注"暂不可用"及原因。
+- **网络放行**:网关 `web_fetch` 和 Claude 的 WebFetch 拦截逻辑(providers/claude/policy.ts)接收当前会话的链接资料库列表,按 §7 放行。
+
+### 9.3 后台
+
+- **资料库页**:
+  - 上方显示挂载服务的状态(正常 / 不可用)。
+  - 新建时先选类型:
+    - 托管目录:名称、说明、读写;
+    - 本机目录:逐级选择目录,显示检查结果,以及是否在系统盘、设备 UUID;
+    - 远程存储:存储类型、连接参数、凭据,"测试连接";
+    - 网络链接:链接、说明,"测试访问"。
+  - 列表显示类型、状态(失效原因、最近变化时间)。可以在文件管理中打开浏览。
+  - 外接设备的配置说明(§4.4)。
+- **集合**:资料库页内新增"集合"分组,可以新建、编辑成员,并显示被哪些 Profile 使用。
+- **Profile**:资料库选择分为"集合"和"单个资料库"两部分,并显示最终生效的列表。
+- **文件管理**:
+  - 托管目录可读写;
+  - 本机目录和远程存储只读浏览(经由挂载点,同样看不到隐藏文件);
+  - 网络链接不出现。
+
+### 9.4 部署
+
+`install.sh`(首次安装,需 root):
+- 在 `/etc/fuse.conf` 中开启 `user_allow_other`;
+- 创建目录:
+  - `/srv/agent-server/knowledge/files`(agentsvc,0700);
+  - `/srv/agent-server/knowledge/mnt`(部署用户:agentsvc,0750,平台只能进入,不能创建或删除挂载点);
+  - `/srv/agent-server/mounter`(部署用户:agentsvc,0750);
+- 安装并启用 `agent-mounter.service`(`User=$SUDO_USER`),平台服务配置 `Wants=agent-mounter.service`。
+
+`deploy.sh`:
+- 编译挂载进程;
+- 下载并校验固定版本的 rclone(远程存储一期起);
+- 只有挂载程序或 rclone 有变化时才重启 agent-mounter。
+
+## 10. 安全分析
+
+| 风险 | 对策 |
+|---|---|
+| 平台被攻破,指挥挂载进程挂载敏感目录 | 允许的根目录写死在挂载进程中;不能挂 home 目录本身和隐藏目录;隐藏文件在文件系统层过滤;只读。最坏情况是部署用户能读的普通资料被只读暴露 |
+| Agent(同为 agentsvc)调用挂载进程,或读取凭据 | 需要密钥;socket、密钥、state.json、rclone 缓存都在 `/srv/agent-server/mounter/` 下,该目录在全局禁止项中 |
+| 挂载进程被攻破 | 等同于部署用户被攻破(可 sudo)。对策:代码小、只接受本机 socket 上带密钥的请求、只访问登记的远程地址 |
+| 访问过程中目录被替换为符号链接 | 逐级用 `openat2` 打开,不跟随符号链接 |
+| 移动设备拔出后,挂载点下原有的其他文件被当成资料 | 按文件系统 UUID 识别设备 |
+| 远程存储凭据泄露 | 不出现在命令行、DTO、审计日志中;建议使用只读密钥 |
+| 链接资料库被用来访问任意内网地址 | 只放行同源地址,只对勾选了它的会话生效,重定向逐跳检查 |
+| 集合扩大授权范围 | 集合页显示使用者,添加成员时提示 |
+| 可写托管目录被会话改坏 | 页面提示共享影响;本机原始资料不受影响 |
+
+## 11. 实测记录
+
+**2026-11-05**:bindfs 方案(已放弃,改为自己的文件系统)。
+
+| 测试 | 结果 |
+|---|---|
+| jerry-remote 不用 root 挂载 | ✅ |
+| 挂载传入平台服务的挂载命名空间(`/proc/<pid>/mountinfo`) | ✅ 平台服务中 `/srv/agent-server/knowledge` 是主机挂载的从属挂载 |
+| 只读、权限映射 | ✅ |
+| bubblewrap 沙箱中读取 | ✅ 可读、不可写、指向 `/home` 的符号链接读不到 |
+
+**2026-11-06**:go-fuse 原型(约 80 行)。
+
+| 测试 | 结果 |
+|---|---|
+| `ls -la`、`find`、`grep -r` | ✅ 看不到 `.ssh`、`.git`、`.env` |
+| 按路径直接读取隐藏文件 | ✅ "没有那个文件或目录" |
+| bubblewrap 沙箱中 | ✅ 同上 |
+| 只读、传入平台服务 | ✅ |
+
+**待测**(实施时):
+- 开启 `user_allow_other` 后,agentsvc 在平台服务和两个引擎的沙箱中读取挂载目录;
+- 外接设备拔出、插回(或用 root 创建 loop 设备模拟);
+- MinIO 加 rclone 两层挂载的读取、pandas 随机读取、断网与恢复。
+
+## 12. 以后可以支持的介质(不在本次范围)
+
+| 介质 | 方式 | 说明 |
+|---|---|---|
+| SMB、SFTP、FTP、HTTP 目录、HDFS、Azure Blob、GCS、Swift | rclone 原生支持 | 增加表单即可 |
+| NFS | 系统挂载到 `/mnt`,按本机目录登记 | rclone 不支持 |
+| Google Drive、OneDrive、Dropbox、Box 等 | rclone 原生支持 | 需要在后台完成 OAuth 授权;国内网络访问不稳定 |
+| 百度网盘、阿里云盘、夸克、115 等 | AList / OpenList 转为 WebDAV | 依赖非公开接口,可能失效,还有违反服务条款的风险,不建议作为正式来源 |
+| 飞书 / 钉钉文档、Confluence、Notion、语雀 | MCP 服务 | 在线文档不适合挂载成文件 |
+| Git 仓库 | 托管目录定期 `git pull` | 以后再考虑 |
+
+## 13. 测试
+
+- **挂载进程(Go 单元测试)**:
+  - 路径检查(根目录、home 本身、隐藏路径段、`..`、符号链接逃逸);
+  - 隐藏文件过滤;
+  - `openat2` 防路径替换;
+  - 设备识别:用模拟的 mountinfo 测试 UUID 解析、设备缺失、位置不允许、挂载表变化;
+  - 协议鉴权;
+  - `sync` 的增删改;
+  - rclone 子进程的状态判断与重启(用模拟的程序)。
+- **平台测试**:
+  - 四种类型的增删改与校验;
+  - 凭据只写不读、不进入审计日志;
+  - 集合并集与停用过滤;
+  - Profile 快照;
+  - 链接放行(同源放行、不同源拦截、重定向逐跳检查、未勾选的会话不放行);
+  - 全局禁止项包含 mounter 目录;
+  - 挂载进程不可用时的状态;
+  - 文件访问规则的限制。
+- **生产实测**:见 §11 待测项,以及在 Claude 和 Codex 会话中分别用 grep、pandas 读取本机目录和 MinIO 中的资料。
+
+## 14. 分期
+
+| 期 | 内容 | 依赖 |
+|---|---|---|
+| 一 | 资料库类型重构(托管目录、网络链接,去掉任意路径)、内网同源放行、资料集合 | 只改平台 |
+| 二 | 挂载进程:文件系统、隐藏文件过滤、本机目录、设备识别与自动恢复、install.sh / deploy.sh / systemd | 开启 `user_allow_other` |
+| 三 | 远程存储:rclone、S3 兼容、WebDAV、凭据 | 第二期;MinIO 测试环境 |
+
+每期完成后部署、实测,经你确认后再提交。
+
+## 15. 实施记录
+
+### 第一期(2026-11-06)
+
+- 数据:迁移 0024 重建 `knowledge_roots`(`kind`、`source`、`secrets`、`url`,去掉 `path`,Agent 看到的目录由类型和 id 派生);新增 `knowledge_collections`;`profiles.knowledge_collection_ids`。已有记录迁移为本机目录(只读),在生产数据库副本上验证过。
+- 服务端:`apps/server/src/knowledge/service.ts`(四种类型的增删改、集合、`effective()` 计算生效资料库、测试访问)、`knowledge/describe.ts`(提示词与 `kb_list_roots` 共用的清单、链接放行站点)。本机目录与远程存储暂时不能新建(需要挂载服务)。
+- 放行:网关 `web_fetch` 每一跳按目标是否与会话的链接资料库同源决定是否拦截内网;Claude 的 WebFetch 同样处理(`RunContext.fetchAllowOrigins`)。
+- 文件访问:只有托管目录可写;网络链接不对应路径;`/srv/agent-server/mounter/` 加入内置全局禁止项。去掉"任意路径"的检查(`checkKnowledgePath`),`/paths/check` 只保留通用检查。
+- 后台:资料库页按类型新建,显示状态与所属集合,托管目录可在文件管理中打开,网络链接可测试访问;资料集合的增删改与使用者;Profile 中分别勾选集合与单个资料库,并显示实际可用的列表。
+- 测试:`test/knowledge.test.ts`,以及 access-policy、gateway(同源放行、跳转到其他站点仍拦截)、file-manager 的相应修改。

+ 87 - 4
packages/shared/src/api.ts

@@ -87,6 +87,8 @@ export interface ProfileDto {
   skills: string[] | 'all'
   skills: string[] | 'all'
   fileAccess: FileAccessPolicy
   fileAccess: FileAccessPolicy
   knowledgeRootIds: string[]
   knowledgeRootIds: string[]
+  /** 勾选的资料集合;生效的资料库 = 单独勾选的 ∪ 集合的成员 */
+  knowledgeCollectionIds: string[]
   /** 可以使用的第三方 MCP 服务(内置工具始终可用) */
   /** 可以使用的第三方 MCP 服务(内置工具始终可用) */
   mcpServerIds: string[]
   mcpServerIds: string[]
   /** Codex 引擎:经 API 的会话可以使用的 Codex 插件(插件 ID,如 google-calendar@openai-curated-remote);默认不开放,后台 Playground 中始终全部开放 */
   /** Codex 引擎:经 API 的会话可以使用的 Codex 插件(插件 ID,如 google-calendar@openai-curated-remote);默认不开放,后台 Playground 中始终全部开放 */
@@ -100,7 +102,7 @@ export interface ProfileDto {
   updatedAt: string
   updatedAt: string
 }
 }
 
 
-export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt' | 'builtin' | 'builtinKey' | 'mcpServerIds' | 'codexPluginIds'> & { mcpServerIds?: string[]; codexPluginIds?: string[] }
+export type ProfileInput = Omit<ProfileDto, 'id' | 'version' | 'updatedAt' | 'builtin' | 'builtinKey' | 'mcpServerIds' | 'codexPluginIds' | 'knowledgeCollectionIds'> & { mcpServerIds?: string[]; codexPluginIds?: string[]; knowledgeCollectionIds?: string[] }
 
 
 /** Profile 历史版本的内容(不含"是否默认") */
 /** Profile 历史版本的内容(不含"是否默认") */
 export type ProfileSnapshot = Omit<ProfileInput, 'isDefault'>
 export type ProfileSnapshot = Omit<ProfileInput, 'isDefault'>
@@ -113,14 +115,95 @@ export interface ProfileVersionDto {
   changed: (keyof ProfileSnapshot)[]
   changed: (keyof ProfileSnapshot)[]
 }
 }
 
 
+/** 资料库类型(docs/design/21-knowledge-sources.md §3):托管目录、本机目录、远程存储、网络链接 */
+export type KnowledgeKind = 'managed' | 'local' | 'remote' | 'link'
+
+/**
+ * 资料库状态:ok 正常;path_missing 路径不存在;device_missing 设备未连接;not_allowed 位置不允许;
+ * unreachable 无法连接;auth_failed 认证失败;mounter_down 挂载服务不可用;pending 正在挂载
+ */
+export type KnowledgeState = 'ok' | 'path_missing' | 'device_missing' | 'not_allowed' | 'unreachable' | 'auth_failed' | 'mounter_down' | 'pending'
+
+export interface KnowledgeStatus {
+  state: KnowledgeState
+  message: string | null
+  /** 最近一次状态变化的时间(挂载服务提供;未知时为 null) */
+  since: string | null
+}
+
+/** 本机目录:登记的路径;在系统盘以外的设备上时,另记文件系统 UUID 与设备内路径(按设备识别,设备挂载位置变化也能找到) */
+export interface KnowledgeLocalSource {
+  path: string
+  uuid: string | null
+  relPath: string | null
+}
+
+export type KnowledgeRemoteType = 's3' | 'webdav'
+
+/** 远程存储:连接参数(不含凭据);凭据只写不读 */
+export interface KnowledgeRemoteSource {
+  type: KnowledgeRemoteType
+  params: Record<string, string>
+  secrets: { name: string; isSet: boolean }[]
+}
+
 export interface KnowledgeRootDto {
 export interface KnowledgeRootDto {
   id: string
   id: string
   name: string
   name: string
-  path: string
+  kind: KnowledgeKind
   description: string
   description: string
-  access: 'read' | 'write'
   enabled: boolean
   enabled: boolean
-  check: PathCheck
+  /** 只有托管目录可以是 write */
+  access: 'read' | 'write'
+  /** Agent 看到的目录(网络链接为空) */
+  path: string
+  /** 网络链接 */
+  url: string | null
+  local: KnowledgeLocalSource | null
+  remote: KnowledgeRemoteSource | null
+  status: KnowledgeStatus
+  /** 所属的资料集合 */
+  collectionIds: string[]
+}
+
+export interface KnowledgeInput {
+  kind: KnowledgeKind
+  name: string
+  description?: string
+  enabled?: boolean
+  access?: 'read' | 'write'
+  url?: string
+  /** 本机目录的路径 */
+  localPath?: string
+  /** 远程存储;secrets 的 value 为 null 表示沿用原值 */
+  remote?: { type: KnowledgeRemoteType; params: Record<string, string>; secrets: { name: string; value: string | null }[] }
+}
+
+/** 资料集合:把若干资料库归为一组,Profile 可以整体勾选(引用关系,成员变化立即生效) */
+export interface KnowledgeCollectionDto {
+  id: string
+  name: string
+  description: string
+  rootIds: string[]
+  /** 勾选了这个集合的 Profile */
+  usedBy: { id: string; name: string }[]
+  updatedAt: string
+}
+
+export interface KnowledgeCollectionInput {
+  name: string
+  description?: string
+  rootIds: string[]
+}
+
+/** 网络链接的测试访问结果 */
+export interface KnowledgeLinkTestDto {
+  ok: boolean
+  status: number | null
+  url: string
+  contentType: string | null
+  title: string | null
+  error: string | null
 }
 }
 
 
 export interface PathCheck {
 export interface PathCheck {