diff --git a/README.md b/README.md index a1a23c2..a686e7d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ Self-host with Docker — full runbook (build gate, DB backup, image roll-out, r | `DATA_DIR` | `./data` | SQLite location | | `STORAGE_DIR` | `./data/skills_files` | Skill files on disk | | `ASH_REQUIRE_REVIEW` | `1` | `0` publishes agent pushes directly (high-risk security hits are still held for review) | +| `ASH_EMBED_URL` | — | Optional semantic layer: an Ollama URL (e.g. `http://127.0.0.1:11434`) or an OpenAI-compatible `/v1` endpoint. Finds duplicates and matches tasks across languages | +| `ASH_EMBED_MODEL` | `bge-m3` | Embedding model (use a multilingual one); `ASH_EMBED_KEY` for OpenAI-compatible APIs | | `ASH_REGISTRATION` | `open` | `closed` disables self-service sign-up (create accounts with `npm run user:create`) | | `ASH_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` — which hops may set `X-Forwarded-*` (client IP for login rate limiting, `https` for `Secure` cookies) | | `ASH_SEED` | `1` | `0` skips the example skills given to each new account | diff --git a/client/scripts/format.test.mjs b/client/scripts/format.test.mjs index a78ab7b..cfcba86 100644 --- a/client/scripts/format.test.mjs +++ b/client/scripts/format.test.mjs @@ -4,8 +4,8 @@ import { formatShortDate, parseTimestamp, relativeTime } from '../src/utils/date import { folderLabels, targetFolderForView } from '../src/utils/systemFolders.js'; test('system views select inbox as the creation target; real folders are retained', () => { - assert.deepEqual(Object.keys(folderLabels).sort(), ['all', 'inbox', 'pending', 'recent', 'starred', 'trash']); - for (const view of ['all', 'starred', 'trash', 'recent', 'pending']) { + assert.deepEqual(Object.keys(folderLabels).sort(), ['all', 'attention', 'inbox', 'pending', 'recent', 'starred', 'trash']); + for (const view of ['all', 'starred', 'trash', 'recent', 'pending', 'attention']) { assert.equal(targetFolderForView(view), 'inbox'); } assert.equal(targetFolderForView('inbox'), 'inbox'); diff --git a/client/src/App.jsx b/client/src/App.jsx index 73a5abc..478ca8e 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -799,6 +799,7 @@ export default function App() { onSelectTag={handleSelectTag} onCopySkill={handleCopySkill} onChanged={refreshAll} + onOpenSkill={handleRevealSkill} apiRef={detailApiRef} /> diff --git a/client/src/components/FolderTree.jsx b/client/src/components/FolderTree.jsx index 089fed1..cc9bb18 100644 --- a/client/src/components/FolderTree.jsx +++ b/client/src/components/FolderTree.jsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + Activity, Archive, ClipboardPaste, ChevronDown, @@ -31,8 +32,10 @@ const navItems = [ { id: 'all', label: folderLabels.all, icon: Archive }, { id: 'trash', label: folderLabels.trash, icon: Trash2, droppable: true }, ]; -// 待审核只在有内容(或正在查看)时出现:关闭审核的部署里不占位 +// 待审核、需关注只在有内容(或正在查看)时出现:没有要处理的事就不占位 const pendingNavItem = { id: 'pending', label: folderLabels.pending, icon: ShieldCheck }; +// 需关注:由使用数据推出的信号(常失败 / 从未使用 / 长期未用 / 疑似重复),整理技能库从这里开始 +const attentionNavItem = { id: 'attention', label: folderLabels.attention, icon: Activity }; function findNode(nodes, path) { for (const node of nodes) { @@ -74,7 +77,11 @@ export default function FolderTree({ const [menuOpen, setMenuOpen] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [dropTarget, setDropTarget] = useState(null); - const systemItems = (stats?.pending > 0 || currentFolder === 'pending') ? [pendingNavItem, ...navItems] : navItems; + const systemItems = [ + ...(stats?.pending > 0 || currentFolder === 'pending' ? [pendingNavItem] : []), + ...(stats?.attention > 0 || currentFolder === 'attention' ? [attentionNavItem] : []), + ...navItems, + ]; const treeRef = useRef(null); useEffect(() => { diff --git a/client/src/components/RelationsPanel.jsx b/client/src/components/RelationsPanel.jsx new file mode 100644 index 0000000..61e52af --- /dev/null +++ b/client/src/components/RelationsPanel.jsx @@ -0,0 +1,121 @@ +import { AlertTriangle, GitBranch, Info, Link2 } from 'lucide-react'; +import './Usage.css'; + +const REF_STATUS = { + ok: null, + missing: { label: '库中没有', className: 'chip-danger' }, + pending: { label: '待审核', className: 'chip-pending' }, + foreign: { label: '其他账号', className: 'chip-muted' }, + 'pinned-old': { label: '固定在历史版本', className: 'chip-muted' }, + 'pin-missing': { label: '固定的版本不存在', className: 'chip-danger' }, +}; + +function SkillLink({ skill, onOpenSkill }) { + if (!skill?.id) return {skill?.slug}; + return ( + + ); +} + +// 技能检查结果:审核条与详情页共用 +export function LintList({ items }) { + if (!items?.length) return null; + return ( + + ); +} + +// 与其他技能相同的命令段 +export function SharedSteps({ items, onOpenSkill }) { + if (!items?.length) return null; + return ( + + ); +} + +// 详情页的引用关系:引用了谁、被谁引用、与谁有相同的步骤,以及技能检查(双语描述、引用写法、元技能契约) +export default function RelationsPanel({ detail, onOpenSkill }) { + const refs = detail?.refs; + if (!refs) return null; + const lint = detail.status === 'pending' ? [] : (detail.lint || []); + const shared = detail.shared_steps || []; + const hasContent = refs.meta || refs.declared.length || refs.undeclared.length || refs.dependents.length || shared.length || lint.length; + if (!hasContent) return null; + + return ( +
+
+
+ {refs.declared.length > 0 && ( +
+ 引用 + +
+ )} + {refs.undeclared.length > 0 && ( +
+ 正文还引用了 + {refs.undeclared.join('、')}(没写进 depends_on) +
+ )} + {refs.dependents.length > 0 && ( +
+ 被引用 + +
+ )} + {shared.length > 0 && ( +
i.code === 'copies-meta' || i.code === 'extract-candidate')}> + 与其他技能相同的步骤({shared.length}) + +
+ )} + {lint.length > 0 && ( +
i.level === 'warn')}> + 技能检查({lint.length}) + +
+ )} +
+ ); +} diff --git a/client/src/components/Review.css b/client/src/components/Review.css index 9f3f885..9ed1852 100644 --- a/client/src/components/Review.css +++ b/client/src/components/Review.css @@ -14,6 +14,8 @@ .chip-pending { color: var(--accent); border-color: var(--accent-border); background: var(--accent-muted); } .chip-danger { color: var(--danger-text); border-color: var(--danger-border); background: var(--danger-muted); } +.chip-warning { color: var(--text-body); border-color: color-mix(in srgb, var(--warning) 45%, transparent); background: color-mix(in srgb, var(--warning) 12%, transparent); } +.chip-muted { color: var(--text-muted); border-color: var(--border-muted); background: transparent; } .diff-view { display: grid; gap: .6rem; min-width: 0; } .diff-summary { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; color: var(--text-muted); font-size: var(--fs-xs); } diff --git a/client/src/components/ReviewPanel.jsx b/client/src/components/ReviewPanel.jsx index 222bf79..c869f68 100644 --- a/client/src/components/ReviewPanel.jsx +++ b/client/src/components/ReviewPanel.jsx @@ -2,13 +2,15 @@ import { useState } from 'react'; import { AlertTriangle, Check, GitCompare, ShieldAlert, X } from 'lucide-react'; import { DialogShell } from './Modals'; import DiffView from './DiffView'; +import { FeedbackList, SimilarSkills } from './UsagePanel'; +import { LintList, SharedSteps } from './RelationsPanel'; import { relativeTime } from '../utils/date'; import { requestJson } from '../utils/requestJson'; import { showToast } from './toastBus'; import './Review.css'; // 详情页顶部的审核条:待审核的新技能 / Agent 提交的待审更新 / 安全扫描提醒 -export default function ReviewPanel({ detail, onChanged }) { +export default function ReviewPanel({ detail, onChanged, onOpenSkill }) { const [busy, setBusy] = useState(''); const [error, setError] = useState(''); const [showDiff, setShowDiff] = useState(false); @@ -39,6 +41,13 @@ export default function ReviewPanel({ detail, onChanged }) { }; const source = update?.meta?.terminal_source || detail.terminal_source || 'Agent'; + // 判断依据:新技能看库里有没有相近的(有没有必要单独成为一个技能),更新看当前版本收到的失败反馈(这次更新在修什么) + const similar = detail.similar || []; + const currentFailures = (detail.usage?.recent_feedback || []).filter((f) => f.outcome === 'fail' && f.current); + // 技能检查(双语描述、引用写法、元技能契约、抄了元技能的步骤):新技能查它本身,更新查待审内容 + const checks = update ? (detail.pending_lint || []) : (detail.lint || []); + // 元技能的更新会影响所有引用它的技能 + const dependents = detail.refs?.meta ? (detail.refs.dependents || []) : []; return (
@@ -51,17 +60,54 @@ export default function ReviewPanel({ detail, onChanged }) { +
+

相似的已有技能

+ +
+ {(detail.shared_steps || []).length > 0 && ( +
+

与已有技能相同的步骤

+ +
+ )} + {checks.length > 0 && ( +
+

技能检查

+ +
+ )} )} {update && (
)} {[...warnings.map((w) => ({ ...w, scope: '当前版本' })), ...updateWarnings.map((w) => ({ ...w, scope: '待审更新' }))].map((w, i) => ( diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx index d62a41b..ecde356 100644 --- a/client/src/components/SkillDetail.jsx +++ b/client/src/components/SkillDetail.jsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { VersionHistoryModal } from './Modals'; import ReviewPanel from './ReviewPanel'; +import UsagePanel from './UsagePanel'; +import RelationsPanel from './RelationsPanel'; import DetailToolbar from './detail/DetailToolbar'; import DocOutline from './detail/DocOutline'; import FileTreeSidebar from './detail/FileTreeSidebar'; @@ -19,7 +21,7 @@ function readSkillDraft(skillId) { return null; } -export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag, onCopySkill, onChanged, apiRef }) { +export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag, onCopySkill, onChanged, onOpenSkill, apiRef }) { const [mode, setMode] = useState('preview'); const [content, setContent] = useState(''); // 列表接口不再回传 content,详情接口是唯一来源;savedContent 是「服务端上那一份」,用来判 dirty @@ -311,7 +313,7 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag
{copied ? '内容已复制' : ''}
{detailError &&
{detailError}
} - { setReloadKey((k) => k + 1); await onChanged?.(); }} /> + { setReloadKey((k) => k + 1); await onChanged?.(); }} /> {mode === 'preview' ? ( <> @@ -368,6 +370,9 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag
)} + + + {loadingDetail && !content ? (
正在载入技能详情…
) : content ? ( diff --git a/client/src/components/SkillList.jsx b/client/src/components/SkillList.jsx index 8bc9207..7c9f567 100644 --- a/client/src/components/SkillList.jsx +++ b/client/src/components/SkillList.jsx @@ -5,6 +5,13 @@ import { showToast } from './toastBus'; import { folderLabels } from '../utils/systemFolders'; import './Review.css'; import { formatShortDate } from '../utils/date'; + +const HEALTH_CHIP = { + failing: 'chip-danger', broken_ref: 'chip-danger', duplicate: 'chip-warning', copies_meta: 'chip-warning', + extractable: 'chip-warning', meta_contract: 'chip-warning', unused: 'chip-muted', stale: 'chip-muted', +}; +// 平时只标需要尽快处理的;「需关注」视图里全部列出 +const ALWAYS_SHOWN = ['failing', 'broken_ref', 'duplicate']; import { ArrowDownAZ, Check, @@ -70,6 +77,7 @@ export default function SkillList({ onCollapse, }) { const isTrash = currentFolder === 'trash'; + const isAttention = currentFolder === 'attention'; const listTitle = currentTag ? `标签:${currentTag}` : folderLabels[currentFolder] || currentFolder; // 选到只剩 1 项时批量条不该消失:以「是否处于多选模式」为准,而不是 size > 1 const multiCount = multiSelectMode ? selectedIds.size : 0; @@ -252,9 +260,9 @@ export default function SkillList({ ) : skills.length === 0 ? (
- {searchQuery ? '没有匹配的技能' : isTrash ? '废纸篓为空' : '这里还没有技能'} - {searchQuery ? '尝试缩短关键词或清除筛选。' : isTrash ? '移入废纸篓的技能会显示在这里。' : '创建技能后即可开始整理。'} - {!isTrash && !searchQuery && } + {searchQuery ? '没有匹配的技能' : isTrash ? '废纸篓为空' : isAttention ? '没有需要关注的技能' : '这里还没有技能'} + {searchQuery ? '尝试缩短关键词或清除筛选。' : isTrash ? '移入废纸篓的技能会显示在这里。' : isAttention ? '常失败、从未使用、长期未用或疑似重复的技能会出现在这里。' : '创建技能后即可开始整理。'} + {!isTrash && !isAttention && !searchQuery && }
) : (
    @@ -303,6 +311,9 @@ export default function SkillList({ {skill.status === 'pending' && 待审} {skill.has_pending_update && 有更新} {skill.warning_count > 0 && ⚠ {skill.warning_count}} + {(skill.health || []).filter((h) => isAttention || ALWAYS_SHOWN.includes(h.code)).map((h) => ( + {h.label} + ))} {skill.version && v{skill.version}} {(skill.tags || []).slice(0, 2).map((tag) => ( + {typeof s.similarity === 'number' && ( + + {s.duplicate ? '疑似重复 · ' : ''}{s.semantic ? '语义 ' : ''}{Math.round(s.similarity * 100)}% + + )} + {s.status === 'pending' && 待审} + + ))} +
+ ); +} + +export function FeedbackList({ items }) { + if (!items?.length) return null; + return ( +
    + {items.map((f, i) => ( +
  • + {f.outcome === 'fail' ? : } + {f.note || (f.outcome === 'fail' ? '(未写原因)' : '走通了')} + + {f.terminal || '未知来源'} · {relativeTime(f.created_at)}{f.current ? '' : ' · 旧版本'} + +
  • + ))} +
+ ); +} + +// 详情页的使用情况:安装、阅读、Agent 反馈与健康信号,外加可能重复的技能。整理技能库时看这里 +export default function UsagePanel({ detail, onOpenSkill }) { + const usage = detail?.usage; + if (!usage || detail.status === 'pending') return null; + const feedbackTotal = usage.ok + usage.fail; + const currentTotal = usage.ok_current + usage.fail_current; + const similar = detail.similar || []; + const stats = [ + `30 天安装 ${usage.installs_30d} 次`, + usage.machines ? `装在 ${usage.machines} 台机器上` : null, + usage.views_30d ? `阅读 ${usage.views_30d} 次` : null, + feedbackTotal + ? `反馈 ${usage.ok} 成功 / ${usage.fail} 失败${currentTotal !== feedbackTotal ? `(当前版本 ${usage.ok_current} / ${usage.fail_current})` : ''}` + : '暂无反馈', + usage.last_used_at ? `最近使用 ${relativeTime(usage.last_used_at)}` : null, + ].filter(Boolean); + + return ( +
+
+
+ {usage.health.length > 0 && ( +
+ {usage.health.map((h) => ( + {h.label} + ))} + {usage.health.map((h) => h.detail).join(';')} +
+ )} + {usage.recent_feedback.length > 0 && ( +
0}> + 最近的反馈({usage.recent_feedback.length}) + +
+ )} + {similar.length > 0 && ( +
s.duplicate)}> + 相似技能({similar.length}) + +
+ )} +
+ ); +} diff --git a/client/src/landing/Docs.jsx b/client/src/landing/Docs.jsx index 11ed86e..012ac0c 100644 --- a/client/src/landing/Docs.jsx +++ b/client/src/landing/Docs.jsx @@ -9,7 +9,7 @@ import { SiteFooter, SiteHeader } from './SiteChrome'; const TOC = [ { group: '入门', items: [['overview', '概览'], ['quickstart', '快速开始'], ['accounts', '账号与 token']] }, - { group: '技能', items: [['skill-format', '技能格式'], ['cli', 'ash 命令行'], ['local', '安装与本地管理'], ['push-review', '推送与审核'], ['versions', '版本历史'], ['bundles', '技能组合'], ['graph', '依赖图']] }, + { group: '技能', items: [['skill-format', '技能格式'], ['cli', 'ash 命令行'], ['local', '安装与本地管理'], ['push-review', '推送与审核'], ['versions', '版本历史'], ['references', '技能之间的引用'], ['bundles', '技能组合'], ['graph', '依赖图']] }, { group: '使用', items: [['web', '网页端'], ['agents', '接入 Agent'], ['http', 'HTTP 接口']] }, { group: '运维', items: [['self-host', '自托管与配置'], ['security', '安全模型'], ['faq', '常见问题']] }, ]; @@ -156,7 +156,7 @@ export default function Docs({ user, registration }) {
  • 查找并安装技能

    - {'ash search 部署\nash info \nash pull '} + {'ash suggest "把服务部署到测试环境"\nash info \nash pull '}
  • 让 Agent 用起来

    @@ -190,9 +190,10 @@ export default function Docs({ user, registration }) { {`--- name: my-skill # 英文标识符 slug,也是安装目录名 description: 一句话说明「什么时候应该使用这个技能」,Agent 靠它判断是否调用 +description_en: Use when … # 另一种语言的描述(description 是英文时写 description_zh) tags: [ops, debugging] version: 1.0.0 -depends_on: [other-skill] # 可选,依赖的其他技能 slug +depends_on: [other-skill] # 可选,引用的其他技能,见「技能之间的引用」 --- # 人类可读的标题 @@ -211,10 +212,13 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug ['slug', <>显式传入 → frontmatter name → 文件名 → 正文第一个 # 标题,取第一个能转成英文标识符的(只保留小写字母、数字、-、_)。纯中文得不到 slug,会报错。], ['显示名称', <>显式传入 → 正文第一个 # 标题 → frontmatter name → 文件名], ['description', '显式传入 → frontmatter description。缺少时会提示:写清「何时使用」,Agent 靠它判断。'], + ['双语描述', <>description_en / description_zh(也认 metadata 里的同名键),补上 description 缺的那种语言;title_en / title_zh 可选。缺一种语言时会提示。], ['tags', '数组或逗号分隔的字符串都可以'], ['version', <>默认 1.0.0], - ['依赖', <>depends_on、dependencies、requires 或 metadata.depends_on], + ['依赖', <>depends_on、dependencies、requires 或 metadata.depends_on(metadata 里写成逗号分隔的字符串)], ]} /> +

    为什么要双语描述

    +

    技能库常是中英混写的。只有一种语言的描述时,用另一种语言描述的任务找不到它,内容相同、语言不同的两个技能也查不出重复。补一句另一种语言的 description 就够了:检索和查重都会用上它,以后网页做双语界面也直接用它。给已有技能补:ash lint --only bilingual 列出缺的;只改双语字段的更新直接发布,不用审核(Agent 框架不读这些字段,不影响 Agent 的行为)。配置了语义层时,没有双语描述的技能也能跨语言查重。正文不用写两份——两份正文要同步维护、迟早不一致,而读正文的 Agent 中英文都懂。

    附属文件限制

    • 只收文本类文件(.md .sh .py .js .ts .json .yaml .toml .sql .csv .svg 等);二进制、压缩包、密钥文件(.pem .key .env)会被跳过。服务端可用 ASH_ALLOWED_EXTS 调整白名单。
    • @@ -233,12 +237,13 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug

      命令参考

      ash login [--token T], '登录;ash whoami 查看账号,ash logout 删除本机 token'], - [ash search <关键词…> [--tag T] [--folder F] [--json], '搜索已发布技能,多个关键词需同时命中(名称、slug、描述、正文)'], + [ash suggest "<任务描述>" [--limit N] [--json], '按任务描述找相关技能,标明「相关 / 可能相关」;没有合适的会直接说没有'], + [ash search <关键词…> [--tag T] [--folder F] [--json], '搜索已发布技能,多个关键词需同时命中(名称、slug、描述、正文);都不命中时给出相关候选'], [ash list [--tag T] [--folder F] [--json], '列出已发布技能'], - [ash info <slug>, '描述、依赖(含缺失的依赖)、文件清单、版本、修订号和安装命令'], + [ash info <slug>, '描述、依赖(含缺失的依赖)、使用情况与健康信号、文件清单、版本、修订号和安装命令'], [ash show <slug> [--version ID] [--pending], '直接输出 SKILL.md,不安装'], [ash versions <slug>, '历史版本列表'], - [ash pull <slug> [选项], <>安装技能及其依赖。选项:--agent claude|codex|hermes|dsh、--dir PATH、--pending、--force、--no-deps], + [ash pull <slug>[@版本] [选项], <>安装技能及其依赖。选项:--agent claude|codex|hermes|dsh|all、--dir PATH、--pending、--force、--no-deps;@版本 安装历史里的那一版并固定], [ash pull bundle:<标识或名称>, '安装整个技能组合'], [ash pull --all [--dir PATH] [--force], '更新所有过期的已装技能(本地改过的跳过,除非 --force)'], [<>ash installed / ash outdated, '本机已装技能及状态 / 只列需要处理的'], @@ -247,6 +252,9 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug [ash push <目录|SKILL.md> [--update] [--folder PATH], '推送技能;同名已存在时必须加 --update'], [ash mine, '本机推送过的技能及审核结果'], [ash withdraw <slug>, '撤回自己仍在待审核的推送'], + [ash feedback <slug> ok|fail ["说明"] [--dir PATH], '用完技能后回报结果,自动对应本机已装的版本'], + [ash lint [slug] [--only CODE] [--json], <>技能检查:双语描述、引用、元技能契约、相同的步骤、能否被调用;不带 slug 列出全库有问题的技能;--only bilingual 只看缺双语描述的], + [ash doctor [--fix] [--dir PATH], '检查本机各 Agent 能不能用上已装的技能:引用的技能在不在旁边、目录有没有 Agent 加载;--fix 自动补装'], [ash guide, '输出 Agent 使用指南'], [ash open, '在浏览器打开应用'], [ash update, '更新 ash 自身'], @@ -255,21 +263,24 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug
      ASH_SERVER_URL, '服务地址(默认是安装时的地址)'], [ASH_TOKEN, <>API token,优先于 ~/.ash/token], - [ASH_SKILLS_DIR, '默认安装目录'], + [ASH_SKILLS_DIR, '默认安装目录(未设置时在 Claude Code 里默认 ~/.claude/skills)'], + [ASH_AGENT, <>未指定 --agent / --dir / ASH_SKILLS_DIR 时装到哪个 Agent:claude、codex、hermes、dsh 或 all(每个 Agent 都装)], [ASH_TERMINAL, '推送时记录的来源名,默认是主机名;ash mine / withdraw 按它识别「本机的推送」'], [ASH_BIN_DIR, 'ash 自身的安装位置'], + [ASH_FEEDBACK_HINT, <>设为 0 时安装不在 SKILL.md 末尾附加反馈提示], ]} />

      装到哪里

      -

      默认自动探测:$ASH_SKILLS_DIR → 唯一的 ~/.hermes/profiles/*/skills → ~/.hermes/skills → ~/.agents/skills → ~/.claude/skills → ~/.dsh/skills,都不存在时用 ~/.agents/skills。--agent 指定 Agent 的标准目录,--dir 指定任意目录。

      +

      默认自动探测:$ASH_SKILLS_DIR → 在 Claude Code 里运行时 ~/.claude/skills(它只从这里加载技能)→ 唯一的 ~/.hermes/profiles/*/skills → ~/.hermes/skills → ~/.agents/skills → ~/.claude/skills → ~/.dsh/skills,都不存在时用 ~/.agents/skills。--agent 指定 Agent 的标准目录,--dir 指定任意目录。

      安装做了什么

      • 下载完整技能包,先解压到同级的临时目录,成功后整体替换,所以上游删掉的文件本地也会消失。
      • 在技能目录里写一个 .ash 文件,记录来源服务、修订号、版本和内容指纹。不要编辑或删除它,推送时会自动排除。
      • 同名目录本地改过、或不是 ash 装的,会先备份到 ~/.ash/backups/ 再覆盖;--force 跳过备份。
      • 依赖装到同一个目录;已经装过的不动,循环依赖不会死循环,缺失的依赖给出警告。--no-deps 不装依赖。
      • +
      • 在 SKILL.md 末尾附一段说明:引用的技能在本机的相对路径、用完怎么 ash feedback。推送回库时这段会自动去掉,写在它后面的修改照常保留;ASH_FEEDBACK_HINT=0 可不附加。

      状态

      推送{'ash push ./my-skill/ # 推送整个目录(推荐,含附属文件)\nash push ./my-skill/ --update # 更新已有技能\nash push ./SKILL.md # 只推送一个文件\nash push ./my-skill/ --folder Ops/Deploy # 新技能放进指定文件夹'}
        -
      • 先 ash search 查重。已有相近技能时,ash pull 下来在原版上改,再 --update 推送。
      • +
      • 先 ash suggest "<新技能要解决的问题>" 查重。已有相近技能时,ash pull 下来在原版上改,再 --update 推送;部分步骤已经是别的技能(尤其元技能)的内容时,用 depends_on 引用它,不要复制。
      • +
      • 推送新技能时,服务端会比对库里名称、描述、标签和标题相近的技能,在推送结果里提示「库中已有相近技能」(相似度 40% 以上标为疑似重复)。
      • 不加 --update 时,同名 slug 已存在会被拒绝(409),不会覆盖已有版本;废纸篓里的同名技能要先在网页上恢复或彻底删除。
      • 内容没有变化的推送不会产生新版本。更新时名称、标签、所在文件夹保留网页上维护的值,只替换内容、描述和版本。
      @@ -295,9 +307,20 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug
    • 推送的新技能状态为「待审核」:Agent 默认拉不到,推送者自己要用时加 --pending。
    • 对已发布技能的更新挂在原技能上等待审核,采纳前 Agent 拉到的仍是当前发布版本。
    • 在应用的「待审核」里查看内容、与当前版本的差异和安全提醒,然后采纳或拒绝。拒绝新技能会移入废纸篓(可恢复),拒绝更新直接丢弃。
    • +
    • 审核条会给出判断依据:新技能列出相似的已有技能(有没有必要单独成为一个技能),更新列出当前版本收到的失败反馈(这次更新在修什么)。
    • 推送者用 ash mine 查看结果,用 ash withdraw 撤回仍在待审的推送(只能撤回本机推送的)。
    • 服务端设置 ASH_REQUIRE_REVIEW=0 可以关闭审核,但命中高危规则的推送仍然强制待审。
    • +

      使用情况与反馈

      +

      技能装到本地后由 Agent 直接读取,服务端看不到「使用」本身,所以靠三种信号:安装(记下装到了哪台机器)、阅读(ash show)和反馈(ash feedback)。通过 ash 安装的 SKILL.md 末尾会附一行提示,请 Agent 用完回报结果;推送回库时这一行会被自动去掉,写在它后面的修改照常保留。

      +

      反馈记在 Agent 本机所装的版本上:技能更新之后,旧版本收到的失败不再算在新版本头上。由此得出的健康信号:

      +
      +

      这些技能汇总在应用的「需关注」视图里,按上面的顺序排列:先修常失败的,再合并重复的,最后清理不用的。详情页的「使用情况」列出最近的反馈和相似技能。

      安全扫描

      SKILL.md 和所有附属文件都会被扫描。结果随技能保存、在详情页显示,只提示不拦截;高危项强制进入人工审核。

      {'ash bundles\nash bundle 感知工具箱\nash pull bundle:感知工具箱'} +
      +

      一段操作已经有技能(尤其是元技能)负责时,引用它,而不是把步骤抄进来:抄过来的两份会漂移,一个坑就要修两处。

      +

      怎么写

      +
        +
      1. frontmatter 的 depends_on 声明它:ash pull 据此把它一并装在本技能旁边。
      2. +
      3. 正文在用到它的地方写「用元技能 <slug>」,后面只写本技能特有的参数和坑;也可以写成相对链接 ../<slug>/SKILL.md。不要写绝对路径。
      4. +
      + {'## 编译\n> 用元技能 `dev-container-build`\n> - target: `//app:main`\n> - **本技能特有**:必须加 `--build_tag_filters=sim`'} +
      slug, '本库里的技能'], + [@账号/slug, '指定账号的技能。为团队库、技能广场预留,目前只能指向自己的账号,指向别人会提示'], + [slug@1.2.0, <>固定版本:ash pull 从历史里装那一版,ash pull --all 不会升级它。版本号来自 frontmatter 的 version,改了内容要记得升版本号], + ]} /> +

      本地 Agent 怎么找到被引用的技能

      +

      各家 Agent(Claude Code、Codex 等)的技能格式都没有「依赖」字段:它们看到的只是技能的名称、描述和正文,也只从各自的目录加载技能。所以引用要靠这几件事落地:

      +
        +
      • 装在一起:ash pull 把 depends_on 里的技能装在同一个目录下,../<slug>/SKILL.md 一定对得上。
      • +
      • 告诉它在哪:已装的 SKILL.md 末尾会列出引用的技能及其相对路径,任何能读文件的 Agent 都能顺着找到(推送回库时这段会自动去掉)。
      • +
      • 装对位置:Claude Code 只从 ~/.claude/skills 和项目的 .claude/skills 加载技能,Codex 从 ~/.agents/skills,Hermes 从 ~/.hermes/skills。在 Claude Code 里运行 ash pull 时默认装到 ~/.claude/skills;一台机器上同时用几个 Agent 时,ash pull <slug> --agent all 装进每个 Agent 的目录(或设置 ASH_AGENT=all)。
      • +
      • 告诉 Agent 怎么用:遇到「用元技能 x」时,Claude Code 直接调用同名技能;其他 Agent 读 ../x/SKILL.md;本机没有就 ash pull x。这条规则写在 /agent.md 里,也写在已装技能末尾的说明里。
      • +
      • 检查本机:ash doctor 逐个目录检查引用的技能在不在旁边、装的目录有没有 Agent 加载、被引用的技能能不能被调用;ash doctor --fix 自动补装缺的。
      • +
      • 让它调用得到:被引用的技能不要设置 disable-model-invocation: true;name 与 slug 一致、只用小写字母数字和连字符;description 不超过 1024 个字符。ash lint 会检查。
      • +
      +

      元技能

      +

      元技能是被别的技能在流程中引用的基础动作:调用方只说「要做什么」,元技能负责「怎么做」。已经有 3 个以上技能在重复同一段操作、它与业务无关、输入输出说得清时才值得抽——第 1 次写在原地,第 2 次忍住,第 3 次才抽。写法:

      +
        +
      • description 以「【元技能】」开头,写明「被其他技能引用」和「被直接要求」两种触发场景。
      • +
      • 正文必须有 ## 契约(输入 / 输出 / 前置 / 失败)和 ## 被谁引用 两节。
      • +
      • 不要加 disable-model-invocation: true,否则别的技能引用不到它。
      • +
      +

      每多一层引用,Agent 就多读一个文件、多一个可能断的地方。引用链超过两层时检查会提醒;一小段、只有一两处用到的步骤,留在原地比抽出去好。

      +

      自动检查

      +

      ash lint [slug]、推送结果、网页详情页的「引用关系」和审核条都会给出同一套检查:

      +
      两种比对:shell 代码块里的命令归一化后精确比对;编号列表、复选框里的文字步骤按词重合度模糊比对(措辞略有不同也算同一步)。与元技能有 3 条以上相同,提示改为引用;与另外 2 个以上技能相同,提示可以提炼元技能], + ]} /> +

      其中引用失效、抄了元技能步骤、可提炼元技能、元技能缺契约也会出现在「需关注」视图里。

      + +

      应用侧栏的「依赖图」把技能之间的关系画成一张图。只认明确的信号,正文里随口提到某个技能名不算:

      • 依赖:frontmatter 里声明的 depends_on 等字段。指向库里不存在的技能会标为未解析。
      • 引用:正文里指向其他技能 SKILL.md 的相对链接(如 ../verification-discipline/SKILL.md),以及同一行里既出现「元技能 / meta-skill」、又出现某个元技能完整 slug 的写法。代码块里的示例不算。
      • -
      • 图中只画出指向元技能的边。元技能的判定:frontmatter type: meta、标签含 meta / meta-skill / 元技能、描述以「【元技能】」开头,或放在名为「元技能」的文件夹里。
      • +
      • 图中只画出指向元技能的边。元技能的判定:frontmatter type: meta、标签含 meta-skill / 元技能、描述以「【元技能】」开头,或放在名为「元技能」的文件夹里。单独的 meta 标签不算(它常指「关于技能本身」的技能)。
      • 三栏布局:文件夹 / 技能列表 / 详情,三栏宽度可拖动并会记住;侧栏和列表都可以折叠。
      • -
      • 系统视图:收件箱(新技能默认放这里)、收藏、最近浏览、待审核、全部、废纸篓。
      • +
      • 系统视图:收件箱(新技能默认放这里)、收藏、最近浏览、待审核、需关注、全部、废纸篓。待审核和需关注只在有内容时出现。
      • 整理:拖拽技能到文件夹、收藏、多选批量操作、右键菜单;文件夹可以多级嵌套、重命名和删除(删除时里面的技能移回收件箱)。
      • 编辑:新建技能、粘贴整份 SKILL.md 导入(实时预览识别出的 slug、标签和冲突),详情页里直接编辑名称、描述和 SKILL.md 正文(附属文件随推送更新)。
      • 分享:地址栏的 /app?skill=<slug> 可以直接定位到某个技能;浏览器打开 /s/<slug> 也会跳到这里。详情页可以一键复制给 Agent 的指令。
      • @@ -365,7 +432,8 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug {onboardingPrompt(origin)}

        约定的要点:

          -
        • 接到项目特定或不熟悉的任务时,先 ash search,中英文同义词都试;搜不到就按常规方式做,不硬套。
        • +
        • 接到项目特定或不熟悉的任务时,先 ash suggest "<任务描述>",中英文关键名词都写上;没有合适的就按常规方式做,不硬套。
        • +
        • 按技能完成(或没能完成)任务后,用 ash feedback 回报一次结果;失败时写一句哪一步、为什么。
        • 需要执行脚本的技能用 ash pull 安装后按 SKILL.md 执行;只读说明用 ash show。
        • 做完可复用的流程再推送;一次性记录、会话相关内容和任何密钥、口令、隐私都不推送。
        • 提示未登录时,Agent 应该请你运行 ash login,而不是自己去要或输入密码。
        • @@ -409,7 +477,18 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug [ASH_SEED, 1, <>0 新账号不带示例技能], [ASH_ALLOWED_EXTS, '文本类', '附属文件扩展名白名单,逗号分隔'], [ASH_TRUST_PROXY, '本机与私网', <>哪些上游可以设置 X-Forwarded-*], + [ASH_EMBED_URL, '不设置', <>可选语义层:Ollama 地址(如 http://127.0.0.1:11434)或以 /v1 结尾的 OpenAI 兼容接口], + [ASH_EMBED_MODEL, bge-m3, '向量模型(需多语言模型才能跨语言对上)'], + [ASH_EMBED_KEY, '不设置', 'OpenAI 兼容接口的 API key'], + [<>ASH_EMBED_PAIR_THRESHOLDS / ASH_EMBED_QUERY_THRESHOLDS, <>0.68,0.74 / 0.58,0.68, '换模型时重新校准:技能之间「相近、疑似重复」、任务描述对技能「可能相关、相关」的余弦阈值'], ]} /> +

          语义层(可选)

          +

          纯文本比对看不见「中英文写法不同」的同类技能:中文写的技能和英文写的技能没有一个共同的词。配置一个多语言向量模型后,服务端为每个技能的名称、描述和标题算一个向量(存在数据库里,内容不变不重算),用于:

          +
            +
          • 查重:只对主语言不同的两个技能用语义分数补位。同语言的同系列技能(例如一组飞书技能)语义上很像却不是重复,仍按文本比对。
          • +
          • 按任务找技能:相关度取文本、语义两者较高的,中文描述也能找到英文写的技能。
          • +
          +

          推荐 Ollama + bge-m3(ollama pull bge-m3,约 1.2GB,CPU 即可)。不配置时一切照旧;模型服务不可用时自动退回文本比对。服务端运行在 Docker 里时,Ollama 地址写宿主机地址(如 http://host.docker.internal:11434)。

          创建账号 / 认领旧数据

          {'npm run user:create -- <用户名> [--admin]\n# Docker:docker exec -it ash npm run user:create -- <用户名> --admin'}

          从没有账号体系的旧版本升级时,旧数据会被迁移但不属于任何人(谁都看不到)。先用 ASH_REGISTRATION=closed 部署,再由管理员认领,文件会一并搬到该账号的目录:

          diff --git a/client/src/utils/systemFolders.js b/client/src/utils/systemFolders.js index e7e5708..54b6678 100644 --- a/client/src/utils/systemFolders.js +++ b/client/src/utils/systemFolders.js @@ -5,6 +5,7 @@ export const folderLabels = { trash: '废纸篓', recent: '最近浏览', pending: '待审核', + attention: '需关注', }; // 系统视图中只有 inbox 是实际可存放技能的目录。 diff --git a/docs/deploy.md b/docs/deploy.md index 5680c95..3411e64 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -71,6 +71,15 @@ deploy/cd/install.sh # 复制脚本到 ~/services/skillhub-cd/bin/ash-cd, | 发布目录 | `/home/liukun/services/skillhub-releases//`(每个版本一个目录,含源码和 compose.deploy.yml) | | 公网入口 | https://ash.709970.xyz(反向 SSH 隧道 38084→9444,公网网关 Nginx 反代,独立于本流程) | | 端口 | 容器 9444,仅绑服务器 127.0.0.1 | +| 语义层 | 宿主机 Ollama(`bge-m3`,只监听 127.0.0.1:11434)→ systemd 用户服务 `ollama-bridge` 转发到 Docker 网桥 `172.17.0.1:11434` → 容器里 `ASH_EMBED_URL=http://host.docker.internal:11434` | + +### 语义层(可选,跨语言查重与找技能) + +- 配置在当前发布的 `compose.deploy.yml` 里:`ASH_EMBED_URL`、`ASH_EMBED_MODEL` 两个环境变量,加上 `extra_hosts: ["host.docker.internal:host-gateway"]`。CD 派生新版本的 compose 时原样继承,不需要每次改。 +- Ollama 没有鉴权,所以不改成监听 0.0.0.0;`ollama-bridge`(`~/services/ollama-bridge/forward.py`)只在 Docker 网桥地址上转发,只有本机容器访问得到。 + - 状态:`systemctl --user status ollama-bridge`;日志:`journalctl --user -u ollama-bridge` + - 从容器里自检:`docker exec ash node -e "fetch('http://host.docker.internal:11434/api/tags').then(r=>console.log(r.status))"` +- 关掉语义层:从 compose 里删掉这两个变量后 `docker compose -p ash -f compose.deploy.yml up -d`。服务端退回纯文本比对,其余不受影响;模型服务临时不可用时也会自动退回。 ## 手动部署五步(CD 的后备) diff --git a/server/db.js b/server/db.js index 93f78f5..6973bbc 100644 --- a/server/db.js +++ b/server/db.js @@ -95,6 +95,17 @@ if (db.pragma('user_version', { simple: true }) < 1) { try { const cols = db.prepare('PRAGMA table_info(skill_versions)').all().map((c) => c.name); if (!cols.includes('label')) db.exec('ALTER TABLE skill_versions ADD COLUMN label TEXT DEFAULT NULL'); + // 快照对应的版本号:固定版本的引用(slug@1.2.0)据此从历史里取出那一版来安装 + if (!cols.includes('version')) { + db.exec('ALTER TABLE skill_versions ADD COLUMN version TEXT DEFAULT NULL'); + const matter = require('gray-matter'); + const update = db.prepare('UPDATE skill_versions SET version = ? WHERE id = ?'); + for (const row of db.prepare('SELECT id, content FROM skill_versions').all()) { + let version = null; + try { version = matter(row.content || '', {}).data.version; } catch { /* 坏掉的 frontmatter 没有版本号 */ } + if (version !== undefined && version !== null && String(version).trim()) update.run(String(version).trim(), row.id); + } + } } catch (e) { console.error('migration:', e.message); } // 审核流:status=pending 的技能只对人可见,Agent 默认拉不到; @@ -229,6 +240,39 @@ if (db.pragma('user_version', { simple: true }) < 1) { })(); } +// ——— 使用记录:安装、阅读、Agent 反馈 ——— +// actor_id 是触发者(token / 会话所属用户)。个人库里就是技能主人;以后团队库、技能广场里会是别人。 +// revision 是事件发生时对应的修订号(反馈为本机已装版本),技能更新后旧版本的失败不再算在当前版本头上。 +// 建在 v1 迁移之后:迁移会重建 skills 表,外键不能先指向旧表。 +db.exec(` + CREATE TABLE IF NOT EXISTS skill_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + skill_id INTEGER NOT NULL, + actor_id INTEGER, + kind TEXT NOT NULL, -- install | dependency | update | view | feedback + outcome TEXT, -- feedback: ok | fail + note TEXT, + terminal TEXT, + revision TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_skill_events_skill ON skill_events(skill_id, created_at); + + -- 可选语义层的向量缓存(见 embeddings.js):text_hash = 模型 + 技能意图文本的哈希,文本没变就不重算 + CREATE TABLE IF NOT EXISTS skill_embeddings ( + skill_id INTEGER PRIMARY KEY, + text_hash TEXT NOT NULL, + vector BLOB NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT); + -- 开始记录使用的时间:在此之前没有数据,「从未使用 / 长期未用」只能从这里起算 + INSERT OR IGNORE INTO app_meta (key, value) VALUES ('usage_tracking_since', strftime('%Y-%m-%d %H:%M:%S', 'now')); +`); + // 新用户(注册或认领时)的内置目录 function ensureUserDefaults(userId) { db.prepare('INSERT OR IGNORE INTO folders (user_id, path, name, parent_path) VALUES (?, ?, ?, ?)').run(userId, 'inbox', '收件箱 (Inbox)', ''); diff --git a/server/embeddings.js b/server/embeddings.js new file mode 100644 index 0000000..f89dc52 --- /dev/null +++ b/server/embeddings.js @@ -0,0 +1,149 @@ +// 可选的语义层:配置了向量模型后,查重与按任务找技能在「中英文写法不同」的情况下也能对上—— +// 纯文本比对对这种情况是瞎的(中文写的技能和英文写的同类技能没有一个共同的词)。 +// +// 配置(都不设时一切照旧,只用文本比对): +// ASH_EMBED_URL Ollama 地址(如 http://127.0.0.1:11434),或 OpenAI 兼容接口(以 /v1 结尾) +// ASH_EMBED_MODEL 模型名,默认 bge-m3(多语言;Ollama 上 ollama pull bge-m3) +// ASH_EMBED_KEY OpenAI 兼容接口的 API key(可选) +// +// 语义分数只在文本比对看不见的地方补位(见 skillIndex):同语言的同系列技能语义上很像却不是重复, +// 所以相似度只对主语言不同的两个技能用语义;按任务找技能时取文本、语义两者较高的相关度。 +// 向量按「模型 + 文本」的哈希存进 skill_embeddings,内容不变就不重算;模型服务不可用时静默退回文本比对。 +const crypto = require('crypto'); +const db = require('./db'); + +const config = () => ({ + url: String(process.env.ASH_EMBED_URL || '').replace(/\/+$/, ''), + model: process.env.ASH_EMBED_MODEL || 'bge-m3', + key: process.env.ASH_EMBED_KEY || '', + timeout: Number(process.env.ASH_EMBED_TIMEOUT_MS) || 15000, +}); +const enabled = () => Boolean(config().url); +const BATCH = 16; + +let lastError = null; +let lastErrorLogged = 0; +function noteError(err) { + lastError = err.message; + // 模型服务挂了时每个请求都会失败:日志每分钟最多一条 + if (Date.now() - lastErrorLogged > 60000) { console.error('embeddings:', err.message); lastErrorLogged = Date.now(); } +} + +const textHash = (text) => crypto.createHash('sha256').update(`${config().model}\0${text}`).digest('hex').slice(0, 16); + +function normalize(values) { + const vec = Float32Array.from(values); + let norm = 0; + for (const x of vec) norm += x * x; + norm = Math.sqrt(norm) || 1; + for (let i = 0; i < vec.length; i += 1) vec[i] /= norm; + return vec; +} + +// 两个单位向量的余弦 +function cosine(a, b) { + if (!a || !b || a.length !== b.length) return 0; + let dot = 0; + for (let i = 0; i < a.length; i += 1) dot += a[i] * b[i]; + return dot; +} + +async function embed(texts) { + const { url, model, key, timeout } = config(); + const openai = /\/v1$/.test(url); + const res = await fetch(openai ? `${url}/embeddings` : `${url}/api/embed`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) }, + body: JSON.stringify({ model, input: texts }), + signal: AbortSignal.timeout(timeout), + }); + if (!res.ok) throw new Error(`向量接口返回 ${res.status}:${(await res.text()).slice(0, 200)}`); + const body = await res.json(); + const vectors = openai ? (body.data || []).map((d) => d.embedding) : body.embeddings; + if (!Array.isArray(vectors) || vectors.length !== texts.length) throw new Error('向量接口返回的数量不对'); + return vectors.map(normalize); +} + +const toBlob = (vec) => Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength); +// Buffer 的偏移不一定是 4 的倍数,先拷贝再按 Float32 解读 +const fromBlob = (buf) => new Float32Array(Uint8Array.from(buf).buffer); + +// 库里已有的向量:Map(skillId → Float32Array),只要文本没变过的(docs 来自相关度索引,带 embedText) +function vectorsFor(docs) { + if (!enabled() || !docs.length) return new Map(); + const wanted = new Map(docs.map((d) => [d.skill.id, textHash(d.embedText)])); + const rows = db.prepare(`SELECT skill_id, text_hash, vector FROM skill_embeddings WHERE skill_id IN (${docs.map(() => '?').join(',')})`) + .all(...docs.map((d) => d.skill.id)); + const vectors = new Map(); + for (const row of rows) if (wanted.get(row.skill_id) === row.text_hash) vectors.set(row.skill_id, fromBlob(row.vector)); + return vectors; +} + +// 补齐缺的向量。同一个库同时只跑一次;返回是否全部就绪。 +// 正在跑的那一轮可能是按更早的技能列表开始的(例如刚推送的新技能不在里面):等它结束后再按当前列表查一遍 +const inflight = new Map(); +function ensureVectors(userId, docs) { + if (!enabled()) return Promise.resolve(false); + if (inflight.has(userId)) return inflight.get(userId).then(() => ensureVectors(userId, docs)); + const run = (async () => { + // 先让出一次:否则没有缺的向量时整个函数同步跑完,finally 会在下面 inflight.set 之前执行, + // 留下一个永远不清除的已完成标记,后来的调用会无限地「等它结束再查一遍」 + await null; + const have = vectorsFor(docs); + const missing = docs.filter((d) => !have.has(d.skill.id)); + const upsert = db.prepare(` + INSERT INTO skill_embeddings (skill_id, text_hash, vector, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(skill_id) DO UPDATE SET text_hash = excluded.text_hash, vector = excluded.vector, updated_at = CURRENT_TIMESTAMP + `); + try { + for (let i = 0; i < missing.length; i += BATCH) { + const batch = missing.slice(i, i + BATCH); + const vectors = await embed(batch.map((d) => d.embedText)); + db.transaction(() => batch.forEach((d, j) => { + // 算向量期间技能可能被删了:外键约束会拒绝写入,跳过即可 + try { upsert.run(d.skill.id, textHash(d.embedText), toBlob(vectors[j])); } catch { /* 已删除 */ } + }))(); + } + lastError = null; + return true; + } catch (err) { + noteError(err); + return false; + } finally { + inflight.delete(userId); + } + })(); + inflight.set(userId, run); + return run; +} + +// 等向量补齐,但最多等 ms 毫秒(推送时要让新技能也参与查重,又不能让推送卡在模型上) +async function ensureVectorsWithin(userId, docs, ms = 5000) { + if (!enabled()) return false; + let timer; + const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); }); + const done = await Promise.race([ensureVectors(userId, docs), timeout]); + clearTimeout(timer); + return done; +} + +// 查询向量:同一句任务描述短时间内常被反复查,留一个小缓存 +const queryCache = new Map(); +async function embedQuery(text) { + if (!enabled()) return null; + const key = textHash(text); + if (queryCache.has(key)) return queryCache.get(key); + try { + const [vec] = await embed([text]); + queryCache.set(key, vec); + if (queryCache.size > 200) queryCache.delete(queryCache.keys().next().value); + return vec; + } catch (err) { + noteError(err); + return null; + } +} + +const status = () => ({ enabled: enabled(), model: enabled() ? config().model : null, last_error: lastError }); + +module.exports = { enabled, embed, vectorsFor, ensureVectors, ensureVectorsWithin, embedQuery, cosine, status }; diff --git a/server/index.js b/server/index.js index f6f3075..c53c00c 100644 --- a/server/index.js +++ b/server/index.js @@ -8,7 +8,7 @@ const skillsRoutes = require('./routes/skills'); const foldersRoutes = require('./routes/folders'); const agentRoutes = require('./routes/agent'); const { render } = require('./templates'); -const { shQuote, getBaseUrl } = require('./shell'); +const { shQuote, getBaseUrl, AGENT_ROOTS_FN } = require('./shell'); const app = express(); const PORT = process.env.PORT || 9444; @@ -32,7 +32,7 @@ app.use('/api/agent', requireAuth, agentRoutes.apiRouter); // CLI 安装/本体(/setup.sh 与 /cli.sh 是同一个脚本:管道执行时安装,安装后作为 ash 本体) app.get(['/setup.sh', '/cli.sh'], (req, res) => { - res.type('text/plain').send(render('cli.sh', { SERVER_URL: shQuote(getBaseUrl(req)) })); + res.type('text/plain').send(render('cli.sh', { SERVER_URL: shQuote(getBaseUrl(req)), AGENT_ROOTS_FN })); }); // Agent 使用指南:引导语只指向这里,改指南不必给每台机器重发提示词 @@ -77,4 +77,14 @@ if (fs.existsSync(clientDist)) { app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 AnotherSkillHub Server listening on http://0.0.0.0:${PORT}`); + // 可选语义层:启动后在后台为各个库补齐向量(逐个库进行,不影响请求) + const embeddings = require('./embeddings'); + if (embeddings.enabled()) { + console.log(`语义层已开启:${embeddings.status().model}`); + setTimeout(async () => { + const { ensureSemanticWithin } = require('./skillIndex'); + const users = require('./db').prepare('SELECT DISTINCT user_id FROM skills WHERE user_id IS NOT NULL AND is_deleted = 0').all(); + for (const { user_id: userId } of users) await ensureSemanticWithin(userId, 10 * 60 * 1000); + }, 1000); + } }); diff --git a/server/review.js b/server/review.js index 4dbf1b0..42e0039 100644 --- a/server/review.js +++ b/server/review.js @@ -13,8 +13,8 @@ function parseJson(value, fallback) { function snapshotSkillVersion(skill, source) { try { - db.prepare('INSERT INTO skill_versions (skill_id, content, files, name, description, source) VALUES (?, ?, ?, ?, ?, ?)') - .run(skill.id, skill.content, skill.files, skill.name, skill.description, source || 'web'); + db.prepare('INSERT INTO skill_versions (skill_id, content, files, name, description, source, version) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(skill.id, skill.content, skill.files, skill.name, skill.description, source || 'web', skill.version || null); } catch (e) { console.error('snapshot failed:', e.message); } @@ -30,6 +30,16 @@ function recordReview(skillId, action, kind, source) { .run(JSON.stringify({ action, kind, source: source || null, at: new Date().toISOString() }), skillId); } +// 固定版本(slug@1.2.0)对应的内容:当前版本就是它时取当前,否则取历史里该版本号最近的一份快照。 +// 同一版本号改过多次(作者没有升版本号)时取最后一次——版本号是作者给的承诺,不升就视为同一版。 +// 返回 { content, files(JSON 字符串), version, source: current | history } 或 null +function resolvePinned(skill, pin) { + if (!pin || String(skill.version) === String(pin)) return { content: skill.content, files: skill.files || '[]', version: skill.version, source: 'current' }; + const row = db.prepare('SELECT content, files, version FROM skill_versions WHERE skill_id = ? AND version = ? ORDER BY created_at DESC, id DESC LIMIT 1') + .get(skill.id, String(pin)); + return row ? { content: row.content, files: row.files || '[]', version: row.version, source: 'history' } : null; +} + // Agent 拉取可见:未删除且已发布;显式 pending=1 时允许待审核的新技能(推送者自用) function isVisibleToAgents(skill, { allowPending = false } = {}) { if (!skill || skill.is_deleted) return false; @@ -78,4 +88,4 @@ function warningsFor(content, files) { return scanSkill(content, files); } -module.exports = { reviewRequired, parseJson, snapshotSkillVersion, isVisibleToAgents, approveSkill, rejectSkill, warningsFor, skillRevision }; +module.exports = { reviewRequired, parseJson, snapshotSkillVersion, isVisibleToAgents, approveSkill, rejectSkill, warningsFor, skillRevision, resolvePinned }; diff --git a/server/routes/agent.js b/server/routes/agent.js index 48f0a1a..9df1b45 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -1,6 +1,7 @@ // Agent 接口分两组挂载: // router → /s 只读:Markdown、信息、附属文件、历史版本、安装脚本、归档 -// api → /api/agent 推送与跟进:push、revisions(已装技能比对)、mine(我的推送)、withdraw(撤回待审) +// api → /api/agent 推送与跟进:push、revisions(已装技能比对)、mine(我的推送)、withdraw(撤回待审)、 +// suggest(按任务描述找技能)、feedback(用完回报结果)、lint(技能检查:双语、引用、元技能) const express = require('express'); const router = express.Router(); const api = express.Router(); @@ -9,19 +10,45 @@ const path = require('path'); const { execFileSync } = require('child_process'); const multer = require('multer'); const db = require('../db'); -const { replaceSkillOnDisk, removeSkillFromDisk, skillDirExists, cleanFolderPath, createSkillArchive, createSkillTarGzArchive, getSkillFileTree, getSkillFileContent } = require('../storage'); -const { normalizeSkillMeta, dependenciesOf } = require('../skillMeta'); +const { replaceSkillOnDisk, removeSkillFromDisk, skillDirExists, cleanFolderPath, createSkillArchive, createSkillTarGzArchive, createTarGzFromContent, getSkillFileTree, getSkillFileContent } = require('../storage'); +const { normalizeSkillMeta, referencesOf, onlyLocalizationChanged, stripInstallFooter, INSTALL_FOOTER_MARK } = require('../skillMeta'); +const { libraryAnalysis, lintFor, isLocal } = require('../skillRefs'); const { findBundle, bundleMembers } = require('../bundleLookup'); -const { scanSkill, hasHighRisk } = require('../security'); -const { reviewRequired, parseJson, snapshotSkillVersion, isVisibleToAgents, skillRevision } = require('../review'); -const { shQuote, getBaseUrl, curlPipeCommand, SCRIPT_TOKEN_PRELUDE } = require('../shell'); +const { scanSkill, hasHighRisk, looksLikeSecret } = require('../security'); +const { rankSkillsSemantic, similarSkills, ensureSemanticWithin } = require('../skillIndex'); +const { recordEvent, usageSummary, usageLine, normalizeOutcome } = require('../usage'); +const { reviewRequired, parseJson, snapshotSkillVersion, isVisibleToAgents, skillRevision, resolvePinned } = require('../review'); +const { shQuote, getBaseUrl, curlPipeCommand, SCRIPT_TOKEN_PRELUDE, AGENT_ROOTS_FN } = require('../shell'); const { render } = require('../templates'); const upload = multer({ limits: { fileSize: 20 * 1024 * 1024 } }); -const INSTALL_AGENTS = ['auto', 'hermes', 'codex', 'claude', 'dsh']; +const INSTALL_AGENTS = ['auto', 'hermes', 'codex', 'claude', 'dsh', 'all']; const truthy = (value) => ['1', 'true', 'yes'].includes(String(value || '').toLowerCase()); const wantsText = (req) => req.query.format === 'text' || req.body?.format === 'text'; +const terminalOf = (req) => String(req.query.terminal || req.body?.terminal || '').trim() || null; +const EVENT_REASONS = ['update', 'dependency']; +// 固定版本:?pin=1.2.0(与 ?version=<快照 id> 区分开,后者是 ash show --version 用的历史快照编号) +const pinOf = (req) => (/^[A-Za-z0-9_.+-]{1,40}$/.test(String(req.query.pin || '')) ? String(req.query.pin) : null); + +// 按 ?pin= 取对应版本的内容;找不到时给出可操作的原因 +function pinnedLookup(req, skill) { + const pin = pinOf(req); + const resolved = resolvePinned(skill, pin); + if (!resolved) return { error: [404, `技能 ${skill.slug} 没有 ${pin} 版本(ash versions ${skill.slug} 查看历史;当前是 ${skill.version})`] }; + return { resolved, pin }; +} +const feedbackHint = (slug) => `按本技能完成任务后,请运行 \`ash feedback ${slug} ok\`;没走通则运行 \`ash feedback ${slug} fail "一句话原因"\`。`; +const LINT_ICON = { warn: '⚠️ ', info: 'ℹ️ ' }; + +// 本库里可以安装的引用:没写账号或写的是自己的账号;@别人/slug 要等团队库 +function localReferences(req, skill) { + return referencesOf(skill.content).refs.filter((r) => isLocal(r, req.user.username) && r.slug !== skill.slug && /^[A-Za-z0-9_.-]+$/.test(r.slug)); +} + +// 引用的技能在本机哪里:ash pull 把依赖装在同一个根目录下,所以 ..//SKILL.md 一定对得上。 +// 写成 Markdown 链接:任何能读文件的 Agent 都能顺着找到;Claude Code 还能按名字直接调用 +const referenceLinks = (slugs) => slugs.map((slug) => `[${slug}](../${slug}/SKILL.md)`).join('、'); // 只在请求者(token / 会话所属用户)自己的库里找 function findSkill(userId, slug) { @@ -83,17 +110,26 @@ router.get('/:slug/info', (req, res) => { const tags = parseJson(skill.tags, []); const files = getSkillFileTree(skill.user_id, skill.folder_path, skill.slug).map((f) => f.path).sort(); const warnings = parseJson(skill.security_warnings, []); - const deps = dependenciesOf(skill.content); + const deps = localReferences(req, skill).map((r) => r.slug); const missing = deps.filter((d) => !isVisibleToAgents(findSkill(skill.user_id, d))); + const usage = usageSummary(skill); + const dependents = libraryAnalysis(req.user.id).dependents.get(skill.slug) || []; + const lint = lintFor(req.user.id, skill, { username: req.user.username }); const out = [ `${skill.name} (${skill.slug})`, skill.description ? `描述: ${skill.description}` : '描述: (无)', `状态: ${skill.status === 'pending' ? '待审核' : '已发布'}${skill.pending_content ? '(有待审核的更新)' : ''} · 版本 ${skill.version} · 更新于 ${skill.updated_at}`, `目录: ${skill.folder_path}${tags.length ? ` · 标签: ${tags.join(', ')}` : ''} · 修订 ${skillRevision(skill)}`, ...(deps.length ? [`依赖: ${deps.join(', ')}${missing.length ? `(库中缺失或未发布: ${missing.join(', ')})` : ''} —— ash pull 会一并安装`] : []), + ...(dependents.length ? [`被引用: ${dependents.length} 个技能(${dependents.join(', ')})—— 修改它会影响这些技能`] : []), + `使用: ${usageLine(usage)}`, + ...usage.health.map((h) => `⚠️ ${h.label}:${h.detail}`), + ...usage.recent_feedback.filter((f) => f.outcome === 'fail' && f.current && f.note).slice(0, 3) + .map((f) => ` 失败反馈(${f.terminal || '未知来源'},${f.created_at.slice(0, 10)}):${f.note}`), `文件 (${files.length}):`, ...files.map((f) => ` ${f}`), ...warnings.map((w) => `⚠️ ${w.msg}`), + ...lint.map((i) => `${LINT_ICON[i.level]} ${i.msg}`), `阅读: ash show ${skill.slug} (${base}/s/${skill.slug}.md)`, `安装: ash pull ${skill.slug} (${curlPipeCommand(`${base}/s/${skill.slug}/install.sh`)})`, `历史: ash versions ${skill.slug}`, @@ -150,41 +186,100 @@ router.get('/:slug', (req, res) => { if (!ver) return res.status(404).send(`# 错误\n\n版本 ${req.query.version} 不存在\n`); return res.send(ver.content); } + if (pinOf(req)) { + const { resolved, error: pinError } = pinnedLookup(req, skill); + if (pinError) return res.status(pinError[0]).send(`# 错误\n\n${pinError[1]}\n`); + return res.send(resolved.content); + } if (truthy(req.query.raw)) return res.send(skill.content); + recordEvent(skill, req.user.id, 'view', { terminal: terminalOf(req) }); - // 多文件技能:直接 fetch 只拿得到 SKILL.md,附上文件清单让 Agent 能取到脚本/参考资料 + // 附加段:多文件技能的文件清单(直接 fetch 只拿得到 SKILL.md)+ 用完回报结果的提示 const files = getSkillFileTree(skill.user_id, skill.folder_path, skill.slug).filter((f) => f.path !== 'SKILL.md').map((f) => f.path).sort(); - if (!files.length) return res.send(skill.content); + const refs = localReferences(req, skill).map((r) => r.slug); const base = getBaseUrl(req); - const manifest = [ + const appendix = [ '', '', '---', '', '', - '## 附属文件', - '', - `本技能包含 ${files.length} 个附属文件,正文中的相对路径指向它们。需要执行脚本时请完整安装:\`ash pull ${skill.slug}\`(或 \`${curlPipeCommand(`${base}/s/${skill.slug}/install.sh`)}\`)。`, - '', - ...files.map((f) => `- \`${f}\`:${base}/s/${skill.slug}/files/${f.split('/').map(encodeURIComponent).join('/')}`), + ...(files.length ? [ + '## 附属文件', + '', + `本技能包含 ${files.length} 个附属文件,正文中的相对路径指向它们。需要执行脚本时请完整安装:\`ash pull ${skill.slug}\`(或 \`${curlPipeCommand(`${base}/s/${skill.slug}/install.sh`)}\`)。`, + '', + ...files.map((f) => `- \`${f}\`:${base}/s/${skill.slug}/files/${f.split('/').map(encodeURIComponent).join('/')}`), + '', + ] : []), + ...(refs.length ? [`> 本技能引用:${refs.map((slug) => `\`${slug}\`(ash show ${slug})`).join('、')}。ash pull ${skill.slug} 会把它们一并装在本技能旁边。`, ''] : []), + `> ${feedbackHint(skill.slug)}`, '', ]; - return res.send(skill.content.replace(/\s*$/, '') + manifest.join('\n')); + return res.send(skill.content.replace(/\s*$/, '') + appendix.join('\n')); } catch (err) { res.status(500).type('text/markdown').send(`# 服务器错误\n\n${err.message}\n`); } }); // 一键安装脚本:/s/:slug/install.sh -// 查询参数: agent=auto|hermes|codex|claude|dsh;dir=/path 自定义目录;pending=1 安装待审核技能 +// agent=all:装进本机每个 Agent 各自加载技能的目录。一台机器上常同时跑着 Claude Code、Codex、Hermes, +// 只装进其中一个目录,其他 Agent 既看不到这个技能、也按名字调用不到它引用的技能。 +// 生成的脚本逐个目录调用单目录安装脚本(依赖同样装在每个目录里,相对链接各自对得上) +function multiAgentScript(req, skill) { + const sub = new URLSearchParams(); + for (const key of ['pending', 'force', 'nodeps', 'pin', 'reason']) { + if (typeof req.query[key] === 'string' && req.query[key]) sub.set(key, req.query[key]); + } + const query = sub.toString() ? `?${sub}` : ''; + return [ + '#!/usr/bin/env bash', + '# AnotherSkillHub 多 Agent 安装脚本(由服务端生成):装进本机每个 Agent 各自加载技能的目录', + 'set -euo pipefail', + `BASE_URL=${shQuote(getBaseUrl(req))}`, + SCRIPT_TOKEN_PRELUDE, + AGENT_ROOTS_FN, + 'roots="$(agent_roots)"', + `if [ -z "$roots" ]; then echo ${shQuote('没有找到任何 Agent 的目录(~/.claude、~/.agents、~/.codex、~/.hermes、~/.dsh);请用 --agent 或 --dir 指定')} >&2; exit 1; fi`, + 'status=0', + 'while IFS= read -r root; do', + ' echo "=== $root"', + ` curl -fsSL -H "Authorization: Bearer $ASH_TOKEN" "$BASE_URL"${shQuote(`/s/${skill.slug}/install.sh${query}`)} | ASH_SKILLS_DIR="$root" bash || status=1`, + 'done < { try { - const { skill, error } = agentLookup(req); + const { skill: current, error } = agentLookup(req); if (error) return scriptError(res, error[0], error[1]); + if (req.query.agent === 'all' && !req.query.dir) return res.type('text/plain').send(multiAgentScript(req, current)); + const { resolved, pin, error: pinError } = pinnedLookup(req, current); + if (pinError) return scriptError(res, pinError[0], pinError[1]); + // 固定版本时,内容、修订号、依赖都按那一版来 + const skill = { ...current, content: resolved.content, files: resolved.files, version: resolved.version }; const agent = INSTALL_AGENTS.includes(req.query.agent) ? req.query.agent : 'auto'; const dir = typeof req.query.dir === 'string' ? req.query.dir : ''; - // 依赖只收合法 slug;不存在/未发布的依赖由脚本安装时提示 - const deps = dependenciesOf(skill.content).filter((d) => /^[A-Za-z0-9_.-]+$/.test(d) && d !== skill.slug); + // 依赖只收本库的合法 slug;不存在/未发布的依赖由脚本安装时提示 + const depRefs = localReferences(req, skill); + const deps = [...new Set(depRefs.map((r) => r.slug))]; + // 传给脚本的依赖带上固定版本:slug 或 slug@1.2.0 + const depSpecs = [...new Set(depRefs.map((r) => (r.pin ? `${r.slug}@${r.pin}` : r.slug)))]; + // 附在 SKILL.md 末尾的一段:引用的技能在本机哪里(本地 Agent 顺着链接就能找到)+ 用完怎么反馈 + const footer = [ + '> 本技能由 AnotherSkillHub 安装。', + // 用到被引用的技能时怎么找到它:Claude Code 按名字调用;其他 Agent 读相对链接;本机没有就现装(装在本技能旁边) + ...(deps.length ? [ + `> 引用的技能:${referenceLinks(deps)}${truthy(req.query.nodeps) ? '(本次安装用了 --no-deps,没有一并装上)' : '(已一并装在本技能旁边)'}。`, + '> 用到时:Claude Code 直接调用同名技能;其他 Agent 读链接里的 SKILL.md;本机没有就运行 `ash pull `,它会装在本技能旁边。', + ] : []), + `> ${feedbackHint(skill.slug)}`, + ].join('\n'); const script = render('install.sh', { SLUG: shQuote(skill.slug), BASE_URL: shQuote(getBaseUrl(req)), @@ -195,7 +290,11 @@ router.get('/:slug/install.sh', (req, res) => { NODEPS: truthy(req.query.nodeps) ? '1' : '0', REVISION: shQuote(skillRevision(skill)), VERSION: shQuote(skill.version || ''), - DEPS: deps.map(shQuote).join(' '), + REASON: shQuote(EVENT_REASONS.includes(req.query.reason) ? req.query.reason : 'install'), + PIN: shQuote(pin || ''), + FOOTER_MARK: shQuote(INSTALL_FOOTER_MARK), + FOOTER: shQuote(footer), + DEPS: depSpecs.map(shQuote).join(' '), }); res.type('text/plain').send(script); } catch (err) { @@ -208,8 +307,15 @@ function sendArchive(req, res, kind) { if (error) return res.status(error[0]).type('text/plain').send(`错误: ${error[1]}\n`); if (!skillDirExists(skill.user_id, skill.folder_path, skill.slug)) return res.status(404).type('text/plain').send('错误: 技能文件缺失\n'); if (kind === 'tar') { + const { resolved, error: pinError } = pinnedLookup(req, skill); + if (pinError) return res.status(pinError[0]).type('text/plain').send(`错误: ${pinError[1]}\n`); + // 安装脚本下载完整包时带上 reason / terminal:这是「装到了某台机器上」的可靠信号 + recordEvent(skill, req.user.id, EVENT_REASONS.includes(req.query.reason) ? req.query.reason : 'install', { + terminal: terminalOf(req), revision: skillRevision({ content: resolved.content, files: resolved.files }), + }); res.attachment(`${skill.slug}.tar.gz`); - createSkillTarGzArchive(skill.user_id, skill.folder_path, skill.slug, res); + if (resolved.source === 'history') createTarGzFromContent(skill.slug, resolved.content, parseJson(resolved.files, []), res); + else createSkillTarGzArchive(skill.user_id, skill.folder_path, skill.slug, res); } else { res.attachment(`${skill.slug}.zip`); createSkillArchive(skill.user_id, skill.folder_path, skill.slug, res); @@ -355,12 +461,13 @@ function pushSummaryText(r) { if (r.status === 'pending') lines.push(` 自用: ash pull ${r.slug} --pending`); (r.security_warnings || []).forEach((w) => lines.push(`⚠️ 安全提醒: ${w.msg}${w.level === 'high' ? '(高危,已强制人工审核)' : ''}`)); (r.notices || []).forEach((n) => lines.push(`ℹ️ ${n}`)); + (r.lint || []).forEach((i) => lines.push(`${LINT_ICON[i.level]} ${i.msg}`)); return `${lines.join('\n')}\n`; } // Agent 一键 Push 上传端点(支持 JSON、单文件 FormData、归档 tar.gz 上传) // 同名 slug 已存在时必须显式 update=1;推送结果默认需要人工审核后才对其他 Agent 可见 -api.post('/push', upload.single('file'), (req, res) => { +api.post('/push', upload.single('file'), async (req, res) => { const asText = wantsText(req); const fail = (status, message, extra = {}) => (asText ? res.status(status).type('text/plain').send(`错误: ${message}\n`) @@ -387,6 +494,7 @@ api.post('/push', upload.single('file'), (req, res) => { content = String(body.content); } if (!content.trim()) return fail(400, '缺少技能内容:请上传 SKILL.md、技能目录归档,或提供 content 字段'); + content = stripInstallFooter(content); const meta = normalizeSkillMeta(content, { slug: body.slug, name: body.name, description: body.description, tags: body.tags }, { fileName }); if (meta.errors.length) return fail(400, meta.errors.join(';')); @@ -410,9 +518,13 @@ api.post('/push', upload.single('file'), (req, res) => { const baseline = !isPending && existing.pending_content != null ? { content: existing.pending_content, files: parseJson(existing.pending_files, []) } : { content: existing.content, files: currentFiles }; + // 已发布、没有挂着待审更新、这次只改了双语字段:直接发布(见 onlyLocalizationChanged 的说明) + const localizationOnly = !isPending && existing.pending_content == null && sameFiles(currentFiles, archiveFiles) + && onlyLocalizationChanged(existing.content, content) && !hasHighRisk(securityWarnings); if (baseline.content === content && sameFiles(baseline.files, archiveFiles)) { result = { ...common, action: 'unchanged', status: existing.status }; - } else if (isPending || !needsReview) { + } else if (isPending || !needsReview || localizationOnly) { + if (needsReview && localizationOnly) notices.push('只改了双语描述(Agent 框架不读这些字段,不影响 Agent 行为),已直接发布,无需审核'); // 从未发布过的待审技能直接原地更新;关闭审核时直接发布。名称/标签/目录保留人工维护的值 snapshotSkillVersion(existing, 'agent'); db.prepare(` @@ -437,24 +549,40 @@ api.post('/push', upload.single('file'), (req, res) => { const folderPath = ensureFolderPath(uid, body.folder || body.folder_path || 'inbox'); if (!folderPath) return fail(400, `非法的目录: ${body.folder || body.folder_path}`); const status = needsReview ? 'pending' : 'approved'; - db.prepare(` + const inserted = db.prepare(` INSERT INTO skills (user_id, slug, name, description, folder_path, tags, content, files, terminal_source, version, status, security_warnings) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(uid, slug, meta.name, meta.description, folderPath, JSON.stringify(meta.tags), content, JSON.stringify(archiveFiles), terminalSource, meta.version, status, JSON.stringify(securityWarnings)); replaceSkillOnDisk(uid, folderPath, slug, content, archiveFiles); result = { slug, name: meta.name, folder_path: folderPath, action: 'created', status }; + // 查重:新技能和库里已有的哪些相近。推送的 Agent 当场就能改为更新或引用,而不是再造一个。 + // 配置了语义层时先等新技能的向量(最多 5 秒),跨语言的重复也能当场查出来 + await ensureSemanticWithin(uid, 5000); + const similar = similarSkills(uid, { id: Number(inserted.lastInsertRowid), slug }); + if (similar.length) { + result.similar = similar.map((m) => ({ slug: m.skill.slug, name: m.skill.name, similarity: Number(m.similarity.toFixed(2)), duplicate: m.high })); + notices.push(`库中已有相近技能:${similar.map((m) => `${m.skill.slug}(${m.high ? '疑似重复,' : ''}${m.semantic ? '语义' : ''}相似度 ${Math.round(m.similarity * 100)}%)`).join('、')}。` + + '如果新技能只是它们的补充或变体,请改为更新已有技能(ash pull 修改后 ash push <目录> --update),' + + '或在 depends_on 里引用它、不再重复它的步骤;确实是不同的技能,请在 description 里写清楚区别'); + } } + // 技能检查(引用、元技能契约、重复的步骤);双语描述缺失已经在上面的元数据提示里 + const pushed = findSkill(uid, slug); + const lint = lintFor(uid, pushed, { username: req.user.username, content: result.action === 'update-pending' ? content : undefined }) + .filter((i) => i.code !== 'bilingual'); + const payload = { success: true, ...result, + lint, review_required: result.status === 'pending' || result.action === 'update-pending', files: archiveFiles.length, security_warnings: securityWarnings, notices, // 兼容旧版 CLI:合并成一行提示 - warning: [...securityWarnings.map((w) => `⚠️ 安全提醒: ${w.msg}`), ...notices].join(' | ') || undefined, + warning: [...securityWarnings.map((w) => `⚠️ 安全提醒: ${w.msg}`), ...notices, ...lint.map((i) => i.msg)].join(' | ') || undefined, url: `${base}/s/${slug}`, install_cmd: curlPipeCommand(`${base}/s/${slug}/install.sh`), }; @@ -533,4 +661,85 @@ api.post('/withdraw', (req, res) => { return fail(400, `${slug} 没有待审核的内容,已发布的技能不能撤回`); }); +// 技能检查:ash lint [slug]。不带 slug 时列出全库有问题的技能——补双语描述、修引用时从这里开始 +api.get('/lint', (req, res) => { + const slug = String(req.query.slug || '').trim(); + const rows = slug + ? [findSkill(req.user.id, slug)].filter((s) => s && !s.is_deleted) + : db.prepare('SELECT * FROM skills WHERE user_id = ? AND is_deleted = 0 ORDER BY slug').all(req.user.id); + if (slug && !rows.length) return res.status(404).type('text/plain').send(`错误: 技能 ${slug} 不存在或已删除\n`); + // ?code=bilingual 只看某一类问题(例如批量补双语描述时) + const only = String(req.query.code || '').split(',').map((c) => c.trim()).filter(Boolean); + const results = rows.map((skill) => ({ + slug: skill.slug, status: skill.status, + issues: lintFor(req.user.id, skill, { username: req.user.username }).filter((i) => !only.length || only.includes(i.code)), + })).filter((r) => slug || r.issues.length); + if (!wantsText(req)) return res.json({ results }); + if (!results.length || results.every((r) => !r.issues.length)) { + return res.type('text/plain').send(slug ? `✅ ${slug} 没有发现问题\n` : '✅ 全库没有发现问题\n'); + } + const lines = [`${results.length} 个技能有待处理的问题(修改本地技能目录后 ash push <目录> --update):`]; + for (const r of results) { + lines.push('', `${r.slug}${r.status === 'pending' ? '(待审核)' : ''}`); + r.issues.forEach((i) => lines.push(` ${LINT_ICON[i.level]} ${i.msg}`)); + } + res.type('text/plain').send(`${lines.join('\n')}\n`); +}); + +// 用完回报结果:ash feedback ok|fail [说明] +// revision 是本机已装版本的修订号(ash 从 .ash 读取),没有时按当前版本算——例如只用 ash show 读过 +api.post('/feedback', (req, res) => { + const fail = (status, message) => res.status(status).type('text/plain').send(`错误: ${message}\n`); + const body = req.body || {}; + const slug = String(body.slug || '').trim(); + const outcome = normalizeOutcome(body.outcome); + if (!outcome) return fail(400, '结果只能是 ok 或 fail'); + const skill = findSkill(req.user.id, slug); + if (!skill || skill.is_deleted) return fail(404, `技能 ${slug} 不存在或已删除`); + const note = String(body.note || '').replace(/\s+/g, ' ').trim().slice(0, 500) || null; + // 失败说明里提到 curl | bash 之类的命令很正常,只拦疑似密钥 + if (note && looksLikeSecret(note)) return fail(400, '说明里疑似包含密钥或 token,未记录。请只描述发生了什么'); + const current = skillRevision(skill); + const revision = /^[0-9a-f]{12}$/.test(String(body.revision || '')) ? String(body.revision) : current; + recordEvent(skill, req.user.id, 'feedback', { outcome, note, terminal: terminalOf(req), revision }); + const lines = [`✅ 已记录 ${slug} 的反馈:${outcome === 'ok' ? '成功' : '失败'}${revision === current ? '' : '(针对本机的旧版本,库里已有更新:ash pull ' + slug + ')'}`]; + if (outcome === 'fail' && !note) lines.push(`ℹ️ 附一句原因更有用:ash feedback ${slug} fail "哪一步、为什么没走通"`); + if (outcome === 'fail') lines.push('ℹ️ 如果你已经找到了正确做法,把修正推送回库里:修改本地技能目录后 ash push <目录> --update'); + res.type('text/plain').send(`${lines.join('\n')}\n`); +}); + +// 按任务描述找技能:ash suggest "<一两句话描述任务>" +api.get('/suggest', async (req, res) => { + const query = String(req.query.q || '').trim(); + const limit = Math.min(Math.max(Number(req.query.limit) || 5, 1), 20); + if (!query) return res.status(400).type('text/plain').send('错误: 请描述要做的任务,例如 ash suggest "把感知回放结果叠到原图上验证"\n'); + const results = (await rankSkillsSemantic(req.user.id, query, { limit })).map((r) => { + const skill = findSkill(req.user.id, r.skill.slug); + const usage = usageSummary(skill); + return { ...r, usage }; + }); + if (!wantsText(req)) { + return res.json({ + query, + results: results.map(({ skill, relevance, strong, matched, semantic, usage }) => ({ + slug: skill.slug, name: skill.name, description: skill.description, folder_path: skill.folder_path, + relevance: Number(relevance.toFixed(3)), strong, matched, semantic, health: usage.health, + feedback: { ok: usage.ok_current, fail: usage.fail_current }, + })), + }); + } + if (!results.length) { + return res.type('text/plain').send(`没有找到与「${query}」明显相关的技能。按常规方式完成任务即可;如果换个说法(中英文、关键名词)可以再试一次。\n`); + } + const clip = (value, n) => (value.length > n ? `${value.slice(0, n - 1)}…` : value); + const lines = [`与任务相关的技能(先 ash info 判断是否适用;都不合适就按常规方式完成):`]; + results.forEach(({ skill, strong, matched, semantic, usage }, i) => { + lines.push(`${i + 1}. ${skill.slug} · ${skill.name}${skill.description ? ` · ${clip(skill.description, 100)}` : ''}`); + const why = semantic ? ' · 语义相近' : matched.length ? ` · 命中: ${matched.slice(0, 5).join(', ')}` : ''; + lines.push(` ${strong ? '相关' : '可能相关'}${why} · ${usageLine(usage)}`); + usage.health.filter((h) => h.code === 'failing' || h.code === 'duplicate').forEach((h) => lines.push(` ⚠️ ${h.label}:${h.detail}`)); + }); + res.type('text/plain').send(`${lines.join('\n')}\n`); +}); + module.exports = { publicRouter: router, apiRouter: api, browserRedirect }; diff --git a/server/routes/skills.js b/server/routes/skills.js index 6ef1837..e9aa981 100644 --- a/server/routes/skills.js +++ b/server/routes/skills.js @@ -2,10 +2,16 @@ const express = require('express'); const router = express.Router(); const db = require('../db'); const { saveSkillToDisk, replaceSkillOnDisk, moveSkillOnDisk, parseSkillContent, getSkillFileTree, getSkillFileContent, cleanFolderPath } = require('../storage'); -const { normalizeSkillMeta } = require('../skillMeta'); +const { normalizeSkillMeta, stripInstallFooter } = require('../skillMeta'); const { buildSkillGraph } = require('../skillGraph'); +const { rankSkillsSemantic, similarSkills, ensureSemantic } = require('../skillIndex'); +const { usageSummary, libraryHealth } = require('../usage'); +const { entryFor, lintSkill, lintFor, sharedStepsOf, isLocal, splitBody, pinAvailable } = require('../skillRefs'); const { parseJson, snapshotSkillVersion, approveSkill, rejectSkill, warningsFor } = require('../review'); +// 「需关注」的排序:先修坏的(常失败、引用失效),再合并重复的,再整理结构,最后清理不用的 +const HEALTH_ORDER = ['failing', 'broken_ref', 'duplicate', 'copies_meta', 'extractable', 'meta_contract', 'stale', 'unused']; + // 待审核:Agent 推送的新技能,或已发布技能上挂着 Agent 提交的更新 const PENDING_WHERE = "(status = 'pending' OR pending_content IS NOT NULL)"; @@ -18,6 +24,37 @@ function findOwnSkill(userId, id) { const findOwnSkillById = (userId, id) => db.prepare('SELECT * FROM skills WHERE id = ? AND user_id = ?').get(id, userId); +// 引用关系与技能检查(详情页、审核条):引用了谁、被谁引用、与谁有相同的步骤、检查结果;待审更新另算一份检查 +function relations(user, skill) { + const { analysis, entry } = entryFor(user.id, skill); + const brief = (e) => ({ id: e.id, slug: e.slug, name: e.name, meta: e.meta }); + const declared = entry.declared.map((ref) => { + const target = isLocal(ref, user.username) ? analysis.bySlug.get(ref.slug) : null; + let status = 'ok'; + if (!isLocal(ref, user.username)) status = 'foreign'; + else if (!target) status = 'missing'; + else if (target.status === 'pending') status = 'pending'; + else if (ref.pin && target.version && ref.pin !== target.version) status = pinAvailable(analysis, ref) ? 'pinned-old' : 'pin-missing'; + return { ...ref, status, ...(target ? { id: target.id, name: target.name, version: target.version, meta: target.meta } : {}) }; + }); + const out = { + refs: { + meta: entry.meta, + declared, + undeclared: [...entry.inBody].filter((slug) => !declared.some((d) => d.slug === slug) && analysis.bySlug.has(slug)), + dependents: (analysis.dependents.get(skill.slug) || []).map((slug) => brief(analysis.bySlug.get(slug))), + }, + lint: lintSkill(analysis, entry, { username: user.username }), + shared_steps: sharedStepsOf(analysis, entry).map(({ others, commands, kind }) => ({ skills: others.map(brief), commands, kind })), + }; + if (skill.pending_content != null) { + out.pending_lint = lintFor(user.id, skill, { username: user.username, content: skill.pending_content }); + // 只改了 frontmatter(例如补双语描述、改依赖声明),正文和附属文件都没动:审核时一眼看出来 + out.pending_metadata_only = splitBody(skill.pending_content) === splitBody(skill.content) && (skill.pending_files || '[]') === (skill.files || '[]'); + } + return out; +} + // 获取统计数据 (用于左侧栏 badge) router.get('/stats', (req, res) => { try { @@ -31,6 +68,7 @@ router.get('/stats', (req, res) => { res.json({ pending: pendingCount, + attention: libraryHealth(uid).size, inbox: inboxCount, starred: starredCount, all: allCount, @@ -59,7 +97,7 @@ router.get('/tags', (req, res) => { }); // 获取技能列表 -router.get('/', (req, res) => { +router.get('/', async (req, res) => { try { const { folder, tag, search, star, format } = req.query; const asText = format === 'text'; @@ -80,6 +118,8 @@ router.get('/', (req, res) => { if (folder === 'pending') { query += ` AND ${PENDING_WHERE}`; + } else if (folder === 'attention') { + // 需关注:由使用数据推出健康信号的技能(常失败 / 从未使用 / 长期未用 / 疑似重复),下面按信号过滤 } else if (folder === 'starred' || star === '1') { query += ` AND is_starred = 1`; } else if (folder && folder !== 'all') { @@ -113,17 +153,35 @@ router.get('/', (req, res) => { if (asText) { const clip = (value, n) => (value.length > n ? `${value.slice(0, n - 1)}…` : value); - const lines = skills.map((s) => `${s.slug} · ${s.name}${s.description ? ` · ${clip(s.description, 80)}` : ''} [${s.folder_path}]`); + const line = (s) => `${s.slug} · ${s.name}${s.description ? ` · ${clip(s.description, 80)}` : ''} [${s.folder_path}]`; + // 关键词要求全部命中,常因为一个词没对上而一无所获:退回到按相关度的候选(与 ash suggest 同一套排序), + // 但仍限定在调用方给的目录 / 标签范围内。星标、待审等系统视图不做退回 + const scoped = !['starred', 'pending', 'trash', 'attention'].includes(folder); + if (!skills.length && search && scoped) { + const where = (s) => (!folder || folder === 'all' || s.folder_path === folder) && (!tag || s.tags.includes(tag)); + const ranked = await rankSkillsSemantic(req.user.id, String(search), { limit: 5, where }); + const fallback = ranked.length + ? [`没有同时命中「${search}」全部关键词的技能。按相关度的候选(ash info 判断是否适用):`, ...ranked.map((r) => line(r.skill))] + : [`没有找到匹配「${search}」的技能。可以用一两句话描述任务再试:ash suggest "<任务描述>"`]; + return res.type('text/plain').send(`${fallback.join('\n')}\n`); + } const header = skills.length ? `共 ${skills.length} 个技能(ash info 看详情,ash pull 安装)` : (search ? `没有找到匹配「${search}」的技能,换个关键词试试` : '技能库为空'); - return res.type('text/plain').send(`${[header, ...lines].join('\n')}\n`); + return res.type('text/plain').send(`${[header, ...skills.map(line)].join('\n')}\n`); } + const health = libraryHealth(req.user.id); + if (folder === 'attention') { + // 需关注按严重程度排:先修常失败的,再合并重复的,最后清理不用的 + const rank = (s) => Math.min(...health.get(s.id).map((h) => HEALTH_ORDER.indexOf(h.code))); + skills = skills.filter((s) => health.has(s.id)).sort((a, b) => rank(a) - rank(b)); + } res.json(skills.map(s => ({ ...s, has_pending_update: Boolean(s.has_pending_update), - tags: JSON.parse(s.tags || '[]') + tags: JSON.parse(s.tags || '[]'), + health: health.get(s.id) || [], }))); } catch (err) { res.status(500).json({ error: err.message }); @@ -149,6 +207,16 @@ router.get('/:id', (req, res) => { return res.status(404).json({ error: 'Skill not found' }); } + // 使用情况与相似技能:审核时判断「有没有必要单独成为一个技能」、整理时发现重复,都靠这两项。 + // 要在下面把 files / tags 解析成数组之前算——修订号按库里的原始 JSON 计算 + if (!skill.is_deleted) { + ensureSemantic(req.user.id); + skill.usage = usageSummary(skill); + skill.similar = similarSkills(req.user.id, skill, { limit: 5 }).map((m) => ({ + ...m.skill, similarity: Number(m.similarity.toFixed(2)), duplicate: m.high, semantic: m.semantic, + })); + Object.assign(skill, relations(req.user, skill)); + } skill.tags = JSON.parse(skill.tags || '[]'); skill.files = JSON.parse(skill.files || '[]'); skill.security_warnings = parseJson(skill.security_warnings, []); @@ -207,7 +275,8 @@ router.post('/parse', (req, res) => { router.post('/', (req, res) => { try { let { folder_path, content, files, terminal_source } = req.body; - const meta = normalizeSkillMeta(content || '', { + content = stripInstallFooter(content || ''); + const meta = normalizeSkillMeta(content, { slug: req.body.slug, name: req.body.name, description: req.body.description, diff --git a/server/security.js b/server/security.js index 26fc3c6..098265d 100644 --- a/server/security.js +++ b/server/security.js @@ -1,11 +1,12 @@ // 推送内容的轻量安全扫描:命中只提示不阻塞;high 级命中会强制进入人工审核。 // 注意:正则不能带 g 标志——模块级正则反复 .test() 会残留 lastIndex,导致间歇性漏报。 +const SECRET_PATTERN = /(?:aws_secret_access_key|api[_-]?key|private[_-]?key|secret|token)\s*[:=]\s*['"][A-Za-z0-9+/_-]{16,}/i; const SECURITY_PATTERNS = [ { re: /rm\s+-rf\s+(?:--no-preserve-root\s+)?(?:\/|~|\$HOME)(?:\s|\/?\*?\s*$)/m, msg: '包含 rm -rf 指向根/家目录的危险写法', level: 'high' }, { re: /curl[^|;\n]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/, msg: '包含 curl|sh 远程执行(若非自有服务安装脚本请人工确认)', level: 'medium' }, { re: /wget[^|;\n]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/, msg: '包含 wget|sh 远程执行', level: 'medium' }, { re: /(?:eval|exec)\s*\(.{0,40}(?:base64|\\x)/i, msg: '包含 base64/十六进制混淆的 eval/exec', level: 'high' }, - { re: /(?:aws_secret_access_key|api[_-]?key|private[_-]?key|secret|token)\s*[:=]\s*['"][A-Za-z0-9+/_-]{16,}/i, msg: '疑似硬编码密钥', level: 'medium' }, + { re: SECRET_PATTERN, msg: '疑似硬编码密钥', level: 'medium' }, { re: /\bnc\s+-e\b|reverse[_-]?shell|bash\s+-i\s+>&\s*\/dev\/tcp/i, msg: '疑似反弹 shell', level: 'high' }, ]; @@ -18,5 +19,6 @@ function scanSkill(content, files = []) { } const hasHighRisk = (warnings) => warnings.some((w) => w.level === 'high'); +const looksLikeSecret = (text) => SECRET_PATTERN.test(String(text || '')); -module.exports = { securityScan, scanSkill, hasHighRisk }; +module.exports = { securityScan, scanSkill, hasHighRisk, looksLikeSecret }; diff --git a/server/seed.js b/server/seed.js index d482468..ab07fd4 100644 --- a/server/seed.js +++ b/server/seed.js @@ -27,6 +27,7 @@ function seedUserLibrary(userId) { content: `--- name: writing-skills description: 需要把一个可复用流程沉淀成 SKILL.md 并推送到技能库时使用 +description_en: Use when distilling a reusable procedure into a SKILL.md and pushing it to the skill hub tags: [meta, authoring] version: 1.0.0 --- @@ -37,12 +38,14 @@ version: 1.0.0 完成了一个下次还会用到、或其他 Agent 也会用到的流程之后。 ## 步骤 -1. \`ash search <关键词>\` 查重;已有相近技能就 \`ash pull\` 下来在原版上改。 -2. frontmatter 的 \`name\` 用英文 slug,\`description\` 写「什么时候用」而不是「是什么」。 +1. \`ash suggest "<这个技能要解决的问题>"\` 查重;已有相近技能就 \`ash pull\` 下来在原版上改。 +2. frontmatter 的 \`name\` 用英文 slug,\`description\` 写「什么时候用」而不是「是什么」; + 再用 \`description_en\`(或 \`description_zh\`)补上另一种语言,中英文任务都能找到它。 3. 正文分「何时使用 / 步骤 / 验证」三段,步骤写成可直接执行的命令。 -4. 脚本放 \`scripts/\`,参考资料放 \`references/\`,正文用相对路径引用。 -5. 不写入任何密钥或口令。 -6. \`ash push <技能目录>\`(更新已有技能加 \`--update\`),把返回的审核状态告诉用户。 +4. 某段步骤已经是别的技能(尤其元技能)的内容:不要抄,在 \`depends_on\` 里声明它,正文写「用元技能 \`\`」+ 本技能特有的参数。 +5. 脚本放 \`scripts/\`,参考资料放 \`references/\`,正文用相对路径引用。 +6. 不写入任何密钥或口令。 +7. \`ash lint\` 自查,再 \`ash push <技能目录>\`(更新已有技能加 \`--update\`),把返回的审核状态告诉用户。 ## 验证 - \`ash show --pending\` 能看到完整正文 @@ -59,6 +62,7 @@ version: 1.0.0 content: `--- name: systematic-debugging description: 遇到原因不明的 bug、测试失败或异常行为,准备动手修之前使用 +description_en: Use when facing an unexplained bug, test failure or unexpected behavior, before attempting a fix tags: [debugging, root-cause] version: 1.0.0 depends_on: [writing-skills] @@ -90,6 +94,7 @@ depends_on: [writing-skills] content: `--- name: release-checklist description: 准备发布新版本或部署到生产环境之前使用 +description_en: Use before releasing a new version or deploying to production tags: [release, ops] version: 1.0.0 --- diff --git a/server/shell.js b/server/shell.js index 858992f..50d8df5 100644 --- a/server/shell.js +++ b/server/shell.js @@ -24,4 +24,19 @@ const SCRIPT_TOKEN_PRELUDE = [ 'export ASH_TOKEN', ].join('\n'); -module.exports = { shQuote, getBaseUrl, curlPipeCommand, SCRIPT_TOKEN_PRELUDE }; +// 本机各个 Agent 各自从哪里加载技能(按 Agent 的主目录是否存在判断装没装这个 Agent): +// Claude Code ~/.claude/skills Codex ~/.agents/skills(官方的用户级目录) +// Hermes 唯一的 ~/.hermes/profiles/*/skills,否则 ~/.hermes/skills +// dsh ~/.dsh/skills +// 生成的多 Agent 安装脚本与 ash CLI 共用这一份定义 +const AGENT_ROOTS_FN = `agent_roots() { + if [ -d "$HOME/.claude" ]; then echo "$HOME/.claude/skills"; fi + if [ -d "$HOME/.codex" ] || [ -d "$HOME/.agents" ]; then echo "$HOME/.agents/skills"; fi + if [ -d "$HOME/.hermes" ]; then + set -- "$HOME"/.hermes/profiles/*/skills + if [ "$#" -eq 1 ] && [ -d "$1" ]; then echo "$1"; else echo "$HOME/.hermes/skills"; fi + fi + if [ -d "$HOME/.dsh" ]; then echo "$HOME/.dsh/skills"; fi +}`; + +module.exports = { shQuote, getBaseUrl, curlPipeCommand, SCRIPT_TOKEN_PRELUDE, AGENT_ROOTS_FN }; diff --git a/server/skillGraph.js b/server/skillGraph.js index 0b4c6e8..30ce73c 100644 --- a/server/skillGraph.js +++ b/server/skillGraph.js @@ -1,5 +1,6 @@ const matter = require('gray-matter'); -const { declaredDependencies } = require('./skillMeta'); +const { declaredDependencies, parseReference } = require('./skillMeta'); +const { isMetaSkill } = require('./skillRefs'); const META_WORD = /元技能|meta[- ]skill/i; const slugPatterns = new Map(); @@ -11,22 +12,13 @@ function slugPattern(slug) { return slugPatterns.get(slug); } -function list(value) { - return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : typeof value === 'string' ? [value] : []; -} - // Relationships come only from explicit signals: declared metadata, local SKILL.md links, and lines that // name a meta skill by its exact slug on the same line as the word 元技能 / meta-skill. Bare prose mentions never count. function buildSkillGraph(skills) { const parsed = skills.filter((skill) => !skill.is_deleted).map((skill) => { let data = {}, body = skill.content || '', warning = false; - try { const result = matter(body); data = result.data; body = result.content; } catch { warning = true; } - let tags = skill.tags; - if (typeof tags === 'string') { try { tags = JSON.parse(tags); } catch { tags = []; } } - const meta = data.type === 'meta' || data.kind === 'meta' || data.metadata?.type === 'meta' - || data.is_meta === true || list(tags).some((tag) => ['meta', 'meta-skill', '元技能'].includes(tag)) - || /^【元技能[·】]/.test(skill.description || '') || /(?:^|\/)元技能(?:\/|$)/.test(skill.folder_path || ''); - return { skill, data, body, warning, meta }; + try { const result = matter(body, {}); data = result.data; body = result.content; } catch { warning = true; } + return { skill, data, body, warning, meta: isMetaSkill(skill, data) }; }); const bySlug = new Map(parsed.map((item) => [item.skill.slug, item])); const metaSlugs = parsed.filter((item) => item.meta).map((item) => item.skill.slug); @@ -40,7 +32,8 @@ function buildSkillGraph(skills) { if (!target) { if (kind === 'dependency') unresolved.push({ source: skill.id, target: slug }); return; } if (target.meta) edges.push({ source: skill.id, target: target.skill.id, kind }); }; - declaredDependencies(data).forEach((slug) => add(slug, 'dependency')); + // 声明的依赖:@账号/slug 指向别的库,不在本库的图里;slug@版本 取 slug + declaredDependencies(data).map(parseReference).filter((ref) => ref && !ref.owner).forEach((ref) => add(ref.slug, 'dependency')); // Local links such as ../verification-discipline/SKILL.md. Ignore fenced examples and remote URLs. const prose = body.replace(/^(`{3,}|~{3,}).*\n[\s\S]*?^\1[^\n]*$/gm, ''); for (const match of prose.matchAll(/\[[^\]]*\]\(]+)(?:>?(?:\s+"[^"]*")?)\)/g)) { diff --git a/server/skillIndex.js b/server/skillIndex.js new file mode 100644 index 0000000..11b4360 --- /dev/null +++ b/server/skillIndex.js @@ -0,0 +1,333 @@ +// 技能相关度索引:按任务描述找技能(ash suggest)、关键词搜不到时的候选、推送与审核时的相似技能。 +// 不依赖外部模型。排序用逐字段 BM25:中文按字二元组、英文按词切分;description 讲「何时使用」、name/slug 是意图本身, +// 权重最高,其次标签、标题,正文最低——每个字段单独饱和,长正文反复出现某个词也盖不过描述里的一次命中。 +// 相似度用意图字段(名称、描述、标签、标题)的 TF-IDF 余弦。索引按用户缓存,库有变化时重建。 +const matter = require('gray-matter'); +const db = require('./db'); +const { localizedMeta, languagesOf } = require('./skillMeta'); +const { tokenize, queryTerms, CJK } = require('./tokenize'); +const embeddings = require('./embeddings'); + +const FIELD_WEIGHTS = { name: 3, description: 3, tags: 2, headings: 1.5, body: 0.6 }; +const INTENT_FIELDS = ['name', 'description', 'tags', 'headings']; +const K1 = 1.2; +const B = 0.75; + +function stripFences(markdown) { + return markdown.replace(/^(`{3,}|~{3,}).*\n[\s\S]*?^\1[^\n]*$/gm, (block) => block.replace(/^(`{3,}|~{3,}).*$/gm, '')); +} + +function parseTags(tags) { + if (Array.isArray(tags)) return tags; + try { return JSON.parse(tags || '[]'); } catch { return []; } +} + +// 一个技能 → 各字段文本。双语描述(description_en / description_zh、title_en / title_zh)并入对应字段: +// 中文任务能找到英文写的技能,查重也能跨语言对上 +function skillFields(skill) { + let body = skill.content || ''; + let data = {}; + try { const parsed = matter(body, {}); body = parsed.content; data = parsed.data || {}; } catch { /* frontmatter 坏了就整段当正文 */ } + const headings = [...body.matchAll(/^#{1,4}\s+(.+)$/gm)].map((m) => m[1]).join('\n'); + const i18n = localizedMeta(data); + return { + name: `${skill.name || ''} ${String(skill.slug || '').replace(/[-_]/g, ' ')} ${i18n.title_en} ${i18n.title_zh}`, + description: [skill.description || '', i18n.description_en, i18n.description_zh].join('\n'), + tags: parseTags(skill.tags).join(' '), + headings, + body: stripFences(body), + }; +} + +function termCounts(text) { + const tf = new Map(); + const tokens = tokenize(text); + for (const token of tokens) tf.set(token, (tf.get(token) || 0) + 1); + return { tf, length: tokens.length }; +} + +function buildIndex(skills) { + const docs = skills.map((skill) => { + const text = skillFields(skill); + const fields = Object.fromEntries(Object.keys(FIELD_WEIGHTS).map((f) => [f, termCounts(text[f])])); + // 给语义层的文本:名称、描述(含双语描述)、前几个标题——讲的是「这个技能做什么」,不含正文细节 + const embedText = [text.name, text.description, text.headings.split('\n').slice(0, 12).join(' / ')].join('\n').slice(0, 2000); + return { skill, fields, embedText }; + }); + const n = docs.length || 1; + const avgLength = Object.fromEntries(Object.keys(FIELD_WEIGHTS).map((f) => [f, docs.reduce((sum, d) => sum + d.fields[f].length, 0) / n || 1])); + const df = new Map(); + const intentDf = new Map(); + for (const doc of docs) { + const all = new Set(); + const intent = new Set(); + for (const [field, { tf }] of Object.entries(doc.fields)) { + for (const token of tf.keys()) { all.add(token); if (INTENT_FIELDS.includes(field)) intent.add(token); } + } + all.forEach((t) => df.set(t, (df.get(t) || 0) + 1)); + intent.forEach((t) => intentDf.set(t, (intentDf.get(t) || 0) + 1)); + } + const idf = (token) => Math.log(1 + (docs.length - (df.get(token) || 0) + 0.5) / ((df.get(token) || 0) + 0.5)); + // 相似度用的 TF-IDF 向量(只含意图字段,按字段权重累加):对称、0~1,长正文不会因为「顺带提到」而显得相似 + for (const doc of docs) { + const weighted = new Map(); + for (const field of INTENT_FIELDS) { + for (const [token, count] of doc.fields[field].tf) weighted.set(token, (weighted.get(token) || 0) + count * FIELD_WEIGHTS[field]); + } + const toVector = (entries) => { + const vector = new Map(); + let norm = 0; + for (const [token, tf] of entries) { + const weight = (1 + Math.log(tf)) * Math.log((docs.length + 1) / ((intentDf.get(token) || 0) + 0.5)); + if (weight <= 0) continue; + vector.set(token, weight); + norm += weight * weight; + } + return { vector, norm: Math.sqrt(norm) }; + }; + doc.intent = toVector(weighted); + // 按语言再各算一份:中文写的技能补了 description_en 之后,和英文写的同类技能在英文这一侧直接比, + // 不会被各自另一种语言的标题稀释 + const entries = [...weighted]; + doc.primaryLanguage = languagesOf(doc.skill.description).zh ? 'zh' : 'en'; + doc.intentByLang = { + zh: toVector(entries.filter(([token]) => CJK.test(token[0]))), + en: toVector(entries.filter(([token]) => !CJK.test(token[0]))), + }; + } + return { docs, avgLength, idf }; +} + +// 两个技能「做的是不是同一件事」:整体比一次。主语言不同(一个中文写、一个英文写)时,再按语言各比一次取最高—— +// 只在这种情况下分语言比:同语言的同系列技能(共享大段套话)分语言比会显得过于相似。 +// 某种语言的词太少(例如中文技能里零星几个英文词)时不单独比较,免得一两个词撞上就判成重复 +const MIN_LANG_TOKENS = 6; +function intentSimilarity(a, b) { + let best = cosine(a.intent, b.intent); + if (a.primaryLanguage === b.primaryLanguage) return best; + for (const lang of ['zh', 'en']) { + const x = a.intentByLang[lang]; + const y = b.intentByLang[lang]; + if (x.vector.size >= MIN_LANG_TOKENS && y.vector.size >= MIN_LANG_TOKENS) best = Math.max(best, cosine(x, y)); + } + return best; +} + +function cosine(a, b) { + if (!a.norm || !b.norm) return 0; + const [small, large] = a.vector.size < b.vector.size ? [a, b] : [b, a]; + let dot = 0; + for (const [token, weight] of small.vector) dot += weight * (large.vector.get(token) || 0); + return dot / (a.norm * b.norm); +} + +function scoreDoc(index, doc, terms) { + let score = 0; + const matched = []; + for (const [token, queryWeight] of terms) { + let termScore = 0; + for (const [field, weight] of Object.entries(FIELD_WEIGHTS)) { + const { tf, length } = doc.fields[field]; + const count = tf.get(token); + if (!count) continue; + termScore += weight * (count * (K1 + 1)) / (count + K1 * (1 - B + B * length / index.avgLength[field])); + } + if (!termScore) continue; + score += queryWeight * index.idf(token) * termScore; + if (queryWeight === 1) matched.push(token); + } + return { score, matched }; +} + +// ——— 按用户缓存 ——— +const cache = new Map(); + +function librarySignature(userId) { + const row = db.prepare(` + SELECT COUNT(*) AS n, MAX(updated_at) AS latest, SUM(LENGTH(content)) AS size, SUM(status = 'pending') AS pending, SUM(id) AS ids + FROM skills WHERE user_id = ? AND is_deleted = 0 + `).get(userId); + return `${row.n}|${row.latest}|${row.size}|${row.pending}|${row.ids}`; +} + +function indexFor(userId) { + const signature = librarySignature(userId); + const hit = cache.get(userId); + if (hit && hit.signature === signature) return hit.index; + const skills = db.prepare(` + SELECT id, slug, name, description, folder_path, tags, content, status, version FROM skills WHERE user_id = ? AND is_deleted = 0 + `).all(userId); + const index = buildIndex(skills); + index.skills = skills; + index.extras = new Map(); + cache.set(userId, { signature, index }); + return index; +} + +// 挂在索引上的其他整库计算(引用分析、重复配对等):库一变化,随索引一起失效 +function libraryCache(userId, key, build) { + const index = indexFor(userId); + if (!index.extras.has(key)) index.extras.set(key, build(index.skills, index)); + return index.extras.get(key); +} + +// 把命中的词元拼回查询原文里的连续片段:「检测」「测框」→「检测框」 +function matchedPhrases(query, matchedTokens) { + const set = new Set(matchedTokens); + const { spans, source } = tokenize(query, { withSpans: true }); + const phrases = []; + for (const span of spans.filter((s) => set.has(s.token)).sort((a, b) => a.start - b.start)) { + const last = phrases[phrases.length - 1]; + if (last && span.start <= last.end) last.end = Math.max(last.end, span.end); + else phrases.push({ start: span.start, end: span.end }); + } + return [...new Set(phrases.map((p) => source.slice(p.start, p.end)))]; +} + +// relevance = 得分 / 「全部原始查询词都在描述里命中一次」的得分,截到 0~1。 +// 低于 MIN_RELEVANCE 视为不相关;STRONG_RELEVANCE 以上标为「相关」,其余为「可能相关」。 +// 只保留不低于第一名 RELATIVE_CUTOFF 的结果,免得长尾噪声淹没真正的候选 +const MIN_RELEVANCE = 0.12; +const STRONG_RELEVANCE = 0.25; +const RELATIVE_CUTOFF = 0.4; +const FULL_MATCH = FIELD_WEIGHTS.description * (K1 + 1) / (1 + K1); + +function rankSkills(userId, query, options) { + return rankInIndex(indexFor(userId), query, options); +} + +// 默认只看已发布技能(Agent 拉得到的) +// where(skill) 可进一步限定范围(例如调用方已经按目录、标签筛选过) +// ——— 语义分数换算到文本比对的同一刻度 ——— +// 默认值用 bge-m3 在 139 个真实技能上校准:技能对之间,主语言不同的无关配对 99% 在 0.62 以下,真正的跨语言重复在 0.75 以上; +// 任务描述对技能,无关查询(「今天天气怎么样」)最高约 0.51,真正命中在 0.6~0.75。换模型时分布不同,用环境变量覆盖: +// ASH_EMBED_PAIR_THRESHOLDS="0.68,0.74" 相近、疑似重复 +// ASH_EMBED_QUERY_THRESHOLDS="0.58,0.68" 可能相关、相关 +function thresholds(name, fallback) { + const values = String(process.env[name] || '').split(',').map(Number).filter((x) => x > 0 && x < 1); + return values.length === 2 && values[0] < values[1] ? values : fallback; +} +function piecewise(x, [low, high], [lowScore, highScore]) { + if (x >= high) return highScore + (x - high) / (1 - high) * (1 - highScore); + if (x >= low) return lowScore + (x - low) / (high - low) * (highScore - lowScore); + const floor = low - (high - low); + return x > floor ? (x - floor) / (low - floor) * lowScore : 0; +} +const semanticPair = (cos) => piecewise(cos, thresholds('ASH_EMBED_PAIR_THRESHOLDS', [0.68, 0.74]), [SIMILAR_MIN, SIMILAR_HIGH]); +const semanticQuery = (cos) => piecewise(cos, thresholds('ASH_EMBED_QUERY_THRESHOLDS', [0.58, 0.68]), [MIN_RELEVANCE, STRONG_RELEVANCE]); + +// 按任务描述排序。queryVector / vectors 来自语义层(可选):相关度取文本、语义两者较高的—— +// 文本命中的照旧,中文描述找英文写的技能这类文本比对看不见的,靠语义补上 +function rankInIndex(index, query, { limit = 5, includePending = false, excludeIds = [], minRelevance = MIN_RELEVANCE, where = null, queryVector = null, vectors = null } = {}) { + const terms = queryTerms(query); + if ((!terms.size && !queryVector) || !index.docs.length) return []; + let ceiling = 0; + for (const [token, weight] of terms) if (weight === 1) ceiling += index.idf(token) * FULL_MATCH; + ceiling = ceiling || 1; + const exclude = new Set(excludeIds.map(Number)); + const scored = []; + for (const doc of index.docs) { + if (exclude.has(doc.skill.id)) continue; + if (!includePending && doc.skill.status === 'pending') continue; + if (where && !where({ ...doc.skill, tags: parseTags(doc.skill.tags) })) continue; + const { score, matched } = scoreDoc(index, doc, terms); + const lexical = score / ceiling; + const vector = queryVector && vectors?.get(doc.skill.id); + const semantic = vector ? semanticQuery(embeddings.cosine(queryVector, vector)) : 0; + const combined = Math.max(lexical, semantic); + if (combined > 0) scored.push({ doc, score, matched, combined, semantic: semantic > lexical }); + } + scored.sort((a, b) => b.combined - a.combined); + const top = scored[0]; + if (!top || top.combined < minRelevance) return []; + return scored + .filter((r) => r.combined >= minRelevance && r.combined >= top.combined * RELATIVE_CUTOFF) + .slice(0, limit) + .map((r) => ({ + skill: publicSkill(r.doc.skill), score: r.score, relevance: Math.min(1, r.combined), + strong: r.combined >= STRONG_RELEVANCE, matched: matchedPhrases(query, r.matched), semantic: r.semantic, + })); +} + +// 带语义层的按任务排序:向量还没算好的技能先按文本比对,同时在后台补齐 +async function rankSkillsSemantic(userId, query, options = {}) { + const index = indexFor(userId); + if (!embeddings.enabled()) return rankInIndex(index, query, options); + const vectors = embeddings.vectorsFor(index.docs); + if (vectors.size < index.docs.length) ensureSemantic(userId); + const queryVector = vectors.size ? await embeddings.embedQuery(query) : null; + return rankInIndex(index, query, { ...options, queryVector, vectors }); +} + +// 在后台补齐这个库缺的向量(不等结果) +function ensureSemantic(userId) { + if (!embeddings.enabled()) return; + embeddings.ensureVectors(userId, indexFor(userId).docs).catch(() => {}); +} +// 等向量补齐,最多等 ms 毫秒 +const ensureSemanticWithin = (userId, ms) => embeddings.ensureVectorsWithin(userId, indexFor(userId).docs, ms); + +// 相似度:两个技能意图字段 TF-IDF 向量的余弦(0~1)。 +// SIMILAR_MIN 以上算「相近」,SIMILAR_HIGH 以上提示「疑似重复」(用 139 个真实技能校准: +// 0.25~0.4 多是同一系列的兄弟技能,0.4 以上基本是职责重叠) +const SIMILAR_MIN = 0.25; +const SIMILAR_HIGH = 0.4; + +function similarSkills(userId, target, options = {}) { + const index = indexFor(userId); + return similarInIndex(index, target, { vectors: embeddings.vectorsFor(index.docs), ...options }); +} + +// 两个技能的相似度:文本比对;主语言不同、且都有向量时,再用语义分数补位(同语言的同系列技能语义上很像却不是重复,不用语义) +function pairSimilarity(a, b, vectors) { + const lexical = intentSimilarity(a, b); + if (!vectors || a.primaryLanguage === b.primaryLanguage) return { similarity: lexical, semantic: false }; + const va = vectors.get(a.skill.id); + const vb = vectors.get(b.skill.id); + const semantic = va && vb ? semanticPair(embeddings.cosine(va, vb)) : 0; + return semantic > lexical ? { similarity: Math.min(1, semantic), semantic: true } : { similarity: lexical, semantic: false }; +} + +function similarInIndex(index, target, { limit = 3, minSimilarity = SIMILAR_MIN, includePending = true, vectors = null } = {}) { + const self = index.docs.find((d) => d.skill.id === target.id); + if (!self) return []; + const results = []; + for (const doc of index.docs) { + if (doc === self || doc.skill.slug === target.slug) continue; + if (!includePending && doc.skill.status === 'pending') continue; + const { similarity, semantic } = pairSimilarity(self, doc, vectors); + if (similarity >= minSimilarity) results.push({ skill: publicSkill(doc.skill), similarity, high: similarity >= SIMILAR_HIGH, semantic }); + } + return results.sort((a, b) => b.similarity - a.similarity).slice(0, limit); +} + +// 已发布技能里「疑似重复」的配对:Map(skillId → 最像的那个 { skill, similarity })。 +// 列表、侧栏计数每次都要用,挂在索引上缓存,库变化、索引重建时一并失效 +function duplicateMap(userId) { + const vectors = embeddings.vectorsFor(indexFor(userId).docs); + // 向量是后台逐步补齐的:补齐前后结果不同,缓存键带上向量个数 + return libraryCache(userId, `duplicates:${vectors.size}`, (skills, index) => { + const docs = index.docs.filter((d) => d.skill.status !== 'pending'); + const best = new Map(); + const keep = (a, b, similarity) => { + if (!best.has(a.skill.id) || best.get(a.skill.id).similarity < similarity) best.set(a.skill.id, { skill: publicSkill(b.skill), similarity }); + }; + for (let i = 0; i < docs.length; i += 1) { + for (let j = i + 1; j < docs.length; j += 1) { + const { similarity } = pairSimilarity(docs[i], docs[j], vectors); + if (similarity >= SIMILAR_HIGH) { keep(docs[i], docs[j], similarity); keep(docs[j], docs[i], similarity); } + } + } + return best; + }); +} + +function publicSkill(skill) { + const { id, slug, name, description, folder_path, status } = skill; + return { id, slug, name, description, folder_path, status }; +} + +module.exports = { + tokenize, queryTerms, buildIndex, rankSkills, rankInIndex, rankSkillsSemantic, similarSkills, similarInIndex, duplicateMap, + libraryCache, matchedPhrases, ensureSemantic, ensureSemanticWithin, semanticPair, semanticQuery, +}; diff --git a/server/skillMeta.js b/server/skillMeta.js index 64d19da..d4d06ae 100644 --- a/server/skillMeta.js +++ b/server/skillMeta.js @@ -4,15 +4,20 @@ // description 显式 > frontmatter description // tags 显式 > frontmatter tags(数组或逗号分隔字符串) // version 显式 > frontmatter version > 1.0.0 +// 另有两类约定也在这里解析: +// 双语描述 description 是主描述(中英文均可),description_en / description_zh 补另一种语言 +// 技能引用 depends_on: [slug, @owner/slug, slug@版本]——见 parseReference const matter = require('gray-matter'); function slugify(value) { return String(value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); } +// 传入 options 对象是为了绕过 gray-matter 的内置缓存:它在解析之前就按原文缓存结果, +// 同一段坏掉的 frontmatter 第二次解析会静默返回空数据、不再报错(缓存也会随解析过的内容无限增长) function parseFrontmatter(content) { try { - return { data: matter(content).data || {}, error: null }; + return { data: matter(content, {}).data || {}, error: null }; } catch (err) { return { data: {}, error: err.message }; } @@ -33,16 +38,84 @@ function stringList(value) { return typeof value === 'string' ? [value] : []; } -// frontmatter 声明的依赖(依赖图与 ash pull 的依赖安装共用) +// frontmatter 声明的依赖(依赖图、引用检查与 ash pull 的依赖安装共用)。 +// 也认 metadata 里逗号分隔的写法:Agent Skills 规范要求 metadata 的值是字符串 function declaredDependencies(data = {}) { - const deps = [data.depends_on, data.dependencies, data.requires, data.metadata?.depends_on].flatMap(stringList); + const deps = [data.depends_on, data.dependencies, data.requires, data.metadata?.depends_on, data.metadata?.['depends-on']] + .flatMap(stringList).flatMap((d) => d.split(',')); return [...new Set(deps.map((d) => d.trim()).filter(Boolean))]; } -function dependenciesOf(content) { - return declaredDependencies(parseFrontmatter(content || '').data); +// 引用写法:slug(本库)、@owner/slug(指定账号,为团队库 / 技能广场预留)、slug@1.2.0(固定版本) +const REFERENCE = /^(?:@([A-Za-z0-9_.-]+)\/)?([A-Za-z0-9_.-]+)(?:@([A-Za-z0-9_.+-]+))?$/; +function parseReference(raw) { + const value = String(raw || '').trim(); + const m = REFERENCE.exec(value); + return m ? { raw: value, owner: m[1] || null, slug: m[2], pin: m[3] || null } : null; } +// 技能声明的引用:{ refs: [解析后的引用], invalid: [写法不对的原文] } +function referencesOf(content) { + const declared = declaredDependencies(parseFrontmatter(content || '').data); + const refs = []; + const invalid = []; + for (const raw of declared) { + const ref = parseReference(raw); + if (ref) refs.push(ref); else invalid.push(raw); + } + return { refs, invalid }; +} + +// ——— 双语描述 ——— +// 两种语言都有,中文任务和英文任务才都能找到它,查重也才能跨语言对上;以后网页做双语界面直接用这两个字段。 +// 只要求元数据双语,不要求正文写两份:两份正文要同步维护、迟早漂移,而读正文的 Agent 本来就中英文都懂 +const CJK_CHARS = /[\u3400-\u9fff\uf900-\ufaff]/g; +function languagesOf(value) { + const s = text(value); + return { zh: (s.match(CJK_CHARS) || []).length >= 4, en: (s.match(/[A-Za-z]{2,}/g) || []).length >= 4 }; +} + +function localizedMeta(data = {}) { + const pick = (key) => text(data[key]) || text(data.metadata?.[key]) || text(data.metadata?.[key.replace('_', '-')]); + return { description_en: pick('description_en'), description_zh: pick('description_zh'), title_en: pick('title_en'), title_zh: pick('title_zh') }; +} + +// 描述还缺哪种语言:['en'] / ['zh'] / []。主描述本身中英混写(例如「【元技能】… Use when …」)也算双语 +function missingLanguages(description, localized = {}) { + const { zh, en } = languagesOf([description, localized.description_en, localized.description_zh].join('\n')); + return [!zh && 'zh', !en && 'en'].filter(Boolean); +} + +// 只改了双语字段(description_en / description_zh / title_en / title_zh,含 metadata 里的同名键):正文与其余 frontmatter 都没动。 +// 这些字段 Claude Code、Codex 等 Agent 框架都不读,改了也不会影响 Agent 的行为,只影响技能库的检索与查重—— +// 所以这类更新可以不经人工审核直接发布,补双语描述才不会变成一大堆待审。以后做团队库 / 技能广场时要重新评估 +const LOCALIZED_KEYS = ['description_en', 'description_zh', 'title_en', 'title_zh']; +function withoutLocalized(data) { + const copy = JSON.parse(JSON.stringify(data || {})); + for (const key of LOCALIZED_KEYS) { + delete copy[key]; + if (copy.metadata && typeof copy.metadata === 'object') { delete copy.metadata[key]; delete copy.metadata[key.replace('_', '-')]; } + } + if (copy.metadata && typeof copy.metadata === 'object' && !Object.keys(copy.metadata).length) delete copy.metadata; + return copy; +} +const stable = (value) => JSON.stringify(value, (key, v) => (v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v)); +function onlyLocalizationChanged(before, after) { + if (before === after) return false; + const bodyOf = (text) => String(text || '').replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, ''); + if (bodyOf(before) !== bodyOf(after)) return false; + const a = parseFrontmatter(before); + const b = parseFrontmatter(after); + if (a.error || b.error) return false; + return stable(withoutLocalized(a.data)) === stable(withoutLocalized(b.data)); +} + +const LANGUAGE_HINT = { + en: '缺少英文描述:请在 frontmatter 加 description_en(英文任务也能找到它,也用于跨语言查重)', + zh: '缺少中文描述:请在 frontmatter 加 description_zh(中文任务也能找到它,也用于跨语言查重)', +}; + function normalizeSkillMeta(content, explicit = {}, { fileName = '' } = {}) { const { data, error: frontmatterError } = parseFrontmatter(content || ''); const body = String(content || '').replace(/^---[\s\S]*?\n---\s*\n?/, ''); @@ -68,8 +141,22 @@ function normalizeSkillMeta(content, explicit = {}, { fileName = '' } = {}) { const warnings = []; if (frontmatterError) warnings.push(`frontmatter 解析失败:${frontmatterError}`); if (!description) warnings.push('缺少 description:建议在 frontmatter 写一句「何时使用这个技能」,Agent 靠它判断是否调用'); + const localized = localizedMeta(data); + if (description) missingLanguages(description, localized).forEach((lang) => warnings.push(LANGUAGE_HINT[lang])); + + return { slug, name, description, tags, version, localized, frontmatter: data, errors, warnings }; +} - return { slug, name, description, tags, version, frontmatter: data, errors, warnings }; +// 安装时附在 SKILL.md 末尾的一段(见 templates/install.sh):标记行 + 若干「> 」行(引用的技能在本机哪里、用完怎么反馈)。 +// 它不属于技能内容:从本机已装目录推送回来时去掉,否则「没改过」的技能也会被当成有修改。 +// 只去掉这一段——Agent 常在文件末尾(这段之后)追加修正,那些内容必须保留 +const INSTALL_FOOTER_MARK = ''; +const INSTALL_FOOTER = /\n\n(?:> [^\n]*(?:\n|$))+/g; +function stripInstallFooter(content) { + return String(content || '').replace(INSTALL_FOOTER, ''); } -module.exports = { slugify, normalizeSkillMeta, normalizeTags, declaredDependencies, dependenciesOf }; +module.exports = { + slugify, normalizeSkillMeta, normalizeTags, parseFrontmatter, declaredDependencies, parseReference, referencesOf, + languagesOf, localizedMeta, missingLanguages, onlyLocalizationChanged, LANGUAGE_HINT, stripInstallFooter, INSTALL_FOOTER_MARK, +}; diff --git a/server/skillRefs.js b/server/skillRefs.js new file mode 100644 index 0000000..c029e8f --- /dev/null +++ b/server/skillRefs.js @@ -0,0 +1,327 @@ +// 技能之间的引用与元技能检查:谁引用了谁、谁被谁引用、引用有没有写对、元技能有没有写契约、有没有人抄了元技能的步骤。 +// +// 引用只认明确的信号(与依赖图一致): +// · frontmatter depends_on(ash pull 据此一并安装) +// · 正文里指向 ..//SKILL.md 的本地链接 +// · 同一行里既有「元技能 / meta-skill」又有某个元技能完整 slug 的写法,如「用元技能 `verification-discipline`」 +// 随口提到另一个技能的名字不算——仲裁规则里写「否则用 X」并不是引用 X。 +const { parseFrontmatter, referencesOf, missingLanguages, localizedMeta, LANGUAGE_HINT } = require('./skillMeta'); +const { extractCommands, sharedRuns, extractProseSteps, sharedProseRuns } = require('./skillSteps'); + +const META_WORD = /元技能|meta[- ]skill/i; +const MAX_CHAIN = 2; // 引用链超过这么多层给出提醒:每多一层,Agent 就多读一个文件、多一个可能断的地方 +const EXTRACT_MIN = 3; // 三次法则:≥3 个技能在重复同一段操作才建议提炼元技能 + +function list(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : typeof value === 'string' ? [value] : []; +} + +function parseTags(tags) { + if (Array.isArray(tags)) return tags; + try { return JSON.parse(tags || '[]'); } catch { return []; } +} + +// 元技能:frontmatter type/kind: meta、标签 meta-skill / 元技能、description 以「【元技能】」开头,或放在「元技能」目录里。 +// 单独的 meta 标签不算:它常指「关于技能本身的技能」(例如怎么写技能),不是被别的技能引用的基础动作 +function isMetaSkill(skill, data = {}) { + return data.type === 'meta' || data.kind === 'meta' || data.metadata?.type === 'meta' || data.is_meta === true + || list(parseTags(skill.tags)).some((tag) => ['meta-skill', '元技能'].includes(tag)) + || /^【元技能[·】]/.test(skill.description || '') || /(?:^|\/)元技能(?:\/|$)/.test(skill.folder_path || ''); +} + +function splitBody(content) { + const text = String(content || ''); + const m = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/.exec(text); + return m ? text.slice(m[0].length) : text; +} + +const stripFences = (body) => body.replace(/^(`{3,}|~{3,}).*\n[\s\S]*?^\1[^\n]*$/gm, ''); + +const slugPatterns = new Map(); +function slugPattern(slug) { + if (!slugPatterns.has(slug)) { + const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + slugPatterns.set(slug, new RegExp(`(?, absolute: [绝对路径链接] } +function bodyReferences(body, metaSlugs) { + const prose = stripFences(body); + const refs = new Set(); + const absolute = []; + for (const match of prose.matchAll(/\[[^\]]*\]\(]+)(?:>?(?:\s+"[^"]*")?)\)/g)) { + const href = match[1]; + if (/^[a-z][a-z\d+.-]*:/i.test(href)) continue; + let path; + try { path = decodeURIComponent(href.split(/[?#]/)[0]); } catch { continue; } + const target = path.match(/(?:^|\/)([^/]+)\/SKILL\.md$/i); + if (!target) continue; + refs.add(target[1]); + if (/^(~|\/|[A-Za-z]:\\)/.test(path)) absolute.push(path); + } + for (const line of prose.split('\n')) { + if (!META_WORD.test(line)) continue; + for (const slug of metaSlugs) if (slugPattern(slug).test(line)) refs.add(slug); + } + return { refs, absolute }; +} + +// 「## 标题」下的一节正文(到下一个同级或更高级标题为止) +function section(body, titles) { + const prose = stripFences(body); + const heading = new RegExp(`^(#{2,4})\\s*(?:${titles.join('|')})\\s*$`, 'mi'); + const m = heading.exec(prose); + if (!m) return null; + const rest = prose.slice(m.index + m[0].length); + const next = new RegExp(`^#{2,${m[1].length}}\\s`, 'm').exec(rest); + return next ? rest.slice(0, next.index) : rest; +} + +// 「## 被谁引用」里列出的技能:反引号里的 slug,或列表项开头的 slug(库里已经没有的也要列出来,才能提示它过时了) +function listedSlugs(text, self) { + const found = new Set(); + for (const m of text.matchAll(/`([A-Za-z0-9_.-]+)`/g)) found.add(m[1]); + for (const m of text.matchAll(/^\s*[-*]\s+([A-Za-z0-9_.-]+)(?=\s|$|[((::,,])/gm)) found.add(m[1]); + found.delete(self); + return found; +} + +// 单个技能的引用与结构信息。library 提供库里的 slug 集合,以便分辨哪些引用解析得到 +function analyzeSkill(skill, library) { + const { data, error: frontmatterError } = parseFrontmatter(skill.content || ''); + const body = splitBody(skill.content); + const meta = isMetaSkill(skill, data); + const { refs: declared, invalid } = referencesOf(skill.content); + const { refs: inBody, absolute } = bodyReferences(body, library.metaSlugs); + inBody.delete(skill.slug); + const contract = section(body, ['契约', 'Contract', '接口', 'Interface']); + const usedBy = section(body, ['被谁引用', 'Used by', '调用方']); + return { + id: skill.id, slug: skill.slug, name: skill.name, status: skill.status, version: skill.version, meta, frontmatterError, + frontmatterName: typeof data.name === 'string' ? data.name.trim() : null, + description: String(skill.description || ''), + // Claude Code 不会自动调用设置了这个字段的技能——别的技能也就引用不到它 + disableModelInvocation: /^(true|yes|on|1)$/i.test(String(data['disable-model-invocation'] ?? '')), + declared: declared.filter((r) => r.slug !== skill.slug), invalid, inBody, absolute, + contract: contract === null ? null : { + input: /输入|input/i.test(contract), output: /输出|output|返回|拿回/i.test(contract), + }, + usedByListed: usedBy === null ? null : listedSlugs(usedBy, skill.slug), + missingLanguages: skill.description ? missingLanguages(skill.description, localizedMeta(data)) : [], + commands: extractCommands(skill.content), + steps: extractProseSteps(skill.content), + }; +} + +// 引用是否指向本库:没写账号,或写的就是自己的账号 +const isLocal = (ref, username) => !ref.owner || (username && ref.owner.toLowerCase() === String(username).toLowerCase()); + +// 整个库的引用分析。skills 为库里未删除的技能(含待审核) +function analyzeLibrary(skills) { + const slugs = new Set(skills.map((s) => s.slug)); + const metaSlugs = skills.filter((s) => isMetaSkill(s, parseFrontmatter(s.content || '').data)).map((s) => s.slug); + const library = { slugs, metaSlugs }; + const entries = skills.map((s) => analyzeSkill(s, library)); + const bySlug = new Map(entries.map((e) => [e.slug, e])); + // 被谁引用:声明的本库引用 + 正文引用(跨账号的引用不计入本库的反向索引) + const dependents = new Map(); + const outgoing = new Map(); + for (const e of entries) { + const targets = new Set([...e.declared.filter((r) => !r.owner).map((r) => r.slug), ...e.inBody]); + outgoing.set(e.slug, [...targets].filter((t) => bySlug.has(t))); + for (const t of targets) { + if (!bySlug.has(t)) continue; + if (!dependents.has(t)) dependents.set(t, []); + dependents.get(t).push(e.slug); + } + } + // 共有的命令段、文字步骤段:所有技能(含待审核,推送时的新技能也要能比对) + const runs = computeRuns(entries); + return { library, entries, bySlug, dependents, outgoing, runs }; +} + +// 固定的版本在历史里有没有(analysis.historyVersions 由 libraryAnalysis 从 skill_versions 填入) +const pinAvailable = (analysis, ref) => Boolean(analysis.historyVersions?.get(ref.slug)?.has(String(ref.pin))); + +// 从 slug 出发最长的引用链,以及遇到的环 +function chainFrom(analysis, slug) { + let longest = [slug]; + let cycle = null; + const walk = (path) => { + if (path.length > longest.length) longest = path; + for (const next of analysis.outgoing.get(path[path.length - 1]) || []) { + if (path.includes(next)) { if (!cycle && next === slug) cycle = [...path, next]; continue; } + if (path.length <= 6) walk([...path, next]); + } + }; + walk([slug]); + return { longest, cycle }; +} + +// kind: commands(shell 命令)| steps(文字步骤) +function computeRuns(entries) { + return [ + ...sharedRuns(entries.filter((e) => e.commands.length).map((e) => ({ id: e.id, commands: e.commands }))).map((r) => ({ ...r, kind: 'commands' })), + ...sharedProseRuns(entries.filter((e) => e.steps.length).map((e) => ({ id: e.id, steps: e.steps }))).map((r) => ({ ...r, kind: 'steps' })), + ]; +} +const UNIT = { commands: '条命令', steps: '个步骤' }; + +const idsToSlugs = (analysis, ids) => ids.map((id) => analysis.entries.find((e) => e.id === id)).filter(Boolean); + +// 与 entry 共有命令段的其他技能:[{ others: [entry…], commands }] +function sharedStepsOf(analysis, entry) { + // entry 可能是还没进库的内容(待审更新):那就单独把它和库里其他技能比一遍 + const inLibrary = analysis.entries.includes(entry); + const runs = inLibrary ? analysis.runs : computeRuns([entry, ...analysis.entries.filter((e) => e.id !== entry.id)]); + return runs.filter((r) => r.skills.includes(entry.id)) + .map((r) => ({ others: idsToSlugs(analysis, r.skills.filter((id) => id !== entry.id)), commands: r.commands, kind: r.kind })); +} + +// 检查一个技能:[{ code, level: warn | info, msg }]。entry 默认取库里的分析结果,也可以传入待审内容的分析 +function lintSkill(analysis, entry, { username = null } = {}) { + const issues = []; + const add = (code, level, msg) => issues.push({ code, level, msg }); + // frontmatter 解析失败时 name / description / depends_on 全部丢失,Agent 框架也读不到——最先修 + if (entry.frontmatterError) add('frontmatter', 'warn', `frontmatter 解析失败(含「: 」的值要用引号括起来):${entry.frontmatterError.split('\n')[0]}`); + // Agent 框架能不能加载、会不会调用它(Agent Skills 规范与 Claude Code 的约束) + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(entry.slug) || entry.slug.length > 64) { + add('spec-name', 'warn', `标识 ${entry.slug} 不符合 Agent Skills 规范(只能用小写字母、数字和单个连字符,最长 64 个字符):严格按规范的客户端可能不加载它`); + } + if (!entry.frontmatterError && entry.frontmatterName && entry.frontmatterName !== entry.slug) { + add('name-mismatch', 'warn', `frontmatter 的 name(${entry.frontmatterName})与标识 ${entry.slug} 不一致:规范要求 name 与目录名相同,Claude Code 以 name 作为技能名,别的技能按 ${entry.slug} 引用它时会对不上`); + } + if (!entry.description.trim()) add('no-description', 'warn', '缺少 description:Agent 框架靠它决定什么时候调用这个技能,缺了几乎不会被自动触发'); + else if (entry.description.length > 1024) add('description-length', 'warn', `description 有 ${entry.description.length} 个字符,超过规范上限 1024:Claude Code 在技能列表里只保留前 1536 个字符,写在后面的触发条件 Agent 看不到`); + if (entry.disableModelInvocation) { + const callers = (analysis.dependents.get(entry.slug) || []).filter((s) => s !== entry.slug); + if (callers.length) add('not-invocable', 'warn', `被 ${callers.join('、')} 引用,但设置了 disable-model-invocation: true:Claude 不能调用它,引用它的技能走不通`); + } + entry.missingLanguages.forEach((lang) => add('bilingual', 'warn', LANGUAGE_HINT[lang])); + + const declaredLocal = new Set(); + for (const raw of entry.invalid) add('invalid-ref', 'warn', `depends_on 里的「${raw}」写法不对:应为 slug、@账号/slug 或 slug@版本`); + for (const ref of entry.declared) { + if (!isLocal(ref, username)) { + add('foreign-ref', 'info', `${ref.raw} 指向其他账号:团队库 / 技能广场上线前还不能跨账号安装`); + continue; + } + declaredLocal.add(ref.slug); + const target = analysis.bySlug.get(ref.slug); + if (target?.disableModelInvocation) add('ref-not-invocable', 'warn', `引用的 ${ref.slug} 设置了 disable-model-invocation: true:Claude Code 里调用不到它(仍可读 ../${ref.slug}/SKILL.md)`); + if (!target) add('missing-ref', 'warn', `引用的 ${ref.slug} 不在库里(或已删除):ash pull 装不上它`); + else if (target.status === 'pending') add('pending-ref', 'info', `引用的 ${ref.slug} 还在待审核:采纳前别的机器装不上它`); + else if (ref.pin && target.version && ref.pin !== target.version) { + if (pinAvailable(analysis, ref)) add('pin-old', 'info', `固定在 ${ref.slug}@${ref.pin}(历史版本,ash pull 会装那一版),库里当前是 ${target.version}:确认新版适用后可以改掉固定`); + else add('pin-missing', 'warn', `固定在 ${ref.slug}@${ref.pin},但库里没有这个版本(当前是 ${target.version}):ash pull 会失败`); + } + } + for (const slug of entry.inBody) { + if (declaredLocal.has(slug) || !analysis.bySlug.has(slug)) continue; + add('undeclared-ref', 'warn', `正文引用了 ${slug},但 depends_on 没有声明:ash pull 不会一并装上它,本地 Agent 可能找不到`); + } + for (const path of entry.absolute) add('absolute-link', 'warn', `链接 ${path} 用了绝对路径:换一台机器或换一个 Agent 就找不到,请写成相对路径 ..//SKILL.md`); + + if (analysis.bySlug.get(entry.slug) === entry) { + const { longest, cycle } = chainFrom(analysis, entry.slug); + if (cycle) add('cycle', 'warn', `引用成环:${cycle.join(' → ')}`); + else if (longest.length - 1 > MAX_CHAIN) { + add('deep-chain', 'info', `引用链有 ${longest.length - 1} 层(${longest.join(' → ')}):每多一层,Agent 就要多读一个文件、多一个可能断的地方;考虑把中间层的内容收进调用方或元技能`); + } + } + + if (entry.meta) { + const actual = (analysis.dependents.get(entry.slug) || []).filter((s) => s !== entry.slug); + if (!entry.contract) add('meta-contract', 'warn', '元技能缺少「## 契约」一节(输入 / 输出 / 前置 / 失败):调用方靠它知道要给什么、能拿回什么'); + else if (!entry.contract.input || !entry.contract.output) add('meta-contract', 'info', '「## 契约」里没写清输入或输出'); + if (!entry.usedByListed) { + add('meta-used-by', 'info', `元技能缺少「## 被谁引用」一节:改它之前要知道会影响谁${actual.length ? `(实际被 ${actual.join('、')} 引用)` : ''}`); + } else { + const missing = actual.filter((s) => !entry.usedByListed.has(s)); + const extra = [...entry.usedByListed].filter((s) => !actual.includes(s)); + if (missing.length || extra.length) { + add('meta-used-by', 'info', `「## 被谁引用」与实际不符${missing.length ? `:漏了 ${missing.join('、')}` : ''}${extra.length ? `${missing.length ? ';' : ':'}${extra.join('、')} 其实没有引用它` : ''}`); + } + } + if (actual.length < 2) { + add('meta-few-callers', 'info', `只有 ${actual.length} 个技能引用这个元技能。三次法则:第 3 个调用方出现再抽;抽出后要立刻把调用方改成引用它`); + } + } + + for (const { others, commands, kind } of sharedStepsOf(analysis, entry)) { + const metaOthers = others.filter((o) => o.meta); + const size = `${commands.length} ${UNIT[kind]}`; + if (!entry.meta && metaOthers.length) { + const m = metaOthers[0].slug; + add('copies-meta', 'warn', `有 ${size}与元技能 ${m} 相同:引用它(depends_on 加上 ${m},正文写「用元技能 \`${m}\`」+ 本技能特有的参数),不要抄它的步骤`); + } else if (!metaOthers.length && others.length + 1 >= EXTRACT_MIN) { + add('extract-candidate', 'info', `有 ${size}与 ${others.map((o) => o.slug).join('、')} 相同:已有 ${others.length + 1} 个技能在重复这段操作,可以考虑提炼为元技能`); + } + } + return issues; +} + +// 整理用的结构信号(「需关注」视图):Map(skillId → [{ code, label, detail }]),只看已发布技能 +function structuralFlags(analysis) { + const flags = new Map(); + const add = (id, flag) => { if (!flags.has(id)) flags.set(id, []); if (!flags.get(id).some((f) => f.code === flag.code)) flags.get(id).push(flag); }; + const published = analysis.entries.filter((e) => e.status !== 'pending'); + for (const e of published) { + const broken = e.declared.filter((r) => !r.owner && !analysis.bySlug.has(r.slug)).map((r) => r.slug); + if (broken.length) add(e.id, { code: 'broken_ref', label: '引用失效', detail: `引用的 ${broken.join('、')} 不在库里` }); + if (e.meta && !e.contract) add(e.id, { code: 'meta_contract', label: '元技能缺契约', detail: '缺少「## 契约」一节(输入 / 输出 / 前置 / 失败)' }); + } + const publishedIds = new Set(published.map((e) => e.id)); + for (const run of analysis.runs) { + const members = idsToSlugs(analysis, run.skills).filter((e) => publishedIds.has(e.id)); + const metas = members.filter((e) => e.meta); + if (metas.length) { + for (const e of members.filter((m) => !m.meta)) { + add(e.id, { code: 'copies_meta', label: '抄了元技能步骤', detail: `有 ${run.commands.length} ${UNIT[run.kind]}与元技能 ${metas[0].slug} 相同,应改为引用它` }); + } + } else if (members.length >= EXTRACT_MIN) { + for (const e of members) { + add(e.id, { code: 'extractable', label: '可提炼元技能', detail: `与 ${members.filter((m) => m !== e).map((m) => m.slug).join('、')} 有 ${run.commands.length} ${UNIT[run.kind]}相同` }); + } + } + } + return flags; +} + +// ——— 按用户缓存(挂在相关度索引上,库一变化就重算)——— +// 延迟加载:上面的纯函数不依赖数据库,单元测试可以直接用 +function libraryAnalysis(userId) { + return require('./skillIndex').libraryCache(userId, 'refs', (skills) => { + const analysis = analyzeLibrary(skills); + const rows = require('./db').prepare(` + SELECT s.slug, v.version FROM skill_versions v JOIN skills s ON s.id = v.skill_id + WHERE s.user_id = ? AND s.is_deleted = 0 AND v.version IS NOT NULL + `).all(userId); + analysis.historyVersions = new Map(); + for (const { slug, version } of rows) { + if (!analysis.historyVersions.has(slug)) analysis.historyVersions.set(slug, new Set()); + analysis.historyVersions.get(slug).add(String(version)); + } + return analysis; + }); +} + +// 某个技能的分析:默认取库里那份;传入 content(例如待审更新、刚推送还没进库的内容)时按这份内容分析 +function entryFor(userId, skill, content) { + const analysis = libraryAnalysis(userId); + const inLibrary = analysis.bySlug.get(skill.slug); + const entry = content === undefined && inLibrary ? inLibrary : analyzeSkill({ ...skill, content: content ?? skill.content }, analysis.library); + return { analysis, entry }; +} + +function lintFor(userId, skill, { username = null, content } = {}) { + const { analysis, entry } = entryFor(userId, skill, content); + return lintSkill(analysis, entry, { username }); +} + +module.exports = { + analyzeLibrary, analyzeSkill, lintSkill, sharedStepsOf, chainFrom, isMetaSkill, isLocal, bodyReferences, structuralFlags, + libraryAnalysis, entryFor, lintFor, splitBody, pinAvailable, EXTRACT_MIN, +}; diff --git a/server/skillSteps.js b/server/skillSteps.js new file mode 100644 index 0000000..81166f0 --- /dev/null +++ b/server/skillSteps.js @@ -0,0 +1,207 @@ +// 重复的操作:找「几个技能里都有的同一段操作」。两个技能描述完全不同,却都有 ssh → docker exec → bazel build +// 这同一串命令、或同一串文字步骤,这正是该提炼成元技能(或改为引用已有元技能)的信号。两种来源: +// +// 命令:shell 代码块里的命令,归一化后精确比对——去掉提示符与注释、合并续行,引号串 / IP / 路径 / 变量 / 数字 +// 换成占位符,保留命令名和参数开关;cd、ls、echo 这类不构成操作的命令不参与比对。 +// 步骤:编号列表、复选框列表里的文字步骤,切词后按重合度模糊比对(措辞略有不同也算同一步)。 +const matter = require('gray-matter'); +const { tokenize } = require('./tokenize'); + +const SHELL_LANGS = new Set(['bash', 'sh', 'shell', 'zsh', 'console', 'shell-session']); +const TRIVIAL = new Set(['cd', 'ls', 'echo', 'pwd', 'export', 'mkdir', 'sleep', 'clear', 'true', 'set', 'source', 'which', 'printf', 'cat', 'exit', 'then', 'fi', 'done', 'do', 'else', 'esac']); +const BOUNDARY = '\u0000'; + +// 至少这么多条连续相同的命令才算「同一段操作」 +const MIN_RUN = 3; + +function normalizeToken(token) { + if (/^(['"]).*\1$/.test(token)) return 'S'; + if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(token)) return 'IP'; + if (/^[\w.-]+@[\w.-]+(:\S*)?$/.test(token)) return 'U@H'; + if (/^(\$\{?\w+\}?|<[^>]+>|\{\{[^}]+\}\})$/.test(token)) return 'V'; + if (/^-{1,2}[\w-]+=/.test(token)) return `${token.split('=')[0]}=V`; + if (token.includes('/') && !/^-/.test(token)) return 'P'; + if (/^\d+$/.test(token)) return 'N'; + return token; +} + +// 一行 shell → 归一化后的命令;不构成操作的返回 null +function normalizeCommand(line) { + let s = line.trim().replace(/^\$\s+/, ''); + // 命令以命令名或可执行文件路径开头;以引号、括号、标点开头的是数据或输出 + if (!s || !/^[\w.[/~]/.test(s)) return null; + s = s.replace(/\s+#\s.*$/, ''); + // 引号串先整体替换,免得里面的空格把它拆开 + s = s.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, 'S'); + const tokens = s.split(/\s+/).filter(Boolean); + while (tokens.length && /^(sudo|time|env|\w+=\S*)$/.test(tokens[0])) tokens.shift(); + if (!tokens.length || TRIVIAL.has(tokens[0])) return null; + return tokens.map(normalizeToken).join(' '); +} + +// 技能 → [{ normalized, raw }],代码块之间插入边界,比对时不跨块 +function extractCommands(content) { + let body = String(content || ''); + try { body = matter(body, {}).content; } catch { /* frontmatter 坏了就整段当正文 */ } + const commands = []; + for (const match of body.matchAll(/^(`{3,}|~{3,})[ \t]*([\w-]*)[^\n]*\n([\s\S]*?)^\1[^\n]*$/gm)) { + if (!SHELL_LANGS.has(match[2].toLowerCase())) continue; + const lines = match[3].replace(/\\\n\s*/g, ' ').split('\n'); + let added = false; + let heredocEnd = null; + for (const line of lines) { + // heredoc 的内容是数据(JSON、配置、脚本正文),不是要比对的命令 + if (heredocEnd) { if (line.trim() === heredocEnd) heredocEnd = null; continue; } + const heredoc = /<<-?\s*(['"]?)(\w+)\1/.exec(line); + if (heredoc) heredocEnd = heredoc[2]; + const normalized = normalizeCommand(line); + if (normalized) { commands.push({ normalized, raw: line.trim().replace(/^\$\s+/, '') }); added = true; } + } + if (added) commands.push({ normalized: BOUNDARY, raw: '' }); + } + return commands; +} + +// 多个技能之间共有的命令段。skills: [{ id, commands }] +// 返回 [{ skills: [id…], commands: [原始命令行…] }],按涉及的技能数、命令段长度降序 +function sharedRuns(skills) { + const shingles = new Map(); + for (const skill of skills) { + const c = skill.commands; + for (let i = 0; i + MIN_RUN <= c.length; i += 1) { + const window = c.slice(i, i + MIN_RUN); + if (window.some((x) => x.normalized === BOUNDARY)) continue; + const key = window.map((x) => x.normalized).join('\n'); + if (!shingles.has(key)) shingles.set(key, new Map()); + const positions = shingles.get(key); + if (!positions.has(skill.id)) positions.set(skill.id, i); + } + } + // 同一组技能共有的若干窗口合并成连续的段,取最长的一段展示。一个窗口若被更多技能共有({a,b,c}), + // 它同样属于其中任意两个({a,b}):a、b 共有 4 条而 c 只有其中 3 条时,{a,b} 这一组要能看到完整的 4 条 + const shared = [...shingles.values()].filter((positions) => positions.size >= 2); + const groups = new Map(); + for (const positions of shared) { + const ids = [...positions.keys()].sort((a, b) => a - b); + const key = ids.join(','); + if (!groups.has(key)) groups.set(key, { ids, starts: [] }); + } + for (const group of groups.values()) { + for (const positions of shared) { + if (group.ids.every((id) => positions.has(id))) group.starts.push(positions.get(group.ids[0])); + } + } + const byId = new Map(skills.map((s) => [s.id, s])); + const results = []; + for (const { ids, starts } of groups.values()) { + const sorted = [...new Set(starts)].sort((a, b) => a - b); + let best = null; + let run = { from: sorted[0], to: sorted[0] + MIN_RUN }; + for (const start of sorted.slice(1)) { + if (start <= run.to) run.to = Math.max(run.to, start + MIN_RUN); + else { if (!best || run.to - run.from > best.to - best.from) best = run; run = { from: start, to: start + MIN_RUN }; } + } + if (!best || run.to - run.from > best.to - best.from) best = run; + const commands = byId.get(ids[0]).commands.slice(best.from, best.to).map((x) => x.raw); + results.push({ skills: ids, commands }); + } + // 一段命令同时出现在 {a,b,c} 和 {a,b} 里时,只保留更大的组 + const kept = results.filter((r) => !results.some((o) => o !== r && o.skills.length > r.skills.length + && r.skills.every((id) => o.skills.includes(id)) && o.commands.length >= r.commands.length)); + return kept.sort((a, b) => b.skills.length - a.skills.length || b.commands.length - a.commands.length); +} + +// ——— 文字步骤 ——— +const STEP_MIN_TOKENS = 4; // 词太少的步骤(「回放」「验证」)太泛,不参与比对 +const STEP_MATCH = 0.5; // 两步的词元重合度(Jaccard)达到这个值算同一步 + +function cleanMarkdown(text) { + return text.replace(/`([^`]*)`/g, '$1').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|~~/g, '').trim(); +} + +// 技能 → [{ raw, tokens }];标题、代码块处插入边界(tokens 为 null),比对时不跨越 +function extractProseSteps(content) { + let body = String(content || ''); + try { body = matter(body, {}).content; } catch { /* frontmatter 坏了就整段当正文 */ } + body = body.replace(/^(`{3,}|~{3,}).*\n[\s\S]*?^\1[^\n]*$/gm, '\n#\n'); + const steps = []; + const boundary = () => { if (steps.length && steps[steps.length - 1].tokens) steps.push({ raw: '', tokens: null }); }; + for (const line of body.split('\n')) { + if (/^\s{0,3}#/.test(line)) { boundary(); continue; } + const item = /^\s{0,3}(?:\d+[.)、]|[-*+]\s+\[[ xX]\])\s+(.+)$/.exec(line); + if (!item) continue; + const raw = cleanMarkdown(item[1]); + const tokens = new Set(tokenize(raw)); + steps.push({ raw, tokens: tokens.size >= STEP_MIN_TOKENS ? tokens : new Set() }); + } + return steps; +} + +function jaccard(a, b) { + let shared = 0; + for (const t of a) if (b.has(t)) shared += 1; + return shared / (a.size + b.size - shared); +} + +// 多个技能之间共有的文字步骤段。skills: [{ id, steps }],返回形状与 sharedRuns 相同:[{ skills, commands }] +function sharedProseRuns(skills) { + // 倒排索引找候选:只比较至少共享 2 个不太常见词元的两步,免得两两全比 + const postings = new Map(); + let total = 0; + skills.forEach((skill, si) => skill.steps.forEach((step, i) => { + if (!step.tokens || !step.tokens.size) return; + total += 1; + for (const t of step.tokens) { if (!postings.has(t)) postings.set(t, []); postings.get(t).push([si, i]); } + })); + const common = Math.max(20, total * 0.05); + const matches = new Set(); + const key = (sa, i, sb, j) => `${sa},${i},${sb},${j}`; + skills.forEach((skill, sa) => skill.steps.forEach((step, i) => { + if (!step.tokens || !step.tokens.size) return; + const counts = new Map(); + for (const t of step.tokens) { + const list = postings.get(t); + if (list.length > common) continue; + for (const [sb, j] of list) { + if (sb <= sa) continue; + const k = key(sa, i, sb, j); + counts.set(k, (counts.get(k) || 0) + 1); + } + } + for (const [k, n] of counts) { + if (n < 2) continue; + const [, , sb, j] = k.split(',').map(Number); + if (jaccard(step.tokens, skills[sb].steps[j].tokens) >= STEP_MATCH) matches.add(k); + } + })); + // 沿对角线串成连续的段(第 i 步配第 j 步、第 i+1 步配第 j+1 步……) + const pairRuns = []; + for (const k of matches) { + const [sa, i, sb, j] = k.split(',').map(Number); + if (matches.has(key(sa, i - 1, sb, j - 1))) continue; + let len = 1; + while (matches.has(key(sa, i + len, sb, j + len))) len += 1; + if (len >= MIN_RUN) pairRuns.push({ sa, sb, from: i, to: i + len, fromB: j, toB: j + len }); + } + // 以每个技能为中心,把与它同一段步骤重合的伙伴并成一组:{a,b,c} 都有这几步 + const groups = new Map(); + skills.forEach((skill, center) => { + const mine = pairRuns.flatMap((r) => (r.sa === center ? [{ partner: r.sb, from: r.from, to: r.to }] + : r.sb === center ? [{ partner: r.sa, from: r.fromB, to: r.toB }] : [])); + for (const run of mine) { + const overlapping = mine.filter((o) => Math.min(o.to, run.to) - Math.max(o.from, run.from) >= MIN_RUN); + const ids = [...new Set([skill.id, ...overlapping.map((o) => skills[o.partner].id)])].sort((a, b) => a - b); + const from = Math.max(...overlapping.map((o) => o.from)); + const to = Math.min(...overlapping.map((o) => o.to)); + const groupKey = ids.join(','); + const commands = skill.steps.slice(from, to).map((x) => x.raw); + if (!groups.has(groupKey) || groups.get(groupKey).commands.length < commands.length) groups.set(groupKey, { skills: ids, commands }); + } + }); + const results = [...groups.values()]; + const kept = results.filter((r) => !results.some((o) => o !== r && o.skills.length > r.skills.length + && r.skills.every((id) => o.skills.includes(id)) && o.commands.length >= r.commands.length)); + return kept.sort((a, b) => b.skills.length - a.skills.length || b.commands.length - a.commands.length); +} + +module.exports = { extractCommands, normalizeCommand, sharedRuns, extractProseSteps, sharedProseRuns, MIN_RUN }; diff --git a/server/storage.js b/server/storage.js index b2aab66..281b959 100644 --- a/server/storage.js +++ b/server/storage.js @@ -89,7 +89,7 @@ function moveSkillOnDisk(userId, oldFolder, newFolder, slug) { // 解析 Markdown Frontmatter function parseSkillContent(rawContent) { try { - const parsed = matter(rawContent); + const parsed = matter(rawContent, {}); // options 对象绕过 gray-matter 的内置缓存(见 skillMeta.parseFrontmatter) return { data: parsed.data || {}, content: parsed.content || rawContent, @@ -157,6 +157,37 @@ function createSkillTarGzArchive(userId, folderPath, slug, res) { }); } +// 把一份不在磁盘上的内容(例如历史版本)打包成与 createSkillTarGzArchive 相同布局的 tar.gz:/SKILL.md + 附属文件 +function createTarGzFromContent(slug, content, files, res) { + const os = require('os'); + const { spawn } = require('child_process'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ash-pinned-')); + const cleanup = () => fs.rmSync(tmp, { recursive: true, force: true }); + try { + const dir = resolveInside(tmp, String(slug)); + if (!dir || String(slug).includes('/')) throw new Error('非法的技能路径'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), content, 'utf8'); + for (const file of Array.isArray(files) ? files : []) { + const filePath = file && file.path && file.content !== undefined ? resolveInside(dir, file.path) : null; + if (!filePath) continue; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, file.content, 'utf8'); + } + } catch (err) { + cleanup(); + throw err; + } + const tar = spawn('tar', ['-czf', '-', '-C', tmp, String(slug)]); + tar.stdout.pipe(res); + tar.stderr.on('data', (d) => console.error('tar stderr:', d.toString())); + tar.on('close', cleanup); + tar.on('error', (err) => { + cleanup(); + if (!res.headersSent) res.status(500).send('Tar error: ' + err.message); + }); +} + // 打包为 zip 流 function createSkillArchive(userId, folderPath, slug, res) { const targetDir = skillDir(userId, folderPath, slug); @@ -181,6 +212,7 @@ module.exports = { parseSkillContent, createSkillArchive, createSkillTarGzArchive, + createTarGzFromContent, getSkillFileTree, getSkillFileContent }; diff --git a/server/templates/agent.md b/server/templates/agent.md index 8765250..f2d5028 100644 --- a/server/templates/agent.md +++ b/server/templates/agent.md @@ -15,14 +15,25 @@ token 保存在 `~/.ash/token`,也可以通过环境变量 `ASH_TOKEN` 提供 ## 什么时候查找技能 -- 接到任务时,尤其是**项目特定或不熟悉的流程**(部署、仿真、内部工具、排障手册),先运行 `ash search <关键词>`。一次没搜到就换同义词,中英文都试一下。 -- 可以按标签或目录缩小范围:`ash search <关键词> --tag ops`、`ash list --folder DevOps`。 +- 接到任务时,尤其是**项目特定或不熟悉的流程**(部署、仿真、内部工具、排障手册),先用一两句话描述任务:`ash suggest "<任务描述>"`。它按相关度给出候选并标明「相关 / 可能相关」,没有合适的会直接说没有。 +- 技能库中英混写:描述里**中英文关键名词都写上**更容易命中,例如 `ash suggest "测试挂了找根因 test failure root cause"`。 +- 知道确切关键词时用 `ash search <关键词…>`(多个关键词需同时命中;都不命中时会给出相关候选)。可以按标签或目录缩小范围:`ash search <关键词> --tag ops`、`ash list --folder DevOps`。 - 搜到相关技能后,先用 `ash info ` 看描述、依赖和文件清单,判断是否适用。 - 只需要阅读说明:`ash show `。 - 需要执行技能里的脚本:`ash pull ` 安装(会一并安装 `depends_on` 声明的依赖),然后阅读安装目录下的 `SKILL.md` 并按步骤执行。`SKILL.md` 里的相对路径(例如 `scripts/run.sh`)都相对于该目录。 - 一类任务要用到一组技能时,看看有没有现成的组合:`ash bundles`、`ash bundle <标识或名称>`,然后 `ash pull bundle:<标识或名称>`。 - 没搜到合适的技能就按常规方式完成任务,不要硬套不相关的技能。 +## 用完回报结果 + +每次按一个来自本库的技能完成(或没能完成)任务后,回报一次结果。这是技能库知道哪些技能好用、哪些该修的唯一途径: + +- 走通了:`ash feedback ok` +- 没走通:`ash feedback fail "哪一步、为什么没走通"`——一句具体的原因比一个 fail 有用得多。 +- 已经找到了正确做法:修改本地技能目录后 `ash push <目录> --update`,把修正交回库里。 + +通过 ash 安装的技能,`SKILL.md` 末尾有一行同样的提示。说明里不要写密钥、token 或个人隐私。 + ## 管理本机已装的技能 - `ash installed` 列出本机通过 ash 安装的技能及状态:最新、有更新、本地有修改、远端已删除。`ash outdated` 只列出需要处理的。 @@ -37,7 +48,10 @@ token 保存在 `~/.ash/token`,也可以通过环境变量 `ASH_TOKEN` 提供 推送前: -1. 先用 `ash search` 查重。如果已有相近技能,先 `ash pull ` 拉到本地,在原版基础上修改,再运行 `ash push <目录> --update`。 +1. 先查重:`ash suggest "<这个新技能要解决的问题>"`。如果已有相近技能,先 `ash pull ` 拉到本地,在原版基础上修改,再运行 `ash push <目录> --update`。 + - 新技能里有些步骤已经是别的技能(尤其是元技能)的内容时,不要复制那些步骤,改为引用(见下文「技能之间的引用」)。 + - 推送结果里提示「库中已有相近技能」或「与元技能相同的命令」时,认真判断:是补充就改为更新已有技能,确实不同就在 description 里写清楚区别。 + - 推送前运行 `ash lint `(已在库里的技能)自查:双语描述、引用写法、元技能契约、能不能被 Agent 调用。 2. 不加 `--update` 时,如果同名 slug 已存在,推送会被拒绝(409),不会覆盖别人的版本。 3. 新技能和对已有技能的更新都会进入「待审核」,人工采纳后其他 Agent 才能拉到。你自己需要立刻使用新技能时,可以运行 `ash pull --pending`。 4. 推送后把服务端返回的结果(是否待审核、有无警告)如实告诉用户。 @@ -51,9 +65,10 @@ token 保存在 `~/.ash/token`,也可以通过环境变量 `ASH_TOKEN` 提供 --- name: my-skill # 必填。英文 slug,只含小写字母/数字/-/_,也是安装目录名 description: 一句话说明「什么时候应该使用这个技能」,Agent 靠它判断是否调用 +description_en: Use when … # 另一种语言的描述(description 是英文时写 description_zh) tags: [ops, debugging] version: 1.0.0 -depends_on: [other-skill] # 可选,依赖的其他技能 slug +depends_on: [other-skill] # 可选,引用的其他技能,写法见下文 --- # 人类可读的标题 @@ -69,30 +84,85 @@ depends_on: [other-skill] # 可选,依赖的其他技能 slug 怎样确认完成,以及常见失败的处理办法。 ``` +- **描述要中英双语**:`description` 用你习惯的语言写,再用 `description_en` 或 `description_zh` 补另一种(主描述本身中英混写也可以)。中文任务和英文任务才都能找到它,查重也才能跨语言对上。正文不用写两份:两份正文要同步维护、迟早不一致,而读正文的 Agent 中英文都懂。 + - 给已有技能补双语描述:`ash lint --only bilingual` 列出缺的,逐个 `ash pull` 后加上 `description_en` / `description_zh` 再 `ash push --update`。**只改双语字段的更新直接发布、不用等审核**(Agent 框架不读这些字段,不影响 Agent 的行为)。 +- frontmatter 是 YAML:值里含有「: 」时要用引号括起来(`description_en: "Use when: …"`),否则整段 frontmatter 解析失败,名称、描述、依赖全部丢失。 - 脚本放在 `scripts/`,参考资料放在 `references/`,正文里用相对路径引用。 - 只能包含文本文件(.md .sh .py .js .json .yaml 等)。单个文件不超过 512KB,总量不超过 4MB,文件数不超过 200 个;超出限制的文件会被跳过,并在推送结果中提示。 - 推送时服务端会做安全扫描。命中高危模式(反弹 shell、混淆的 eval 等)的技能一律进入人工审核。 +## 技能之间的引用 + +一段操作已经有技能(尤其是元技能)负责时,引用它,不要把它的步骤抄进来——抄过来的两份会漂移,一个坑就要修两处。 + +**怎么写**(两处都要写): + +1. frontmatter 的 `depends_on` 声明它。`ash pull` 据此把它一并装在本技能旁边;没声明的引用在别的机器上找不到。 +2. 正文在用到它的地方写「用元技能 ``」,后面只写本技能特有的参数和坑: + +```markdown +## 编译 +> 用元技能 `dev-container-build` +> - target: `//app:main` +> - **本技能特有**:必须加 `--build_tag_filters=sim`,否则会静默用旧二进制 +``` + +也可以写成链接 `[dev-container-build](../dev-container-build/SKILL.md)`。**不要写绝对路径**(`~/.claude/skills/...`),换一台机器或换一个 Agent 就找不到了。 + +**按技能干活时,遇到「用元技能 `x`」或指向 `../x/SKILL.md` 的链接**,按这个顺序找到它: + +1. 在 Claude Code 里:直接调用名为 `x` 的技能; +2. 其他 Agent(或 Claude Code 里调用不到时):读本技能旁边的 `../x/SKILL.md`——`ash pull` 把引用的技能装在同一个目录下,已装的 `SKILL.md` 末尾也列出了这些链接; +3. 本机没有:运行 `ash pull x`(在 Claude Code 里默认装到 `~/.claude/skills`),再按 1 或 2;只想看内容用 `ash show x`。 + +读完 `x` 按它的步骤做,再回到本技能继续。 + +**装到哪里决定了哪个 Agent 看得到**:Claude Code 只从 `~/.claude/skills`(和项目的 `.claude/skills`)加载技能,Codex 从 `~/.agents/skills`,Hermes 从 `~/.hermes/skills`。一台机器上同时用几个 Agent 时,用 `ash pull --agent all` 装进每个 Agent 的目录(或设置 `ASH_AGENT=all` 作为默认)。`ash doctor` 检查本机:引用的技能在不在旁边、装的目录有没有 Agent 加载;`ash doctor --fix` 自动补装缺的。 + +**引用写法**:`slug`(本库)、`@账号/slug`(指定账号,为团队库 / 技能广场预留,目前只能指向自己的账号)、`slug@1.2.0`(固定版本:`ash pull` 从历史里装那一版,`ash pull --all` 不会升级它;版本号由 frontmatter 的 `version` 决定,改了内容要记得升版本号)。 + +**让引用的技能调用得到**:被引用的技能不要设置 `disable-model-invocation: true`(Claude 就不能调用它了);`name` 与 slug 保持一致、只用小写字母数字和连字符;description 不超过 1024 个字符。`ash lint` 会检查这些。 + +**引用的代价**:每多一层引用,Agent 就要多读一个文件、多一个可能断的地方。引用链不要超过两层;一小段、只有一两处用到的步骤,留在原地比抽出去好。 + +### 什么时候提炼元技能 + +元技能是被别的技能在流程中引用的基础动作:调用方只说「要做什么」,元技能负责「怎么做」。五条都满足才抽: + +1. 已经有 **≥3 个技能**在重复它(真去数,不是「将来可能有」)——第 1 次写在原地,第 2 次忍住,第 3 次才抽; +2. 与业务无关,换个项目也成立; +3. 输入输出一句话说得清; +4. 不含业务判断(「怎么编译」是元技能,「编译结果对不对」不是); +5. 改一处,所有调用方都受益。 + +`ash lint` 和推送结果会提示「有 N 条命令(或 N 个步骤)与 a、b 相同」——shell 命令和编号步骤都会比对,那是候选;抽完要**立刻把调用方改成引用它**,否则只是多了一个没人用的技能。 + +元技能的写法:description 以「【元技能】」开头,写明被引用和被直接要求两种触发场景;正文必须有 **`## 契约`**(输入 / 输出 / 前置 / 失败)和 **`## 被谁引用`** 两节;不要加 `disable-model-invocation: true`(那会让别的技能引用不到它)。 + ## 命令速查 ``` +ash suggest "<任务描述>" [--limit N] [--json] ash search <关键词…> [--tag T] [--folder F] [--json] ash list [--tag T] [--folder F] [--json] ash info ash show [--version ID] [--pending] ash versions +ash lint [slug] [--only CODE] [--json] ash bundles | ash bundle <标识或名称> -ash pull [--agent claude|codex|hermes|dsh] [--dir PATH] [--pending] [--force] [--no-deps] +ash pull [@版本] [--agent claude|codex|hermes|dsh|all] [--dir PATH] [--pending] [--force] [--no-deps] ash pull bundle:<标识或名称> ash pull --all [--dir PATH] [--force] ash installed | ash outdated [--dir PATH] ash remove [--dir PATH] [--force] +ash doctor [--fix] [--dir PATH] ash push <技能目录|SKILL.md> [--update] [--folder PATH] [--json] ash mine | ash withdraw +ash feedback ok|fail ["说明"] ash guide ``` -默认安装目录是自动探测的:`$ASH_SKILLS_DIR`,否则依次尝试唯一的 `~/.hermes/profiles/*/skills`、`~/.hermes/skills`、`~/.agents/skills`、`~/.claude/skills`、`~/.dsh/skills`。 +默认安装目录是自动探测的:`$ASH_SKILLS_DIR`;`$ASH_AGENT`(claude / codex / hermes / dsh / all);在 Claude Code 里运行时是 `~/.claude/skills`;否则依次尝试唯一的 `~/.hermes/profiles/*/skills`、`~/.hermes/skills`、`~/.agents/skills`、`~/.claude/skills`、`~/.dsh/skills`。 ## 没有 ash 时的 HTTP 接口 @@ -100,6 +170,7 @@ ash guide `curl -fsSL -H "Authorization: Bearer $ASH_TOKEN" __BASE_URL__/s//install.sh | bash` ``` +GET __BASE_URL__/api/agent/suggest?q=<任务描述>&format=text 按任务描述找技能(不带 format 返回 JSON) GET __BASE_URL__/api/skills?search=<关键词>&tag=&folder=&format=text 搜索(纯文本,每行一个技能) GET __BASE_URL__/s//info 技能信息、依赖与文件清单 GET __BASE_URL__/s/.md SKILL.md(多文件技能末尾附文件清单;?version= 取历史版本) @@ -113,4 +184,6 @@ POST __BASE_URL__/api/agent/push multipart:file= 我的推送及审核状态 POST __BASE_URL__/api/agent/withdraw 撤回待审推送:slug=&terminal=<来源> GET __BASE_URL__/api/agent/revisions?slug=a&slug=b 已装技能比对:每行 slug、状态、修订号、版本 +GET __BASE_URL__/api/agent/lint?slug=&format=text 技能检查(不带 slug 列出全库有问题的技能) +POST __BASE_URL__/api/agent/feedback 用完回报:slug=&outcome=ok|fail¬e=<说明>&terminal=<来源> ``` diff --git a/server/templates/cli.sh b/server/templates/cli.sh index 4453c65..e85fe8e 100644 --- a/server/templates/cli.sh +++ b/server/templates/cli.sh @@ -62,6 +62,43 @@ skill_roots() { } | awk '!seen[$0]++' } +# 本机各个 Agent 加载技能的目录(与服务端生成的多 Agent 安装脚本共用一份定义) +__AGENT_ROOTS_FN__ + +# 某个目录是哪个 Agent 在加载;没有 Agent 从这里加载时输出空 +root_agent() { + case "$1" in + "$HOME/.claude/skills") echo "Claude Code" ;; + "$HOME/.agents/skills") echo "Codex" ;; + "$HOME/.hermes/skills"|"$HOME"/.hermes/profiles/*/skills) echo "Hermes" ;; + "$HOME/.dsh/skills") echo "dsh" ;; + */.claude/skills) echo "Claude Code(项目)" ;; + */.agents/skills) echo "Codex(项目)" ;; + *) echo "" ;; + esac +} + +# 已装技能引用的技能(每行一个 slug 或 slug@版本,跨账号的 @owner/slug 不算)。 +# 新装的技能记在 .ash 的 deps 里;更早装的从 SKILL.md 的 depends_on: [a, b] 读 +deps_of() { + local dir="$1" deps + if grep -q '^deps=' "$dir/.ash" 2>/dev/null; then deps="$(meta "$dir" deps)" + else deps="$(sed -n '1,/^---$/s/^depends_on: *\[\(.*\)\].*/\1/p' "$dir/SKILL.md" 2>/dev/null | tr -d " \"'")"; fi + printf '%s\n' "$deps" | tr ',' '\n' | while IFS= read -r spec; do + case "$spec" in ''|@*) continue ;; esac + echo "$spec" + done +} + +# 引用了、但同一目录里没有的技能:本地 Agent 顺着 ..//SKILL.md 找不到它 +missing_deps() { + local dir="$1" root spec + root="$(dirname "$dir")" + deps_of "$dir" | while IFS= read -r spec; do + if [ ! -f "$root/${spec%%@*}/SKILL.md" ]; then echo "$spec"; fi + done +} + # 已由 ash 安装的技能目录(含 .ash 元数据),每行一个目录 installed_dirs() { local root f @@ -91,6 +128,12 @@ skill_state() { line="$(printf '%s\n' "$revs" | awk -F'\t' -v s="$slug" '$1 == s { print; exit }')" rstatus="$(printf '%s' "$line" | cut -f2)"; rrev="$(printf '%s' "$line" | cut -f3)"; rver="$(printf '%s' "$line" | cut -f4)" if [ "$(meta "$dir" fingerprint)" != "$(ash_fingerprint "$dir")" ]; then modified=1; fi + # 固定版本的技能不跟着库里更新走:ash pull --all 跳过,ash outdated 不列 + if [ -n "$(meta "$dir" pinned)" ]; then + if [ "$modified" = 1 ]; then printf 'modified\t固定在 v%s · 本地有修改\n' "$(meta "$dir" pinned)" + else printf 'pinned\t固定在 v%s(库里当前 v%s)\n' "$(meta "$dir" pinned)" "$rver"; fi + return + fi case "$rstatus" in missing) printf 'missing\t远端已删除\n'; return ;; trashed) printf 'trashed\t远端在废纸篓\n'; return ;; @@ -163,24 +206,32 @@ show_help() { AnotherSkillHub CLI · 服务地址: $SERVER_URL 查找 - ash search <关键词…> [--tag T] [--folder F] [--json] 搜索已发布技能(多个关键词需同时命中) + ash suggest "<任务描述>" [--limit N] [--json] 按任务描述找相关技能(接到任务时先用它) + ash search <关键词…> [--tag T] [--folder F] [--json] 搜索已发布技能(多个关键词需同时命中;都不命中时给出相关候选) ash list [--tag T] [--folder F] [--json] 列出已发布技能 ash info 描述、依赖、文件清单、版本与安装命令 - ash show [--version ID] [--pending] 直接输出 SKILL.md(不安装) + ash show [@版本] [--version ID] [--pending] 直接输出 SKILL.md(不安装);@版本 取那一版 ash versions 历史版本列表 + ash lint [slug] [--only CODE] [--json] 技能检查:双语描述、引用、元技能契约、重复的步骤、能否被 Agent 调用 + --only bilingual 只看缺双语描述的(批量补齐时用;只改双语字段的更新无需审核) 技能组合 ash bundles [--json] 列出全部组合 ash bundle <标识或名称> 查看组合成员 安装与本地管理 - ash pull [--agent claude|codex|hermes|dsh] [--dir PATH] [--pending] [--force] [--no-deps] + ash pull [@版本] [--agent claude|codex|hermes|dsh|all] [--dir PATH] [--pending] [--force] [--no-deps] 安装技能及其依赖;本地改过的同名目录先备份到 ~/.ash/backups(--force 不备份) + --agent all 装进本机每个 Agent 的目录;slug@1.2.0 安装历史里的那一版并固定 ash pull bundle:<标识或名称> 一次安装整个技能组合 ash pull --all [--dir PATH] [--force] 更新所有过期的已装技能(本地改过的跳过,除非 --force) ash installed [--dir PATH] 本机已装技能及状态(最新 / 有更新 / 本地有修改 / 远端已删除) ash outdated [--dir PATH] 只列出需要处理的已装技能 ash remove [--dir PATH] [--force] 卸载;本地改过的先备份(--force 直接删除) + ash doctor [--fix] [--dir PATH] 检查本机各 Agent 能不能用上已装的技能:引用的技能在不在旁边、装的目录有没有 Agent 加载;--fix 自动补装 + +反馈 + ash feedback ok|fail ["说明"] [--dir PATH] 用完技能后回报结果;失败时写一句哪一步、为什么没走通 推送与审核 ash push <技能目录|SKILL.md> [--update] [--folder PATH] [--json] @@ -199,7 +250,7 @@ AnotherSkillHub CLI · 服务地址: $SERVER_URL ash update 更新 ash 自身 完整文档:$SERVER_URL/docs -环境变量:ASH_SERVER_URL 服务地址;ASH_TOKEN API token(默认读 ~/.ash/token);ASH_SKILLS_DIR 默认安装目录;ASH_BIN_DIR ash 安装位置;ASH_TERMINAL 推送来源名(默认 hostname) +环境变量:ASH_AGENT 默认安装到哪个 Agent(claude|codex|hermes|dsh|all);ASH_SERVER_URL 服务地址;ASH_TOKEN API token(默认读 ~/.ash/token);ASH_SKILLS_DIR 默认安装目录(在 Claude Code 里运行时默认 ~/.claude/skills);ASH_BIN_DIR ash 安装位置;ASH_TERMINAL 推送来源名(默认 hostname) EOF } @@ -224,10 +275,10 @@ case "$cmd" in pull) [ -n "${1:-}" ] || die "请提供技能标识 (slug)、组合 (bundle:<标识或名称>) 或 --all" target="$1"; shift - args=(); dir=""; force=0 + args=(); dir=""; force=0; agent="" while [ "$#" -gt 0 ]; do case "$1" in - --agent) [ -n "${2:-}" ] || die "--agent 需要参数"; args+=(--data-urlencode "agent=$2"); shift 2 ;; + --agent) [ -n "${2:-}" ] || die "--agent 需要参数"; agent="$2"; args+=(--data-urlencode "agent=$2"); shift 2 ;; --dir) [ -n "${2:-}" ] || die "--dir 需要参数"; dir="$2"; args+=(--data-urlencode "dir=$2"); shift 2 ;; --pending) args+=(--data-urlencode "pending=1"); shift ;; --force) force=1; args+=(--data-urlencode "force=1"); shift ;; @@ -235,6 +286,11 @@ case "$cmd" in *) die "未知参数: $1" ;; esac done + # 没指定 --agent / --dir / ASH_SKILLS_DIR 时用 ASH_AGENT(例如 all:一台机器上同时用 Claude Code、Codex、Hermes)。 + # ash pull --all 更新时总是装回原来的目录,不受它影响 + if [ -z "$agent" ] && [ -z "$dir" ] && [ -z "${ASH_SKILLS_DIR:-}" ] && [ -n "${ASH_AGENT:-}" ] && [ "$target" != "--all" ]; then + args+=(--data-urlencode "agent=$ASH_AGENT") + fi if [ "$target" = "--all" ]; then dirs="$(installed_dirs "$dir")" [ -n "$dirs" ] || { echo "本机没有通过 ash 安装的技能"; exit 0; } @@ -243,7 +299,7 @@ case "$cmd" in while IFS= read -r d; do slug="$(meta "$d" slug)" state="$(skill_state "$d" "$revs")"; code="${state%%$'\t'*}"; note="${state#*$'\t'}" - extra=() + extra=(--data-urlencode "reason=update") if [ "$force" = 1 ]; then extra+=(--data-urlencode "force=1"); fi if [ "$(meta "$d" pending)" = 1 ]; then extra+=(--data-urlencode "pending=1"); fi case "$code" in @@ -254,7 +310,7 @@ case "$cmd" in *) continue ;; esac # 装回原来的目录:把它的上级目录作为安装根目录 - if ASH_SKILLS_DIR="$(dirname "$d")" run_install "$SERVER_URL/s/$slug/install.sh" "${extra[@]+"${extra[@]}"}"; then updated=$((updated + 1)); else failed=$((failed + 1)); fi + if ASH_SKILLS_DIR="$(dirname "$d")" run_install "$SERVER_URL/s/$slug/install.sh" "${extra[@]}"; then updated=$((updated + 1)); else failed=$((failed + 1)); fi done < --agent 装到 Agent 的目录"; problems=$((problems + 1)); fi + for f in "$root"/*/.ash; do + [ -f "$f" ] || continue + d="$(dirname "$f")"; slug="$(meta "$d" slug)"; total=$((total + 1)) + while IFS= read -r spec; do + [ -n "$spec" ] || continue + dep="${spec%%@*}" + if [ -f "$root/$dep/SKILL.md" ]; then + if sed -n '1,/^---$/p' "$root/$dep/SKILL.md" | grep -qiE '^disable-model-invocation: *(true|yes|on|1)'; then + echo " ⚠️ $slug 引用的 $dep 设置了 disable-model-invocation:Claude Code 里调用不到它(仍可读 ../$dep/SKILL.md)" + problems=$((problems + 1)) + fi + continue + fi + if [ "$fix" = 1 ]; then + extra=(--data-urlencode "reason=dependency") + case "$spec" in *@*) extra+=(--data-urlencode "pin=${spec#*@}") ;; esac + if ASH_SKILLS_DIR="$root" run_install "$SERVER_URL/s/$dep/install.sh" "${extra[@]}" >/dev/null 2>&1; then + echo " ✓ 已把 $slug 引用的 $dep 装到旁边" + else + echo " ✗ $slug 引用的 $dep 安装失败(库里没有、未发布或网络错误)"; problems=$((problems + 1)) + fi + else + echo " ✗ $slug 引用的 $dep 不在同一目录,本地 Agent 找不到它(ash doctor --fix 自动补装)"; problems=$((problems + 1)) + fi + done </dev/null 2>&1; then + echo "⚠️ 当前在 Claude Code 里,但 ~/.claude/skills 没有 ash 装的技能:Claude Code 只从那里加载(ash pull --agent claude,或 --agent all)" + problems=$((problems + 1)) + fi + if [ "$problems" = 0 ]; then echo "✅ 没有发现问题"; else echo "发现 $problems 个问题"; exit 1; fi + ;; list) parse_filters "$@" http "${filter_args[@]}" "$SERVER_URL/api/skills" @@ -328,13 +445,50 @@ EOF [ "${#words[@]}" -gt 0 ] || die "请提供搜索关键词" http "${filter_args[@]}" --data-urlencode "search=${words[*]}" "$SERVER_URL/api/skills" ;; + suggest) + args=(-G); json=0; words=() + while [ "$#" -gt 0 ]; do + case "$1" in + --json) json=1; shift ;; + --limit) [ -n "${2:-}" ] || die "--limit 需要参数"; args+=(--data-urlencode "limit=$2"); shift 2 ;; + *) words+=("$1"); shift ;; + esac + done + [ "${#words[@]}" -gt 0 ] || die '请描述要做的任务,例如 ash suggest "把感知回放结果叠到原图上验证"' + if [ "$json" = 0 ]; then args+=(--data-urlencode "format=text"); fi + http "${args[@]}" --data-urlencode "q=${words[*]}" "$SERVER_URL/api/agent/suggest" + ;; + feedback) + [ -n "${1:-}" ] && [ -n "${2:-}" ] || die '用法: ash feedback ok|fail ["说明"]' + slug="$1"; outcome="$2"; shift 2; valid_slug "$slug" + case "$outcome" in ok|fail) ;; *) die "结果只能是 ok 或 fail" ;; esac + dir=""; note=() + while [ "$#" -gt 0 ]; do + case "$1" in + --dir) [ -n "${2:-}" ] || die "--dir 需要参数"; dir="$2"; shift 2 ;; + *) note+=("$1"); shift ;; + esac + done + # 反馈对应本机已装的版本:从 .ash 取修订号(没装过、只用 ash show 读过时由服务端按当前版本算) + revision="" + while IFS= read -r d; do + [ -n "$d" ] || continue + if [ "$(meta "$d" slug)" = "$slug" ]; then revision="$(meta "$d" revision)"; break; fi + done </dev/null || true)}" @@ -21,9 +26,11 @@ if [ -n "$ASH_TOKEN" ]; then AUTH=(-H "Authorization: Bearer $ASH_TOKEN"); fi echo "📦 AnotherSkillHub: 正在安装技能 [$SKILL_NAME]" -# 自动探测:ASH_SKILLS_DIR > 唯一的 hermes profile > 已存在的常见目录 > ~/.agents/skills +# 自动探测:ASH_SKILLS_DIR > 在 Claude Code 里运行时装到 ~/.claude/skills > 唯一的 hermes profile > 已存在的常见目录 > ~/.agents/skills +# Claude Code 只从 ~/.claude/skills(和项目的 .claude/skills)加载技能:装到别处它既看不到这个技能,也按名字调用不到它引用的技能 detect_root() { if [ -n "${ASH_SKILLS_DIR:-}" ]; then echo "$ASH_SKILLS_DIR"; return; fi + if [ "${CLAUDECODE:-}" = "1" ]; then echo "$HOME/.claude/skills"; return; fi if [ -d "$HOME/.hermes/profiles" ]; then set -- "$HOME"/.hermes/profiles/*/skills if [ "$#" -eq 1 ] && [ -d "$1" ]; then echo "$1"; return; fi @@ -61,17 +68,25 @@ STAGING="$(mktemp -d "$SKILLS_ROOT/.ash-staging.XXXXXX")" TMP_ARCHIVE="$(mktemp "${TMPDIR:-/tmp}/ash-pull.XXXXXX")" trap 'rm -rf "$STAGING" "$TMP_ARCHIVE"' EXIT # 归档顶层带 / 目录,--strip-components=1 去掉 -if curl -fsSL ${AUTH[@]+"${AUTH[@]}"} "$BASE_URL/s/$SKILL_NAME/archive.tar.gz?pending=$PENDING" -o "$TMP_ARCHIVE"; then +# reason / terminal 让服务端记下「装到了哪台机器上」(安装、依赖、更新分开统计) +if curl -fsSL -G ${AUTH[@]+"${AUTH[@]}"} --data-urlencode "pending=$PENDING" --data-urlencode "reason=$REASON" \ + --data-urlencode "terminal=$TERMINAL" --data-urlencode "pin=$PIN" "$BASE_URL/s/$SKILL_NAME/archive.tar.gz" -o "$TMP_ARCHIVE"; then tar -xzf "$TMP_ARCHIVE" -C "$STAGING" --strip-components=1 echo "✓ 完整技能包下载成功" else echo "⚠️ 完整技能包下载失败,改为只拉取 SKILL.md" >&2 - curl -fsSL ${AUTH[@]+"${AUTH[@]}"} "$BASE_URL/s/$SKILL_NAME.md?raw=1&pending=$PENDING" -o "$STAGING/SKILL.md" + curl -fsSL -G ${AUTH[@]+"${AUTH[@]}"} --data-urlencode "raw=1" --data-urlencode "pending=$PENDING" --data-urlencode "pin=$PIN" \ + "$BASE_URL/s/$SKILL_NAME.md" -o "$STAGING/SKILL.md" fi rm -f "$STAGING/.ash" if [ -d "$STAGING/scripts" ]; then chmod +x "$STAGING/scripts"/* 2>/dev/null || true fi +# 末尾附一段说明:引用的技能在本机哪里、用完怎么反馈。Agent 每次按技能干活都会读 SKILL.md,这是告诉它的唯一地方。 +# 推送回库时服务端会去掉这段;ASH_FEEDBACK_HINT=0 可不加 +if [ "${ASH_FEEDBACK_HINT:-1}" != "0" ] && [ -f "$STAGING/SKILL.md" ]; then + printf '\n%s\n%s\n' "$FOOTER_MARK" "$FOOTER" >> "$STAGING/SKILL.md" +fi # 覆盖前保护:本地改过的、或不是 ash 安装的同名目录先备份到 ~/.ash/backups(--force 跳过备份) if [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then @@ -98,6 +113,9 @@ mv "$STAGING" "$INSTALL_DIR" printf 'revision=%s\n' "$REVISION" printf 'version=%s\n' "$VERSION" printf 'pending=%s\n' "$PENDING" + printf 'pinned=%s\n' "$PIN" + # 引用的技能(ash doctor 据此检查它们是否都在同一目录) + printf 'deps=%s\n' "$(IFS=,; echo "${DEPS[*]+"${DEPS[*]}"}")" printf 'fingerprint=%s\n' "$(ash_fingerprint "$INSTALL_DIR")" printf 'installed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "$INSTALL_DIR/.ash" @@ -107,17 +125,26 @@ echo "✅ 技能 [$SKILL_NAME] v$VERSION 已安装到 $INSTALL_DIR" # 依赖:装到同一目录;已安装的不动;ASH_DEPS_SEEN 防止循环依赖 if [ "$NODEPS" != "1" ] && [ "${#DEPS[@]}" -gt 0 ]; then export ASH_DEPS_SEEN="${ASH_DEPS_SEEN:-},$SKILL_NAME," - for dep in "${DEPS[@]}"; do + for spec in "${DEPS[@]}"; do + # 依赖可以固定版本:slug@1.2.0 + dep="${spec%%@*}"; dep_pin="" + case "$spec" in *@*) dep_pin="${spec#*@}" ;; esac case "$ASH_DEPS_SEEN" in *",$dep,"*) continue ;; esac if [ -f "$SKILLS_ROOT/$dep/.ash" ]; then - echo "↳ 依赖 $dep 已安装,跳过" + have="$(sed -n 's/^version=//p' "$SKILLS_ROOT/$dep/.ash")" + if [ -n "$dep_pin" ] && [ "$have" != "$dep_pin" ]; then + echo "⚠️ 依赖 $dep 已安装 v$have,本技能固定要求 v$dep_pin,没有覆盖(需要时 ash pull $dep@$dep_pin)" >&2 + else + echo "↳ 依赖 $dep 已安装,跳过" + fi continue fi - echo "↳ 安装依赖 $dep" - if ! dep_script="$(curl -fsS ${AUTH[@]+"${AUTH[@]}"} "$BASE_URL/s/$dep/install.sh")" || ! printf '%s\n' "$dep_script" | ASH_SKILLS_DIR="$SKILLS_ROOT" bash; then + echo "↳ 安装依赖 $spec" + if ! dep_script="$(curl -fsS -G ${AUTH[@]+"${AUTH[@]}"} --data-urlencode "reason=dependency" --data-urlencode "pin=$dep_pin" "$BASE_URL/s/$dep/install.sh")" \ + || ! printf '%s\n' "$dep_script" | ASH_SKILLS_DIR="$SKILLS_ROOT" bash; then echo "⚠️ 依赖 $dep 安装失败(不存在、未发布或网络错误),请手动处理" >&2 fi done fi -echo " 下一步:阅读 $INSTALL_DIR/SKILL.md 并按其步骤执行" +echo " 下一步:阅读 $INSTALL_DIR/SKILL.md 并按其步骤执行;用完运行 ash feedback $SKILL_NAME ok|fail" diff --git a/server/tests/helpers.js b/server/tests/helpers.js index c7c4b4c..9e592c5 100644 --- a/server/tests/helpers.js +++ b/server/tests/helpers.js @@ -6,6 +6,10 @@ const os = require('node:os'); const path = require('node:path'); const { setTimeout: delay } = require('node:timers/promises'); +// 在 Claude Code 里跑测试时会继承 CLAUDECODE=1,安装目录会被自动改到 ~/.claude/skills; +// 测试一律从「普通终端」出发,需要 Claude Code 行为的用例自己显式设置 +delete process.env.CLAUDECODE; + // 服务全部需要登录:startServer 会注册一个测试账号,之后发往该服务的 fetch 自动带上它的 token, // 并写入 ASH_TOKEN,让测试里启动的 ash CLI / 安装脚本继承。要测未登录的行为,用 rawFetch。 const rawFetch = globalThis.fetch; diff --git a/server/tests/references.test.js b/server/tests/references.test.js new file mode 100644 index 0000000..3b6e3c7 --- /dev/null +++ b/server/tests/references.test.js @@ -0,0 +1,244 @@ +// 技能之间的引用、元技能检查、重复的步骤、双语描述:纯函数规则 + 端到端(推送提示、ash lint、安装后本地能找到被引用的技能) +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { startServer } = require('./helpers'); + +const tmpDir = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +process.env.DATA_DIR ||= tmpDir('ash-refs-db-'); +const { parseReference, declaredDependencies, normalizeSkillMeta, missingLanguages } = require('../skillMeta'); +const { normalizeCommand, extractCommands, sharedRuns } = require('../skillSteps'); +const { analyzeLibrary, lintSkill, structuralFlags, analyzeSkill } = require('../skillRefs'); +const { buildIndex, similarInIndex } = require('../skillIndex'); + +const fence = (lines) => `\`\`\`bash\n${lines.join('\n')}\n\`\`\``; +const BUILD = ['ssh dev@10.0.0.8', 'docker exec -it devbox bash', 'bazel build --config=j6 //app:main', 'bazel test //app:all']; +const skill = (id, slug, { description = `${slug} 何时使用`, content = '', status = 'approved', version = '1.0.0', tags = '[]' } = {}) => ({ + id, slug, name: slug, description, status, version, tags, folder_path: 'inbox', content, +}); +const md = (front, body) => `---\n${front}\n---\n\n${body}\n`; + +test('reference syntax: slug, @owner/slug, slug@version; metadata may hold a comma list', () => { + assert.deepEqual(parseReference('verify'), { raw: 'verify', owner: null, slug: 'verify', pin: null }); + assert.deepEqual(parseReference('@team/verify@2.0.1'), { raw: '@team/verify@2.0.1', owner: 'team', slug: 'verify', pin: '2.0.1' }); + assert.equal(parseReference('not a slug'), null); + assert.deepEqual(declaredDependencies({ metadata: { 'depends-on': 'a, b' }, depends_on: ['c'] }), ['c', 'a', 'b']); +}); + +test('bilingual descriptions: the other language is asked for, mixed descriptions already count', () => { + assert.deepEqual(missingLanguages('遇到原因不明的 bug 时使用'), ['en']); + assert.deepEqual(missingLanguages('Use when a test fails for no clear reason'), ['zh']); + assert.deepEqual(missingLanguages('【元技能】验收纪律。Use when deciding whether a run passed'), []); + assert.deepEqual(missingLanguages('遇到原因不明的 bug 时使用', { description_en: 'Use when a bug has no clear cause' }), []); + const meta = normalizeSkillMeta(md('name: a\ndescription: 遇到原因不明的 bug 时使用', '# A')); + assert.ok(meta.warnings.some((w) => w.includes('description_en'))); + const both = normalizeSkillMeta(md('name: a\ndescription: 遇到原因不明的 bug 时使用\nmetadata:\n description-en: Use when a bug has no clear cause', '# A')); + assert.ok(!both.warnings.some((w) => w.includes('description_en')), 'metadata keys (string values, per the Agent Skills spec) are accepted'); +}); + +test('bilingual metadata lets a Chinese skill and its English twin be found as similar', () => { + const filler = ['日报 周报 汇总', 'deploy frontend preview', '数据库 迁移 回滚', 'image generation prompt', '飞书 日历 会议', 'kubernetes pod logs'] + .map((d, i) => skill(10 + i, `filler-${i}`, { description: d, content: md(`description: ${d}`, `# ${d}`) })); + const zh = (en) => skill(1, 'root-cause', { + description: '遇到原因不明的故障或测试失败,动手修之前先复现、定位、解释根因', + content: md(`description: 遇到原因不明的故障或测试失败,动手修之前先复现、定位、解释根因${en ? '\ndescription_en: "Use when a test fails or a failure has no clear cause, before fixing: reproduce, localize, explain the root cause"' : ''}`, + '# 四步根因排查\n\n## 复现\n\n## 定位\n\n## 解释'), + }); + const english = skill(2, 'debugging-recovery', { + description: 'Use when a test fails or behavior is unexpected: reproduce, localize and explain the root cause before fixing', + content: md('description: Use when a test fails or behavior is unexpected', '# Debugging and recovery\n\n## Reproduce\n\n## Localize the root cause\n\n## Fix'), + }); + const score = (withEn) => similarInIndex(buildIndex([zh(withEn), english, ...filler]), english, { minSimilarity: 0 }) + .find((m) => m.skill.slug === 'root-cause')?.similarity || 0; + assert.ok(score(false) < 0.25, `without a translation the pair is invisible (${score(false)})`); + assert.ok(score(true) >= 0.25, `with description_en it is flagged as similar (${score(true)})`); +}); + +test('command normalization keeps the operation and drops the caller-specific details', () => { + assert.equal(normalizeCommand('$ sudo bazel build --config=j6_irs2 //simapp/slave:perceptor'), 'bazel build --config=V P'); + assert.equal(normalizeCommand('ssh liukun@100.112.81.111 "docker ps"'), 'ssh U@H S'); + assert.equal(normalizeCommand('cd /tmp'), null, 'trivial commands are not operations'); + assert.equal(normalizeCommand('"permission": "ask",'), null, 'data lines are not commands'); + const commands = extractCommands(`${fence(['cat > x.json < c.raw).map((c) => c.normalized), ['jq . x.json'], 'heredoc bodies and non-shell blocks are skipped'); +}); + +test('shared runs are found across skills, keeping the widest group', () => { + const a = { id: 1, commands: extractCommands(fence(BUILD)) }; + const b = { id: 2, commands: extractCommands(fence(['echo start', ...BUILD.map((l) => l.replace('j6', 'x86').replace('//app', '//other'))])) }; + const c = { id: 3, commands: extractCommands(fence(BUILD.slice(0, 3))) }; + const d = { id: 4, commands: extractCommands(fence(['git status', 'git diff', 'git log'])) }; + const runs = sharedRuns([a, b, c, d]); + assert.deepEqual(runs.map((r) => r.skills), [[1, 2, 3], [1, 2]]); + assert.equal(runs[1].commands.length, 4); +}); + +test('lint: references, meta-skill contract, copied steps and extraction candidates', () => { + const metaBody = (extra = '') => `# 容器编译\n\n## 契约\n输入:target\n输出:构建是否成功\n\n## 怎么用\n\n${fence(BUILD)}\n${extra}`; + const library = [ + skill(1, 'dev-build', { description: '【元技能】在开发容器里编译。Use when building inside the dev container', content: md('description: x', metaBody('\n## 被谁引用\n- `replay`\n- `ghost-caller`')) }), + skill(2, 'replay', { content: md('depends_on: [dev-build]', `# 回放\n\n> 用元技能 \`dev-build\`\n\n${fence(BUILD)}`) }), + skill(3, 'sloppy', { content: md('depends_on: [missing-skill, "@bob/shared", "dev-build@0.9.0", "bad ref!"]', '# x\n\n见 [chain-a](~/.claude/skills/chain-a/SKILL.md)\n\n> 用元技能 `dev-build`') }), + skill(4, 'chain-a', { content: md('depends_on: [chain-b]', '# a') }), + skill(5, 'chain-b', { content: md('depends_on: [chain-c]', '# b') }), + skill(6, 'chain-c', { content: md('depends_on: [chain-d]', '# c') }), + skill(7, 'chain-d', { content: md('description: d', '# d') }), + skill(8, 'loop-a', { content: md('depends_on: [loop-b]', '# a') }), + skill(9, 'loop-b', { content: md('depends_on: [loop-a]', '# b') }), + ...[10, 11, 12].map((id) => skill(id, `git-${id}`, { content: md('description: g', fence(['git fetch origin', 'git rebase origin/main', 'git push --force-with-lease'])) })), + skill(13, 'bare-meta', { description: '【元技能】什么也没写', content: md('description: x', '# 空') }), + ]; + const analysis = analyzeLibrary(library); + const lint = (slug) => lintSkill(analysis, analysis.bySlug.get(slug), { username: 'me' }); + const codes = (slug) => lint(slug).map((i) => i.code); + + assert.deepEqual(analysis.dependents.get('dev-build').sort(), ['replay', 'sloppy']); + assert.ok(codes('replay').includes('copies-meta'), 'declaring the meta skill is not enough when its steps are copied too'); + assert.match(lint('replay').find((i) => i.code === 'copies-meta').msg, /用元技能 `dev-build`/); + + const sloppy = lint('sloppy'); + for (const code of ['missing-ref', 'foreign-ref', 'pin-missing', 'invalid-ref', 'undeclared-ref', 'absolute-link']) { + assert.ok(sloppy.some((i) => i.code === code), `sloppy should report ${code}`); + } + assert.ok(!sloppy.some((i) => i.code === 'undeclared-ref' && i.msg.includes('dev-build')), 'a pinned declaration still declares the skill'); + assert.ok(lintSkill(analysis, analysis.bySlug.get('sloppy'), { username: 'bob' }).every((i) => i.code !== 'foreign-ref'), '@own-name/slug is local'); + + assert.match(lint('chain-a').find((i) => i.code === 'deep-chain').msg, /chain-a → chain-b → chain-c → chain-d/); + assert.ok(!codes('chain-b').includes('deep-chain'), 'two levels are fine'); + assert.ok(codes('loop-a').includes('cycle')); + + const meta = lint('dev-build'); + assert.match(meta.find((i) => i.code === 'meta-used-by').msg, /漏了 sloppy.*ghost-caller 其实没有引用它/); + assert.ok(!meta.some((i) => i.code === 'meta-contract')); + assert.deepEqual(codes('bare-meta').filter((c) => c.startsWith('meta-')).sort(), ['meta-contract', 'meta-few-callers', 'meta-used-by']); + + assert.ok(codes('git-10').includes('extract-candidate'), 'three skills repeating the same operation'); + const broken = analyzeLibrary([skill(20, 'broken', { content: '---\ndescription: Use when: x\n---\n# b' })]); + assert.match(lintSkill(broken, broken.bySlug.get('broken'))[0].msg, /frontmatter 解析失败(含「: 」的值要用引号括起来)/); + + const flags = structuralFlags(analysis); + const flagCodes = (id) => (flags.get(id) || []).map((f) => f.code); + assert.deepEqual(flagCodes(2), ['copies_meta']); + assert.ok(flagCodes(3).includes('broken_ref')); + assert.deepEqual(flagCodes(10), ['extractable']); + assert.deepEqual(flagCodes(13), ['meta_contract']); + + // 待审内容单独分析:把抄来的步骤换成引用后,提示消失 + const fixed = analyzeSkill({ ...library[1], content: md('depends_on: [dev-build]', '# 回放\n\n> 用元技能 `dev-build`\n> - target: //app:main') }, analysis.library); + assert.ok(!lintSkill(analysis, fixed).some((i) => i.code === 'copies-meta')); +}); + +test('references end to end: push hints, ash lint, local resolution after install', async (t) => { + const server = await startServer(); + const { origin } = server; + t.after(() => server.stop()); + const work = tmpDir('ash-refs-'); + const home = path.join(work, 'home'); + fs.mkdirSync(home); + const cli = path.join(work, 'cli.sh'); + fs.writeFileSync(cli, await (await fetch(`${origin}/cli.sh`)).text()); + const ash = (args, env = {}) => spawnSync('bash', [cli, ...args], { cwd: work, env: { ...process.env, HOME: home, ASH_TERMINAL: 'box', ...env }, encoding: 'utf8' }); + const ok = (args, env) => { + const r = ash(args, env); + assert.equal(r.status, 0, `ash ${args.join(' ')} failed:\n${r.stdout}\n${r.stderr}`); + return r; + }; + const writeSkill = (content) => { + const dir = tmpDir('ash-src-'); + fs.writeFileSync(path.join(dir, 'SKILL.md'), content); + return dir; + }; + const detail = async (slug) => (await fetch(`${origin}/api/skills/${slug}`)).json(); + const approve = async (slug) => assert.equal((await fetch(`${origin}/api/skills/${(await detail(slug)).id}/approve`, { method: 'POST' })).status, 200); + + const meta = md('name: dev-build\ndescription: 【元技能】在开发容器里编译并判断构建状态。Use when building inside the dev container', + `# 容器编译\n\n## 契约\n输入:target\n输出:构建是否成功\n\n## 怎么用\n\n${fence(BUILD)}\n\n## 被谁引用\n- \`replay\``); + ok(['push', writeSkill(meta)]); + await approve('dev-build'); + + await t.test('pushing a skill that copies a meta skill tells the agent to reference it instead', async () => { + const copy = md('name: replay\ndescription: 跑回放并验证结果\ndescription_en: Run the replay and verify the results', `# 回放\n\n${fence(BUILD)}`); + const out = ok(['push', writeSkill(copy)]).stdout; + assert.match(out, /有 4 条命令与元技能 dev-build 相同:引用它/); + assert.doesNotMatch(out, /缺少英文描述/); + const review = await detail('replay'); + assert.ok(review.lint.some((i) => i.code === 'copies-meta')); + assert.equal(review.shared_steps[0].skills[0].slug, 'dev-build'); + }); + + await t.test('the fixed version references the meta skill; the update is flagged metadata-only only when the body is unchanged', async () => { + const fixed = md('name: replay\ndescription: 跑回放并验证结果\ndescription_en: Run the replay and verify the results\ndepends_on: [dev-build]', + '# 回放\n\n> 用元技能 `dev-build`\n> - target: //app:main'); + const out = ok(['push', writeSkill(fixed), '--update']).stdout; + assert.doesNotMatch(out, /相同:引用它/); + await approve('replay'); + const d = await detail('dev-build'); + assert.deepEqual(d.refs.dependents.map((s) => s.slug), ['replay']); + assert.ok(!d.lint.some((i) => i.code === 'meta-used-by'), '「被谁引用」 matches reality'); + assert.match(ok(['info', 'dev-build']).stdout, /被引用: 1 个技能(replay)/); + + // 只改双语描述:Agent 框架不读这些字段,直接发布 + const retitled = fixed.replace('description_en: Run the replay and verify the results', 'description_en: Run the perception replay and verify results'); + assert.match(ok(['push', writeSkill(retitled), '--update']).stdout, /只改了双语描述.*已直接发布/); + const published = await detail('replay'); + assert.equal(published.pending_update, null); + assert.match(published.content, /Run the perception replay/); + + // 改了其他 frontmatter(依赖声明)但没动正文:仍要审核,审核条标出「只改了元数据」 + ok(['push', writeSkill(retitled.replace('depends_on: [dev-build]', 'depends_on: [dev-build]\ntags: [replay]')), '--update']); + const pending = await detail('replay'); + assert.equal(pending.pending_metadata_only, true); + assert.equal((await fetch(`${origin}/api/skills/${pending.id}/reject`, { method: 'POST' })).status, 200); + }); + + await t.test('after install the local agent can follow the reference to a real file', async () => { + const dir = path.join(work, 'skills'); + ok(['pull', 'replay', '--dir', dir]); + const installed = fs.readFileSync(path.join(dir, 'replay', 'SKILL.md'), 'utf8'); + const link = installed.match(/\[dev-build\]\((\.\.\/dev-build\/SKILL\.md)\)/); + assert.ok(link, 'the footer lists the referenced skill with a relative link'); + assert.ok(fs.existsSync(path.resolve(dir, 'replay', link[1])), 'the relative link resolves to the installed dependency'); + assert.match(ok(['installed', '--dir', dir]).stdout, /replay .* 最新/); + assert.match(ok(['push', path.join(dir, 'replay'), '--update']).stdout, /内容无变化/, 'the multi-line footer is stripped on push'); + assert.match(ok(['show', 'replay']).stdout, /本技能引用:`dev-build`(ash show dev-build)/); + }); + + await t.test('inside Claude Code, skills install where Claude Code loads them', () => { + ok(['pull', 'replay'], { CLAUDECODE: '1' }); + assert.ok(fs.existsSync(path.join(home, '.claude', 'skills', 'replay', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(home, '.claude', 'skills', 'dev-build', 'SKILL.md')), 'its reference is installed next to it'); + }); + + await t.test('@own-account references install; other accounts are reported, not fetched', async () => { + const d = md('name: nightly\ndescription: 每晚编译并回放\ndescription_en: Build and replay every night\ndepends_on: ["@tester/dev-build", "@bob/secret"]', '# 夜间任务\n\n> 用元技能 `dev-build`'); + ok(['push', writeSkill(d)]); + await approve('nightly'); + const dir = path.join(work, 'nightly'); + const r = ok(['pull', 'nightly', '--dir', dir]); + assert.ok(fs.existsSync(path.join(dir, 'dev-build', 'SKILL.md'))); + assert.doesNotMatch(r.stdout + r.stderr, /secret/); + const refs = (await detail('nightly')).refs.declared; + assert.deepEqual(refs.map((x) => x.status), ['ok', 'foreign']); + }); + + await t.test('ash lint lists what needs fixing across the library', async () => { + const all = ok(['lint']).stdout; + assert.match(all, /nightly\n.*@bob\/secret 指向其他账号/); + assert.match(ok(['lint', 'replay']).stdout, /✅ replay 没有发现问题/); + const json = await (await fetch(`${origin}/api/agent/lint?slug=nightly`)).json(); + assert.equal(json.results[0].issues[0].code, 'foreign-ref'); + assert.equal(ash(['lint', 'nope']).status, 1); + }); + + await t.test('attention shows skills that copy a meta skill', async () => { + const copy = md('name: copycat\ndescription: 另一个抄了编译步骤的技能\ndescription_en: Another skill that copied the build steps', `# 抄\n\n${fence(BUILD)}`); + ok(['push', writeSkill(copy)]); + await approve('copycat'); + const list = await (await fetch(`${origin}/api/skills?folder=attention`)).json(); + const copycat = list.find((s) => s.slug === 'copycat'); + assert.deepEqual(copycat.health.map((h) => h.code), ['copies_meta']); + }); +}); diff --git a/server/tests/semantic-agents.test.js b/server/tests/semantic-agents.test.js new file mode 100644 index 0000000..5b829b1 --- /dev/null +++ b/server/tests/semantic-agents.test.js @@ -0,0 +1,219 @@ +// 语义层(跨语言查重与找技能)、文字步骤比对、只改双语字段直接发布、可调用性检查、 +// 固定版本安装、多 Agent 安装与 ash doctor +const assert = require('node:assert/strict'); +const { spawn, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { startServer } = require('./helpers'); + +const tmpDir = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +process.env.DATA_DIR ||= tmpDir('ash-sem-db-'); +const { extractProseSteps, sharedProseRuns } = require('../skillSteps'); +const { onlyLocalizationChanged } = require('../skillMeta'); +const { analyzeLibrary, lintSkill } = require('../skillRefs'); +const { semanticPair, semanticQuery } = require('../skillIndex'); + +const md = (front, body) => `---\n${front}\n---\n\n${body}\n`; + +test('prose steps: numbered steps are matched even when worded a little differently', () => { + const a = { id: 1, steps: extractProseSteps(md('name: a', '## 步骤\n1. 登录开发机并进入 devbox 容器\n2. 拉取最新的 main 分支代码并同步子模块\n3. 用 j6 配置编译感知模块的回放目标\n4. 把产物拷到回放目录并记录版本号\n5. 写报告')) }; + const b = { id: 2, steps: extractProseSteps(md('name: b', '# 回归\n\n1. 先登录开发机,进入 devbox 容器\n2. 拉取最新的 main 分支代码,同步子模块\n3. 用 j6 配置编译感知模块回放目标\n4. 把产物拷到回放目录,记录版本号')) }; + const c = { id: 3, steps: extractProseSteps(md('name: c', '1. 打开飞书文档\n2. 选中要评论的段落\n3. 输入评论内容并提交\n4. 通知相关同事查看')) }; + const runs = sharedProseRuns([a, b, c]); + assert.equal(runs.length, 1); + assert.deepEqual(runs[0].skills, [1, 2]); + assert.equal(runs[0].commands.length, 4); + // 代码块、标题处断开,短步骤不参与 + const steps = extractProseSteps(md('name: d', '1. 这是第一个比较长的步骤描述\n```bash\n1. not a step\n```\n## 下一节\n2. 验证')); + assert.equal(steps.filter((s) => s.tokens && s.tokens.size).length, 1); +}); + +test('only-localization updates are recognised exactly', () => { + const base = md('name: a\ndescription: 中文描述\ntags: [x]', '# A\n\n正文'); + assert.equal(onlyLocalizationChanged(base, base.replace('description: 中文描述', 'description: 中文描述\ndescription_en: English one')), true); + assert.equal(onlyLocalizationChanged(base, base.replace('tags: [x]', 'tags: [x]\nmetadata:\n description-en: English')), true); + assert.equal(onlyLocalizationChanged(base, base.replace('中文描述', '改了主描述')), false, 'the main description is what agents read'); + assert.equal(onlyLocalizationChanged(base, base.replace('tags: [x]', 'tags: [y]')), false); + assert.equal(onlyLocalizationChanged(base, base.replace('正文', '正文改了')), false); + assert.equal(onlyLocalizationChanged(base, base), false); +}); + +test('lint: can agents load and invoke the skill', () => { + const skill = (id, slug, content, description = `${slug} 何时使用 when to use it`) => ({ id, slug, name: slug, description, status: 'approved', version: '1.0.0', tags: '[]', content }); + const analysis = analyzeLibrary([ + skill(1, 'manual-only', md('name: manual-only\ndisable-model-invocation: true', '# m')), + skill(2, 'caller', md('name: caller\ndepends_on: [manual-only]', '# c')), + skill(3, 'Bad_Name', md('name: Bad_Name', '# b')), + skill(4, 'renamed', md('name: other-name', '# r')), + skill(5, 'silent', md('name: silent', '# s'), ''), + skill(6, 'wordy', md('name: wordy', '# w'), `${'很长的描述'.repeat(300)} use when`), + ]); + const codes = (slug) => lintSkill(analysis, analysis.bySlug.get(slug)).map((i) => i.code); + assert.ok(codes('manual-only').includes('not-invocable')); + assert.ok(codes('caller').includes('ref-not-invocable')); + assert.ok(codes('Bad_Name').includes('spec-name')); + assert.ok(codes('renamed').includes('name-mismatch')); + assert.ok(codes('silent').includes('no-description')); + assert.ok(codes('wordy').includes('description-length')); +}); + +test('semantic scores map onto the lexical scale at the calibrated thresholds', () => { + assert.equal(semanticPair(0.5), 0); + assert.ok(Math.abs(semanticPair(0.68) - 0.25) < 1e-9); + assert.ok(Math.abs(semanticPair(0.74) - 0.4) < 1e-9); + assert.ok(semanticPair(0.9) > semanticPair(0.8)); + assert.ok(Math.abs(semanticQuery(0.58) - 0.12) < 1e-9); + assert.ok(Math.abs(semanticQuery(0.68) - 0.25) < 1e-9); + assert.equal(semanticQuery(0.4), 0); +}); + +// 假的向量服务(Ollama /api/embed 接口):按概念词表给文本打分,中英文说同一件事的文本落在同一个方向上。 +// 跑在单独的进程里:测试用 spawnSync 调 ash,会阻塞本进程的事件循环,服务要是在本进程里就答不上来 +const FAKE_EMBEDDINGS = ` +const CONCEPTS = [/数据库|迁移|回滚|database|migration|rollback|schema/gi, /部署|上线|deploy|release/gi, + /飞书|文档|lark|doc/gi, /技能|skill|写|writ/gi, /测试|根因|调试|bug|debug|test|root cause/gi]; +let calls = 0; +const server = require('http').createServer((req, res) => { + if (req.url === '/calls') return res.end(String(calls)); + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + calls += 1; + const { input } = JSON.parse(body || '{}'); + const vectors = (Array.isArray(input) ? input : [input]).map((t) => [...CONCEPTS.map((re) => (String(t).match(re) || []).length), 0.3]); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ embeddings: vectors })); + }); +}); +server.listen(0, '127.0.0.1', () => console.log(server.address().port)); +`; +async function startFakeEmbeddings() { + const child = spawn(process.execPath, ['-e', FAKE_EMBEDDINGS], { stdio: ['ignore', 'pipe', 'inherit'] }); + const port = await new Promise((resolve) => child.stdout.once('data', (d) => resolve(Number(String(d).trim())))); + const url = `http://127.0.0.1:${port}`; + return { + url, + calls: async () => Number(await (await fetch(`${url}/calls`)).text()), + // 被信号杀掉的进程 exitCode 是 null、signalCode 才有值 + close: () => new Promise((resolve) => { if (child.exitCode !== null || child.signalCode !== null) resolve(); else { child.once('exit', resolve); child.kill(); } }), + }; +} + +test('semantic layer, pins, multi-agent installs and ash doctor end to end', async (t) => { + const fake = await startFakeEmbeddings(); + const server = await startServer({ ASH_EMBED_URL: fake.url, ASH_EMBED_MODEL: 'fake', ASH_EMBED_TIMEOUT_MS: '2000' }); + const { origin } = server; + t.after(async () => { await server.stop(); await fake.close(); }); + const work = tmpDir('ash-sem-'); + const home = path.join(work, 'home'); + fs.mkdirSync(home); + const cli = path.join(work, 'cli.sh'); + fs.writeFileSync(cli, await (await fetch(`${origin}/cli.sh`)).text()); + const ash = (args, env = {}) => spawnSync('bash', [cli, ...args], { cwd: work, env: { ...process.env, HOME: home, ASH_TERMINAL: 'box', ...env }, encoding: 'utf8' }); + const ok = (args, env) => { + const r = ash(args, env); + assert.equal(r.status, 0, `ash ${args.join(' ')} failed:\n${r.stdout}\n${r.stderr}`); + return r; + }; + const writeSkill = (content) => { + const dir = tmpDir('ash-src-'); + fs.writeFileSync(path.join(dir, 'SKILL.md'), content); + return dir; + }; + const detail = async (slug) => (await fetch(`${origin}/api/skills/${slug}`)).json(); + const approve = async (slug) => assert.equal((await fetch(`${origin}/api/skills/${(await detail(slug)).id}/approve`, { method: 'POST' })).status, 200); + + await t.test('a Chinese skill and its English twin are caught as duplicates without any translation', async () => { + ok(['push', writeSkill(md('name: db-rollback\ndescription: 数据库迁移失败时回滚,恢复到迁移前的状态', '# 数据库迁移回滚\n\n## 回滚步骤\n\n## 验证数据'))]); + await approve('db-rollback'); + const out = ok(['push', writeSkill(md('name: migration-revert\ndescription: Revert a failed database migration and roll back the schema', '# Database migration rollback\n\n## Rollback\n\n## Verify'))]).stdout; + assert.match(out, /库中已有相近技能:db-rollback(疑似重复,语义相似度 \d+%)/); + const d = await detail('migration-revert'); + assert.equal(d.similar[0].slug, 'db-rollback'); + assert.equal(d.similar[0].semantic, true); + assert.ok(await fake.calls() > 0, 'vectors come from the configured embedding service'); + }); + + await t.test('a Chinese task description finds the English skill', async () => { + await approve('migration-revert'); + ok(['push', writeSkill(md('name: vercel-ship\ndescription: Deploy the site to Vercel and share the preview link', '# Deploy to Vercel'))]); + await approve('vercel-ship'); + const out = ok(['suggest', '把网站部署上线']).stdout; + assert.match(out, /vercel-ship/); + const json = await (await fetch(`${origin}/api/agent/suggest?q=${encodeURIComponent('数据库回滚')}`)).json(); + assert.ok(json.results.some((r) => r.slug === 'migration-revert'), 'the English skill is found for a Chinese query'); + }); + + await t.test('when the embedding service is down, search still answers from text', async () => { + await fake.close(); + const res = await fetch(`${origin}/api/agent/suggest?q=${encodeURIComponent('数据库迁移回滚')}&format=text`); + assert.equal(res.status, 200); + assert.match(await res.text(), /db-rollback/); + }); + + const v1 = md('name: toolkit\ndescription: 常用工具包 common toolkit for daily tasks\nversion: 1.0.0', '# 工具包 v1\n\n第一版'); + const v2 = v1.replace('version: 1.0.0', 'version: 2.0.0').replace('第一版', '第二版'); + await t.test('pinned versions install from history and are left alone by pull --all', async () => { + ok(['push', writeSkill(v1)]); + await approve('toolkit'); + ok(['push', writeSkill(v2), '--update']); + await approve('toolkit'); + const dir = path.join(work, 'pinned'); + ok(['pull', 'toolkit@1.0.0', '--dir', dir]); + assert.match(fs.readFileSync(path.join(dir, 'toolkit', 'SKILL.md'), 'utf8'), /第一版/); + assert.match(fs.readFileSync(path.join(dir, 'toolkit', '.ash'), 'utf8'), /^pinned=1\.0\.0$/m); + assert.match(ok(['installed', '--dir', dir]).stdout, /固定在 v1\.0\.0(库里当前 v2\.0\.0)/); + assert.match(ok(['outdated', '--dir', dir]).stdout, /都是最新的/); + ok(['pull', '--all', '--dir', dir]); + assert.match(fs.readFileSync(path.join(dir, 'toolkit', 'SKILL.md'), 'utf8'), /第一版/, 'pull --all does not upgrade a pinned skill'); + assert.match(ok(['show', 'toolkit@1.0.0']).stdout, /第一版/); + const missing = ash(['pull', 'toolkit@9.9.9', '--dir', path.join(work, 'nope')]); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /没有 9\.9\.9 版本/); + }); + + await t.test('a dependency pinned to an old version is installed at that version', async () => { + ok(['push', writeSkill(md('name: uses-old\ndescription: 用旧版工具包 uses the old toolkit\ndepends_on: ["toolkit@1.0.0"]', '# 用旧版\n\n> 用元技能 `toolkit`'))]); + await approve('uses-old'); + const dir = path.join(work, 'deps'); + ok(['pull', 'uses-old', '--dir', dir]); + assert.match(fs.readFileSync(path.join(dir, 'toolkit', 'SKILL.md'), 'utf8'), /第一版/); + const lint = (await detail('uses-old')).lint.map((i) => i.code); + assert.ok(lint.includes('pin-old') && !lint.includes('pin-missing')); + }); + + await t.test('--agent all installs into every agent on the machine, dependencies included', async () => { + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + ok(['pull', 'uses-old', '--agent', 'all']); + for (const root of ['.claude/skills', '.agents/skills']) { + assert.ok(fs.existsSync(path.join(home, root, 'uses-old', 'SKILL.md')), `installed for ${root}`); + assert.ok(fs.existsSync(path.join(home, root, 'toolkit', 'SKILL.md')), `dependency next to it in ${root}`); + } + ok(['pull', 'vercel-ship'], { ASH_AGENT: 'all' }); + assert.ok(fs.existsSync(path.join(home, '.agents/skills/vercel-ship/SKILL.md')), 'ASH_AGENT=all is the default when set'); + }); + + await t.test('ash doctor finds references that are not next to the skill, and --fix repairs them', () => { + assert.match(ok(['doctor']).stdout, /✅ 没有发现问题/); + fs.rmSync(path.join(home, '.claude/skills/toolkit'), { recursive: true }); + const broken = ash(['doctor']); + assert.equal(broken.status, 1); + assert.match(broken.stdout, /\.claude\/skills(Claude Code)/); + assert.match(broken.stdout, /uses-old 引用的 toolkit 不在同一目录/); + assert.match(ok(['installed']).stdout, /引用的技能不在旁边:toolkit@1\.0\.0/); + assert.match(ok(['doctor', '--fix']).stdout, /已把 uses-old 引用的 toolkit 装到旁边/); + assert.match(fs.readFileSync(path.join(home, '.claude/skills/toolkit/SKILL.md'), 'utf8'), /第一版/, '--fix respects the pin'); + assert.match(ok(['doctor']).stdout, /✅ 没有发现问题/); + const custom = ash(['doctor', '--dir', path.join(work, 'deps')]); + assert.match(custom.stdout, /没有 Agent 从这里加载/); + }); + + await t.test('ash lint --only narrows to one kind of problem', () => { + const out = ok(['lint', '--only', 'bilingual']).stdout; + assert.match(out, /db-rollback\n.*缺少英文描述/); + assert.doesNotMatch(out, /pin-old|历史版本/); + }); +}); diff --git a/server/tests/skill-meta.test.js b/server/tests/skill-meta.test.js index 4efbd3b..992da64 100644 --- a/server/tests/skill-meta.test.js +++ b/server/tests/skill-meta.test.js @@ -40,3 +40,13 @@ test('security scan is stable across repeated calls (no global-regex lastIndex s } assert.deepEqual(securityScan('plain text'), []); }); + +test('install footer is removed exactly, keeping anything an agent appended after it', () => { + const { stripInstallFooter } = require('../skillMeta'); + const original = '---\nname: a\n---\n\n# A\n'; + const hint = '> 本技能由 AnotherSkillHub 安装。用完运行 `ash feedback a ok`。'; + assert.equal(stripInstallFooter(`${original}\n\n${hint}\n`), original); + assert.equal(stripInstallFooter(`${original}\n\n${hint}`), original); + assert.equal(stripInstallFooter(`${original}\n\n${hint}\n\n## 已知问题\n\n先换算坐标。\n`), `${original}\n## 已知问题\n\n先换算坐标。\n`); + assert.equal(stripInstallFooter(original), original); +}); diff --git a/server/tests/usage-suggest.test.js b/server/tests/usage-suggest.test.js new file mode 100644 index 0000000..3addc46 --- /dev/null +++ b/server/tests/usage-suggest.test.js @@ -0,0 +1,219 @@ +// 使用记录与反馈(安装 / 阅读 / ash feedback → 健康信号),按任务描述找技能(ash suggest、search 退回), +// 推送时的相似技能提示,以及安装时附加、推送时去掉的反馈提示 +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { startServer } = require('./helpers'); + +const tmpDir = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +const md = (slug, description, { title = slug, tags = ['ops'], body = '' } = {}) => + `---\nname: ${slug}\ndescription: ${description}\ntags: [${tags.join(', ')}]\nversion: 1.0.0\n---\n\n# ${title}\n\n${body}\n`; + +// 纯函数:不连数据库也要能跑,DATA_DIR 指向临时目录免得在仓库里建库 +process.env.DATA_DIR ||= tmpDir('ash-index-db-'); +const { buildIndex, rankInIndex, similarInIndex, queryTerms, matchedPhrases } = require('../skillIndex'); + +test('ranking prefers what a skill is for over incidental body mentions, and bridges Chinese and English', () => { + const skills = [ + { id: 1, slug: 'tl-replay-verify', name: '红绿灯回放验证', description: '跑通感知回放并把检测框叠回原始图像做可视化验证', tags: '["perception"]', content: '# 步骤\n\n回放、抽帧、叠框', status: 'approved' }, + { id: 2, slug: 'root-cause', name: 'Root cause debugging', description: 'Use when a test fails or behavior is unexpected, before fixing', tags: '["debug"]', content: '# Steps', status: 'approved' }, + { id: 3, slug: 'release-notes', name: 'Release notes', description: 'Write release notes', tags: '[]', content: `# Notes\n\n${'test review bug '.repeat(40)}`, status: 'approved' }, + { id: 4, slug: 'draft-skill', name: '检测框草稿', description: '检测框叠回原图', tags: '[]', content: '', status: 'pending' }, + ]; + const index = buildIndex(skills); + const tl = rankInIndex(index, '把感知回放的检测框叠回原图,验证红绿灯识别对不对'); + assert.equal(tl[0].skill.slug, 'tl-replay-verify'); + assert.ok(tl[0].strong); + assert.ok(!tl.some((r) => r.skill.slug === 'draft-skill'), 'pending skills are not suggested to agents'); + assert.ok(rankInIndex(index, '检测框叠回原图', { includePending: true }).some((r) => r.skill.slug === 'draft-skill')); + // 中文描述找到英文技能;正文里反复出现 test 的无关技能排在后面 + const debug = rankInIndex(index, '测试挂了,找根因'); + assert.equal(debug[0].skill.slug, 'root-cause'); + assert.deepEqual(rankInIndex(index, '今天天气怎么样'), []); + assert.ok(queryTerms('部署上线').get('deploy') < 1, 'glossary expansions weigh less than the words the agent wrote'); + assert.deepEqual(matchedPhrases('把检测框叠回原图', ['检测', '测框', '原图']), ['检测框', '原图']); +}); + +test('similarity is symmetric and only looks at what skills are for', () => { + const skills = [ + { id: 1, slug: 'deploy-vercel', name: 'Deploy to Vercel', description: 'Deploy apps to Vercel with the CLI', tags: '["deploy"]', content: '# Deploy\n' }, + { id: 2, slug: 'vercel-deploy', name: 'Vercel deploy', description: 'Deploy an app to Vercel via CLI', tags: '["deploy"]', content: '# Deploy\n' }, + { id: 3, slug: 'essay', name: 'Essay', description: 'Write an essay', tags: '[]', content: `# Essay\n\n${'deploy vercel '.repeat(50)}` }, + ]; + const index = buildIndex(skills); + const [a] = similarInIndex(index, skills[0]); + const [b] = similarInIndex(index, skills[1]); + assert.equal(a.skill.slug, 'vercel-deploy'); + assert.ok(a.high, 'near-identical intent is flagged as a likely duplicate'); + assert.equal(a.similarity.toFixed(6), b.similarity.toFixed(6)); + assert.ok(!similarInIndex(index, skills[0]).some((m) => m.skill.slug === 'essay'), 'body mentions do not make skills similar'); +}); + +test('usage, feedback and suggestions end to end', async (t) => { + const server = await startServer(); + const { origin } = server; + t.after(() => server.stop()); + + const work = tmpDir('ash-usage-'); + const home = path.join(work, 'home'); + fs.mkdirSync(home); + const cli = path.join(work, 'cli.sh'); + fs.writeFileSync(cli, await (await fetch(`${origin}/cli.sh`)).text()); + const ash = (args, env = {}) => spawnSync('bash', [cli, ...args], { + cwd: work, env: { ...process.env, HOME: home, ASH_TERMINAL: 'box-1', ...env }, encoding: 'utf8', + }); + const ok = (args, env) => { + const r = ash(args, env); + assert.equal(r.status, 0, `ash ${args.join(' ')} failed:\n${r.stdout}\n${r.stderr}`); + return r; + }; + const writeSkill = (files) => { + const dir = tmpDir('ash-src-'); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + } + return dir; + }; + const detail = async (slug) => (await fetch(`${origin}/api/skills/${slug}`)).json(); + const approve = async (slug) => assert.equal((await fetch(`${origin}/api/skills/${(await detail(slug)).id}/approve`, { method: 'POST' })).status, 200); + + const replay = md('tl-replay-verify', '跑通感知回放并把检测框叠回原始图像做可视化验证', { title: '红绿灯回放验证', tags: ['perception'], body: '## 步骤\n\n1. 回放\n2. 叠框\n' }); + ok(['push', writeSkill({ 'SKILL.md': replay, 'scripts/overlay.py': 'print(1)\n' })]); + await approve('tl-replay-verify'); + const skillsDir = path.join(work, 'skills'); + + await t.test('suggest ranks by task description and says so when nothing fits', async () => { + const out = ok(['suggest', '把感知回放的检测框叠回原图,验证红绿灯识别对不对']).stdout; + assert.match(out.split('\n')[1], /^1\. tl-replay-verify/); + assert.match(out, /相关 · 命中: .*检测框/); + assert.match(ok(['suggest', '推荐一只股票']).stdout, /没有找到与「推荐一只股票」明显相关的技能/); + const json = await (await fetch(`${origin}/api/agent/suggest?q=${encodeURIComponent('感知回放叠框')}`)).json(); + assert.equal(json.results[0].slug, 'tl-replay-verify'); + assert.equal(typeof json.results[0].relevance, 'number'); + assert.equal(ash(['suggest']).status, 1); + }); + + await t.test('search falls back to ranked candidates when no skill has every keyword', () => { + const out = ok(['search', '感知回放', '不存在的词']).stdout; + assert.match(out, /没有同时命中「感知回放 不存在的词」全部关键词的技能/); + assert.match(out, /tl-replay-verify/); + }); + + await t.test('install records the machine and appends a feedback hint that push strips again', async () => { + const r = ok(['pull', 'tl-replay-verify', '--dir', skillsDir]); + assert.match(r.stdout, /用完运行 ash feedback tl-replay-verify ok\|fail/); + const installed = fs.readFileSync(path.join(skillsDir, 'tl-replay-verify', 'SKILL.md'), 'utf8'); + assert.ok(installed.startsWith(replay), 'original content is untouched'); + assert.match(installed, /\n> 本技能由 AnotherSkillHub 安装。\n> 按本技能完成任务后.*ash feedback tl-replay-verify ok/); + assert.match(ok(['installed', '--dir', skillsDir]).stdout, /tl-replay-verify .* 最新/, 'the hint does not count as a local modification'); + assert.match(ok(['push', path.join(skillsDir, 'tl-replay-verify'), '--update']).stdout, /内容无变化/); + const usage = (await detail('tl-replay-verify')).usage; + assert.equal(usage.installs, 1); + assert.equal(usage.machines, 1); + ok(['pull', 'tl-replay-verify', '--dir', path.join(work, 'plain')], { ASH_FEEDBACK_HINT: '0' }); + assert.equal(fs.readFileSync(path.join(work, 'plain', 'tl-replay-verify', 'SKILL.md'), 'utf8'), replay); + }); + + await t.test('a fix appended after the hint survives the push', async () => { + const dir = path.join(work, 'edited'); + ok(['pull', 'tl-replay-verify', '--dir', dir]); + fs.appendFileSync(path.join(dir, 'tl-replay-verify', 'SKILL.md'), '\n## 已知问题\n\n叠框前先换算坐标。\n'); + assert.match(ok(['push', path.join(dir, 'tl-replay-verify'), '--update']).stdout, /已提交 tl-replay-verify 的更新/); + const pending = (await detail('tl-replay-verify')).pending_update.content; + assert.equal(pending, `${replay}\n## 已知问题\n\n叠框前先换算坐标。\n`); + const id = (await detail('tl-replay-verify')).id; + assert.equal((await fetch(`${origin}/api/skills/${id}/reject`, { method: 'POST' })).status, 200); + }); + + await t.test('pull --all counts as an update, show counts as a read', async () => { + const before = (await detail('tl-replay-verify')).usage; + const updated = replay.replace('2. 叠框', '2. 叠框\n3. 对比'); + ok(['push', writeSkill({ 'SKILL.md': updated, 'scripts/overlay.py': 'print(1)\n' }), '--update']); + await approve('tl-replay-verify'); + ok(['pull', '--all', '--dir', skillsDir]); + const show = ok(['show', 'tl-replay-verify']).stdout; + assert.match(show, /ash feedback tl-replay-verify ok/, 'agents reading without installing see the hint too'); + const after = (await detail('tl-replay-verify')).usage; + assert.equal(after.installs, before.installs, 'updates are not new installs'); + assert.equal(after.views_30d, before.views_30d + 1); + assert.ok(after.last_used_at); + }); + + await t.test('feedback is tied to the installed revision and surfaces failures', async () => { + assert.match(ok(['feedback', 'tl-replay-verify', 'ok', '--dir', skillsDir]).stdout, /已记录 tl-replay-verify 的反馈:成功/); + const failed = ok(['feedback', 'tl-replay-verify', 'fail', '抽帧脚本找不到', 'ffmpeg', '--dir', skillsDir]).stdout; + assert.match(failed, /失败/); + assert.match(failed, /ash push <目录> --update/); + ok(['feedback', 'tl-replay-verify', 'fail', '颜色阈值不对']); // 不带 --dir:找不到已装副本,按当前版本算 + const usage = (await detail('tl-replay-verify')).usage; + assert.equal(usage.ok_current, 1); + assert.equal(usage.fail_current, 2); + assert.equal(usage.recent_feedback[0].note, '颜色阈值不对'); + assert.equal(usage.recent_feedback[1].note, '抽帧脚本找不到 ffmpeg'); + assert.deepEqual(usage.health.map((h) => h.code), ['failing']); + const info = ok(['info', 'tl-replay-verify']).stdout; + assert.match(info, /使用: 30 天安装 \d+ 次 · 装在 1 台机器上 · 阅读 1 次 · 反馈 1 成功 \/ 2 失败/); + assert.match(info, /⚠️ 常失败:当前版本 2 次失败、1 次成功/); + assert.match(info, /失败反馈(box-1,\d{4}-\d{2}-\d{2}):颜色阈值不对/); + assert.match(ok(['suggest', '感知回放叠框验证']).stdout, /⚠️ 常失败/); + + const list = await (await fetch(`${origin}/api/skills?folder=attention`)).json(); + assert.deepEqual(list.map((s) => s.slug), ['tl-replay-verify'], 'seed skills are too new to count as unused'); + assert.equal(list[0].health[0].label, '常失败'); + assert.equal((await (await fetch(`${origin}/api/skills/stats`)).json()).attention, 1); + }); + + await t.test('attention puts the most serious signals first and counts idleness from when tracking started', async () => { + const Database = require('better-sqlite3'); + const db = new Database(path.join(server.env.DATA_DIR, 'another-skillhub.db')); + db.prepare("UPDATE app_meta SET value = datetime('now', '-30 days') WHERE key = 'usage_tracking_since'").run(); + db.prepare("UPDATE skills SET created_at = datetime('now', '-90 days') WHERE slug IN ('release-checklist', 'tl-replay-verify')").run(); + db.close(); + const list = await (await fetch(`${origin}/api/skills?folder=attention`)).json(); + assert.equal(list[0].slug, 'tl-replay-verify', 'failing skills come first'); + const release = list.find((s) => s.slug === 'release-checklist'); + assert.deepEqual(release.health.map((h) => h.code), ['unused']); + assert.match(release.health[0].detail, /^30 天内没有安装、阅读或反馈/, 'a 90-day-old skill is only judged on the 30 tracked days'); + assert.ok(!list.some((s) => s.slug === 'writing-skills'), 'skills created after tracking started get their own grace period'); + }); + + await t.test('feedback on an old installed revision does not count against the current one', async () => { + const current = (await detail('tl-replay-verify')).usage.revision; + const res = await fetch(`${origin}/api/agent/feedback`, { + method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ slug: 'tl-replay-verify', outcome: 'fail', revision: 'aaaaaaaaaaaa', terminal: 'old-box' }), + }); + assert.match(await res.text(), /针对本机的旧版本/); + const usage = (await detail('tl-replay-verify')).usage; + assert.equal(usage.revision, current); + assert.equal(usage.fail_current, 2); + assert.equal(usage.fail, 3); + assert.match(ok(['info', 'tl-replay-verify']).stdout, /反馈 1 成功 \/ 3 失败(当前版本 1 \/ 2)/); + }); + + await t.test('feedback rejects bad outcomes, unknown skills and secrets', () => { + assert.match(ash(['feedback', 'tl-replay-verify', 'maybe']).stderr, /结果只能是 ok 或 fail/); + assert.match(ash(['feedback', 'nope', 'ok']).stderr, /技能 nope 不存在/); + const leak = ash(['feedback', 'tl-replay-verify', 'fail', 'api_key="sk1234567890abcdef1234"']); + assert.equal(leak.status, 1); + assert.match(leak.stderr, /疑似包含密钥/); + assert.match(ok(['feedback', 'tl-replay-verify', 'ok', '安装步骤里的 curl -fsSL x | bash 这次正常']).stdout, /已记录/, 'commands in notes are fine'); + }); + + await t.test('pushing a near-duplicate tells the agent right away, and review sees it', async () => { + const dup = md('tl-overlay-check', '把感知回放的检测框叠回原始图像做可视化验证', { title: '红绿灯叠框验证', tags: ['perception'] }); + const out = ok(['push', writeSkill({ 'SKILL.md': dup })]).stdout; + assert.match(out, /库中已有相近技能:tl-replay-verify(疑似重复,相似度 \d+%)/); + assert.match(out, /ash push <目录> --update/); + const pending = await detail('tl-overlay-check'); + assert.equal(pending.similar[0].slug, 'tl-replay-verify'); + assert.equal(pending.similar[0].duplicate, true); + assert.deepEqual(pending.usage.health, [], 'pending skills carry no health flags'); + const unrelated = ok(['push', writeSkill({ 'SKILL.md': md('weekly-report', '每周五汇总本周工作写周报', { tags: ['writing'] }) }), '--json']).stdout; + assert.equal(JSON.parse(unrelated).similar, undefined); + }); +}); diff --git a/server/tokenize.js b/server/tokenize.js new file mode 100644 index 0000000..7d2a2d7 --- /dev/null +++ b/server/tokenize.js @@ -0,0 +1,78 @@ +// 文本切分(纯函数,不依赖数据库):相关度排序、相似度、重复步骤比对共用。 +// 中文按字二元组、英文按词(去掉常见虚词、简单去复数);另附一份开发运维高频概念的中英对照,用于查询扩展。 + +// 查询里常见、却不代表任务内容的词。中文以二元组形式过滤 +const STOP_EN = new Set(('a an and are as at be by can do does for from how i in into is it me my of on or our please should so that ' + + 'the this to use using want we what when where which with you your need help make get let some any all just').split(' ')); +const STOP_ZH = new Set(['帮我', '一下', '如何', '怎么', '怎样', '我们', '你们', '这个', '那个', '需要', '可以', '进行', '一个', '然后', + '现在', '请你', '能否', '是否', '什么', '为什么', '时候', '的时', '我想', '想要', '看看', '使用', '用于', '这些', '里的', '的一', '一份']); + +// 中英对照:技能库常是中英混写,Agent 用中文描述任务时要能找到英文写的技能,反之亦然。 +// 只收开发与运维里的高频概念;扩展出来的词按 EXPANSION_WEIGHT 计分,只加分不抬高满分线 +const GLOSSARY = { + 测试: 'test', 调试: 'debug', 根因: 'root cause debug', 排查: 'troubleshoot debug', 报错: 'error', 错误: 'error', 故障: 'failure incident', + 部署: 'deploy', 上线: 'deploy release', 发布: 'release publish', 回滚: 'rollback', 构建: 'build', 编译: 'compile build', + 浏览器: 'browser', 网页: 'web page', 页面: 'page', 网站: 'website site', 登录: 'login', 填表: 'form', 表单: 'form', 截图: 'screenshot', + 文档: 'doc document', 飞书: 'lark feishu', 代码库: 'codebase repo', 仓库: 'repo repository', 代码: 'code', + 审查: 'review', 评审: 'review', 审核: 'review', 重构: 'refactor', 性能: 'performance', 优化: 'optimize', + 设计: 'design', 界面: 'ui interface', 前端: 'frontend', 后端: 'backend', 数据库: 'database', 服务器: 'server', + 容器: 'docker container', 日志: 'log', 监控: 'monitor', 图表: 'chart', 可视化: 'visualization dataviz', + 幻灯片: 'slides presentation', 演示: 'presentation slides', 表格: 'spreadsheet sheet', 翻译: 'translate', 写作: 'writing', + 简洁: 'concise', 总结: 'summary summarize', 摘要: 'summary', 新人: 'onboard onboarding', 新同事: 'onboard onboarding', + 上手: 'onboard', 入门: 'onboard getting started', 介绍: 'overview explain', 解释: 'explain', 计划: 'plan', 规划: 'plan', + 需求: 'requirement spec', 提交: 'commit', 合并: 'merge', 分支: 'branch', 安全: 'security', 漏洞: 'vulnerability security', + 依赖: 'dependency', 升级: 'upgrade', 迁移: 'migrate migration', 自动化: 'automation', 定时: 'schedule cron', + 图片: 'image', 图像: 'image', 视频: 'video', 生成: 'generate', 技能: 'skill', 验证: 'verify verification', 验收: 'verify acceptance', + 感知: 'perception', 检测: 'detection detect', 仿真: 'simulation sim', 回放: 'replay', 进度: 'progress status', +}; +const EXPANSION_WEIGHT = 0.6; + +const CJK = /[㐀-鿿豈-﫿]/; +const RUN = /[㐀-鿿豈-﫿]+|[a-z0-9]+/g; + +function stem(word) { + if (word.length > 4 && word.endsWith('ies')) return `${word.slice(0, -3)}y`; + if (word.length > 3 && word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1); + return word; +} + +// 切出词元序列;withSpans 时同时给出每个词元在原串中的位置(用于把命中的二元组拼回可读的词) +function tokenize(text, { withSpans = false } = {}) { + const tokens = []; + const spans = []; + const source = String(text || '').toLowerCase(); + const push = (token, start, end) => { tokens.push(token); if (withSpans) spans.push({ token, start, end }); }; + for (const match of source.matchAll(RUN)) { + const run = match[0]; + if (CJK.test(run[0])) { + if (run.length === 1) { push(run, match.index, match.index + 1); continue; } + for (let i = 0; i < run.length - 1; i += 1) { + const bigram = run.slice(i, i + 2); + if (!STOP_ZH.has(bigram)) push(bigram, match.index + i, match.index + i + 2); + } + } else if (run.length >= 2 && !STOP_EN.has(run)) { + push(stem(run), match.index, match.index + run.length); + } + } + return withSpans ? { tokens, spans, source } : tokens; +} + +// 反向对照(英文词 → 中文词元),启动时算一次 +const GLOSSARY_EN = new Map(); +for (const [zh, en] of Object.entries(GLOSSARY)) { + for (const token of tokenize(en)) GLOSSARY_EN.set(token, [...(GLOSSARY_EN.get(token) || []), ...tokenize(zh)]); +} + +// 查询 → { 词元: 权重 }:原词 1,对照扩展出的词 EXPANSION_WEIGHT +function queryTerms(query) { + const terms = new Map(); + const original = tokenize(query); + for (const token of original) terms.set(token, 1); + const expand = (token) => { if (!terms.has(token)) terms.set(token, EXPANSION_WEIGHT); }; + const lower = String(query || '').toLowerCase(); + for (const [zh, en] of Object.entries(GLOSSARY)) if (lower.includes(zh)) tokenize(en).forEach(expand); + for (const token of original) (GLOSSARY_EN.get(token) || []).forEach(expand); + return terms; +} + +module.exports = { tokenize, queryTerms, stem, CJK, GLOSSARY, EXPANSION_WEIGHT }; diff --git a/server/usage.js b/server/usage.js new file mode 100644 index 0000000..c493ff0 --- /dev/null +++ b/server/usage.js @@ -0,0 +1,135 @@ +// 使用记录与健康信号:技能被谁装了、读了、用得怎么样。 +// 技能装到本地后由 Agent 框架直接读取,服务端看不到「使用」本身,所以靠两种信号: +// · 安装 / 阅读(ash pull、ash show)——服务端自己记 +// · 反馈(ash feedback ok|fail)——安装时在 SKILL.md 末尾附一行提示,请 Agent 用完回报 +// 健康信号只从这些事实推出:当前版本常失败、从未使用、长期未用、与别的技能疑似重复; +// 另加技能之间的结构信号(skillRefs):引用失效、抄了元技能步骤、可提炼元技能、元技能缺契约。 +const db = require('./db'); +const { skillRevision } = require('./review'); +const { duplicateMap } = require('./skillIndex'); +const { libraryAnalysis, structuralFlags } = require('./skillRefs'); + +const EVENT_KINDS = ['install', 'dependency', 'update', 'view', 'feedback']; +// 「使用」:主动安装、作为依赖安装、阅读、反馈。update 是 ash pull --all 顺手刷新,不代表用过 +const USE_KINDS = "('install', 'dependency', 'view', 'feedback')"; + +const UNUSED_AFTER_DAYS = 14; +const STALE_AFTER_DAYS = 60; +const FAILING_MIN = 2; + +function recordEvent(skill, actorId, kind, { outcome = null, note = null, terminal = null, revision = null } = {}) { + if (!skill || !EVENT_KINDS.includes(kind)) return; + try { + db.prepare('INSERT INTO skill_events (skill_id, actor_id, kind, outcome, note, terminal, revision) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(skill.id, actorId || null, kind, outcome, note, terminal ? String(terminal).slice(0, 120) : null, revision || skillRevision(skill)); + } catch (e) { + // 记录失败不能影响安装与阅读本身 + console.error('recordEvent failed:', e.message); + } +} + +const trackingSince = () => db.prepare("SELECT value FROM app_meta WHERE key = 'usage_tracking_since'").get()?.value || '1970-01-01 00:00:00'; + +// 单个技能的使用概况(详情页、ash info) +function usageSummary(skill) { + const revision = skillRevision(skill); + const row = db.prepare(` + SELECT + COALESCE(SUM(kind IN ('install', 'dependency') AND created_at >= datetime('now', '-30 days')), 0) AS installs_30d, + COALESCE(SUM(kind IN ('install', 'dependency')), 0) AS installs, + COALESCE(SUM(kind = 'view' AND created_at >= datetime('now', '-30 days')), 0) AS views_30d, + COALESCE(SUM(kind = 'feedback' AND outcome = 'ok'), 0) AS ok, + COALESCE(SUM(kind = 'feedback' AND outcome = 'fail'), 0) AS fail, + COALESCE(SUM(kind = 'feedback' AND outcome = 'ok' AND revision = @revision), 0) AS ok_current, + COALESCE(SUM(kind = 'feedback' AND outcome = 'fail' AND revision = @revision), 0) AS fail_current, + COUNT(DISTINCT CASE WHEN kind IN ('install', 'dependency', 'update') THEN terminal END) AS machines, + MAX(CASE WHEN kind IN ${USE_KINDS} THEN created_at END) AS last_used_at + FROM skill_events WHERE skill_id = @id + `).get({ id: skill.id, revision }); + const recent = db.prepare(` + SELECT outcome, note, terminal, revision, created_at FROM skill_events + WHERE skill_id = ? AND kind = 'feedback' ORDER BY created_at DESC, id DESC LIMIT 10 + `).all(skill.id).map((f) => ({ ...f, current: f.revision === revision })); + return { ...row, revision, recent_feedback: recent, health: healthFlags(skill, row) }; +} + +function daysSince(timestamp) { + if (!timestamp) return Infinity; + return (Date.now() - Date.parse(`${String(timestamp).replace(' ', 'T')}Z`)) / 86400000; +} + +// 由使用数据推出的健康信号:[{ code, label, detail }] +function healthFlags(skill, stats, since = trackingSince()) { + if (!skill || skill.is_deleted || skill.status === 'pending') return []; + const flags = []; + const failCurrent = Number(stats.fail_current || 0); + const okCurrent = Number(stats.ok_current || 0); + if (failCurrent >= FAILING_MIN && failCurrent >= okCurrent) { + flags.push({ code: 'failing', label: '常失败', detail: `当前版本 ${failCurrent} 次失败、${okCurrent} 次成功` }); + } + // 从技能创建或开始记录使用(取较晚者)起算,老技能不会在上线第一天全部变成「从未使用」 + const observedDays = daysSince(String(skill.created_at || '') > since ? skill.created_at : since); + if (!stats.last_used_at) { + if (observedDays >= UNUSED_AFTER_DAYS) flags.push({ code: 'unused', label: '从未使用', detail: `${Math.floor(observedDays)} 天内没有安装、阅读或反馈` }); + } else if (daysSince(stats.last_used_at) >= STALE_AFTER_DAYS) { + flags.push({ code: 'stale', label: '长期未用', detail: `最近一次使用在 ${String(stats.last_used_at).slice(0, 10)}` }); + } + return flags; +} + +// 整个库的健康信号:Map(skillId → flags)。列表、「需关注」视图、侧栏计数共用 +function libraryHealth(userId) { + const since = trackingSince(); + const skills = db.prepare(` + SELECT s.id, s.slug, s.content, s.files, s.status, s.is_deleted, s.created_at, + MAX(CASE WHEN e.kind IN ${USE_KINDS} THEN e.created_at END) AS last_used_at + FROM skills s LEFT JOIN skill_events e ON e.skill_id = s.id + WHERE s.user_id = ? AND s.is_deleted = 0 AND s.status != 'pending' + GROUP BY s.id + `).all(userId); + // 修订号在 JS 里算(sha256),反馈先全部取出,再按各技能的当前修订号计数 + const feedback = db.prepare(` + SELECT e.skill_id, e.outcome, e.revision FROM skill_events e JOIN skills s ON s.id = e.skill_id + WHERE s.user_id = ? AND s.is_deleted = 0 AND e.kind = 'feedback' + `).all(userId); + const bySkill = new Map(); + for (const f of feedback) bySkill.set(f.skill_id, [...(bySkill.get(f.skill_id) || []), f]); + const duplicates = duplicateMap(userId); + const structural = structuralFlags(libraryAnalysis(userId)); + const health = new Map(); + for (const skill of skills) { + const revision = skillRevision(skill); + const events = bySkill.get(skill.id) || []; + const stats = { + ok_current: events.filter((f) => f.outcome === 'ok' && f.revision === revision).length, + fail_current: events.filter((f) => f.outcome === 'fail' && f.revision === revision).length, + last_used_at: skill.last_used_at, + }; + const flags = healthFlags(skill, stats, since); + const similar = duplicates.get(skill.id); + if (similar) flags.push({ code: 'duplicate', label: '疑似重复', detail: `与 ${similar.skill.slug} 相似度 ${Math.round(similar.similarity * 100)}%`, slug: similar.skill.slug }); + flags.push(...(structural.get(skill.id) || [])); + if (flags.length) health.set(skill.id, flags); + } + return health; +} + +// 纯文本一行概况:ash info / ash suggest +function usageLine(summary) { + const parts = []; + parts.push(`30 天安装 ${summary.installs_30d} 次`); + if (summary.machines) parts.push(`装在 ${summary.machines} 台机器上`); + if (summary.views_30d) parts.push(`阅读 ${summary.views_30d} 次`); + if (summary.ok || summary.fail) { + const current = summary.ok_current + summary.fail_current; + parts.push(`反馈 ${summary.ok} 成功 / ${summary.fail} 失败${current !== summary.ok + summary.fail ? `(当前版本 ${summary.ok_current} / ${summary.fail_current})` : ''}`); + } else { + parts.push('暂无反馈'); + } + return parts.join(' · '); +} + +const OUTCOMES = { ok: 'ok', success: 'ok', good: 'ok', pass: 'ok', fail: 'fail', failure: 'fail', bad: 'fail', error: 'fail' }; +const normalizeOutcome = (value) => OUTCOMES[String(value || '').trim().toLowerCase()] || null; + +module.exports = { recordEvent, usageSummary, libraryHealth, usageLine, normalizeOutcome };