@modesto_ma/dsh-guanxiang 0.1.0-beta.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/lib/client.js +77 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +894 -159
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/page.tsx +29 -1
- package/src/client/styles.ts +1 -0
- package/src/config.ts +21 -0
- package/src/dispatch/prompt.ts +22 -4
- package/src/dispatch/tool.ts +62 -5
- package/src/host.ts +33 -1
- package/src/index.ts +6 -2
- package/src/repos/git.ts +13 -6
- package/src/repos/registry.ts +29 -0
- package/src/service.ts +23 -6
- package/src/settings/contract.ts +13 -1
- package/src/skill/entry.ts +13 -3
- package/src/skill-update/git.ts +157 -0
- package/src/skill-update/proposal.ts +116 -0
- package/src/skill-update/tool.ts +314 -0
package/src/skill/entry.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { SkillDefinition, SkillProvider, SkillProviderControl } from '../host.ts'
|
|
10
10
|
import type { AppManifest } from '../settings/contract.ts'
|
|
11
11
|
import { DISPATCH_TOOL_NAME } from '../dispatch/tool.ts'
|
|
12
|
+
import { PROPOSE_TOOL_NAME } from '../skill-update/tool.ts'
|
|
12
13
|
|
|
13
14
|
/** Entry skill name, addressed as `/guanxiang`. */
|
|
14
15
|
export const ENTRY_SKILL_NAME = 'guanxiang'
|
|
@@ -61,13 +62,20 @@ export function renderCatalog(apps: readonly AppManifest[]): string {
|
|
|
61
62
|
return ['| 应用 id | 名称 | 描述 | 别名 | 日志特征 |', '| --- | --- | --- | --- | --- |', ...rows].join('\n')
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
function proposalStep(): string {
|
|
66
|
+
return `
|
|
67
|
+
5. **SKILL 修改提议。** 派发结果中某个应用带有「SKILL 修改提议」时,在汇总回复末尾另起一节「SKILL 修改提议」:说明改动的文件和理由,附上 diff 文件中的关键改动;diff 较长时给出 diff 文件路径,在飞书话题中可以用 \`feishu_send_file\` 发送该文件。然后调用 \`${PROPOSE_TOOL_NAME}\`(\`workDir\` 取结果中给出的运行目录,\`summary\` 一句话说明改了什么)。工具会先请求用户授权,批准后推送新分支并创建合并请求。不要自己用 git 推送或修改仓库。工具返回后,把合并请求链接或「用户拒绝 / 无法授权」如实告诉用户。提议不可提交时,只说明原因。
|
|
68
|
+
`
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
/**
|
|
65
72
|
* The entry skill body.
|
|
66
73
|
* @param apps - usable applications at load time.
|
|
67
74
|
* @param maxAppsPerRequest - dispatch task ceiling.
|
|
75
|
+
* @param skillUpdates - whether SKILL improvement proposals are enabled.
|
|
68
76
|
* @returns Markdown instructions.
|
|
69
77
|
*/
|
|
70
|
-
export function renderEntrySkill(apps: readonly AppManifest[], maxAppsPerRequest: number): string {
|
|
78
|
+
export function renderEntrySkill(apps: readonly AppManifest[], maxAppsPerRequest: number, skillUpdates: boolean): string {
|
|
71
79
|
return `# 观象:日志分析入口
|
|
72
80
|
|
|
73
81
|
观其象,知其因。没有 \`${DISPATCH_TOOL_NAME}\` 工具可用时(例如你是子 Agent),忽略本 SKILL,按普通方式完成任务。
|
|
@@ -109,7 +117,7 @@ ${renderCatalog(apps)}
|
|
|
109
117
|
\`\`\`
|
|
110
118
|
|
|
111
119
|
每个派发的应用都有一节。子 Agent 失败、超时或结论不充分时,在该节如实说明,不要编造结论。日志版本与仓库代码可能不一致,子 Agent 提示了版本差异时保留这条提示。
|
|
112
|
-
`
|
|
120
|
+
${skillUpdates ? proposalStep() : ''}`
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
/** Inputs the provider reads at each listing and load. */
|
|
@@ -126,6 +134,8 @@ export interface EntrySkillSource {
|
|
|
126
134
|
apps(): readonly AppManifest[]
|
|
127
135
|
/** Current dispatch task ceiling. */
|
|
128
136
|
maxAppsPerRequest(): number
|
|
137
|
+
/** Whether SKILL improvement proposals are enabled. */
|
|
138
|
+
skillUpdates(): boolean
|
|
129
139
|
/** Whether top-level agents may load the skill without `/guanxiang`. */
|
|
130
140
|
readonly autoInvoke: boolean
|
|
131
141
|
/**
|
|
@@ -176,7 +186,7 @@ export function createEntrySkillProvider(source: EntrySkillSource): {
|
|
|
176
186
|
get: async (requested, options): Promise<SkillDefinition | undefined> => {
|
|
177
187
|
if (requested.name !== ENTRY_SKILL_NAME) return undefined
|
|
178
188
|
await source.prepare(options.signal)
|
|
179
|
-
return { ...summary(), content: renderEntrySkill(source.apps(), source.maxAppsPerRequest()) }
|
|
189
|
+
return { ...summary(), content: renderEntrySkill(source.apps(), source.maxAppsPerRequest(), source.skillUpdates()) }
|
|
180
190
|
},
|
|
181
191
|
}
|
|
182
192
|
},
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 在拉取仓库中用 git 底层命令生成 SKILL 修改的提交并推送:用临时索引文件
|
|
3
|
+
* 在分析时的提交上替换文件、写出树对象,不改动任何检出、快照或本地分支;
|
|
4
|
+
* 推送时用 GitLab push options 顺带创建合并请求。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-guanxiang/skill-update/git
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import type { GitRunner } from '../repos/git.ts'
|
|
13
|
+
import type { ProposedFile } from './proposal.ts'
|
|
14
|
+
|
|
15
|
+
/** Line counts of one changed file. */
|
|
16
|
+
export interface FileStat {
|
|
17
|
+
/** Path relative to the repository root. */
|
|
18
|
+
readonly path: string
|
|
19
|
+
/** Added lines; undefined for a binary change. */
|
|
20
|
+
readonly added?: number
|
|
21
|
+
/** Removed lines; undefined for a binary change. */
|
|
22
|
+
readonly removed?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A proposal turned into a tree object on top of its base commit. */
|
|
26
|
+
export interface PreparedChange {
|
|
27
|
+
readonly base: string
|
|
28
|
+
readonly tree: string
|
|
29
|
+
readonly files: readonly FileStat[]
|
|
30
|
+
/** Unified diff from the base commit to {@link tree}, limited to the skill directory. */
|
|
31
|
+
readonly diff: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** git runner and the timeout of one command. */
|
|
35
|
+
export interface GitCommands {
|
|
36
|
+
readonly git: GitRunner
|
|
37
|
+
readonly timeoutMs: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Author and committer of a commit. */
|
|
41
|
+
export interface CommitAuthor {
|
|
42
|
+
readonly name: string
|
|
43
|
+
readonly email: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Merge request fields sent as GitLab push options. */
|
|
47
|
+
export interface MergeRequestOptions {
|
|
48
|
+
readonly target: string
|
|
49
|
+
readonly title: string
|
|
50
|
+
readonly description: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-renames']
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write the proposed files over the skill directory of `base` into a new tree.
|
|
57
|
+
* @param commands - git runner and timeout.
|
|
58
|
+
* @param dir - repository directory whose object database receives the objects.
|
|
59
|
+
* @param base - commit the analysis ran on.
|
|
60
|
+
* @param skillDir - skill directory relative to the repository root, `/`-separated.
|
|
61
|
+
* @param files - proposed files relative to `skillDir`.
|
|
62
|
+
* @returns the tree, per-file line counts, and the diff; `files` is empty when nothing changes.
|
|
63
|
+
* @throws when a proposed path replaces a symbolic link or a directory, or a git command fails.
|
|
64
|
+
*/
|
|
65
|
+
export async function prepareChange(commands: GitCommands, dir: string, base: string, skillDir: string, files: readonly ProposedFile[]): Promise<PreparedChange> {
|
|
66
|
+
const { git, timeoutMs } = commands
|
|
67
|
+
const scratch = await mkdtemp(join(tmpdir(), 'guanxiang-index-'))
|
|
68
|
+
try {
|
|
69
|
+
const env = { GIT_INDEX_FILE: join(scratch, 'index') }
|
|
70
|
+
await git(['read-tree', base], { cwd: dir, timeoutMs, env })
|
|
71
|
+
const listing = await git(['ls-tree', '-r', '-t', '-z', base, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
72
|
+
const modes = new Map<string, string>()
|
|
73
|
+
for (const record of listing.split('\0')) {
|
|
74
|
+
const tab = record.indexOf('\t')
|
|
75
|
+
if (tab < 0) continue
|
|
76
|
+
modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(' ')))
|
|
77
|
+
}
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const path = `${skillDir}/${file.path}`
|
|
80
|
+
const existing = modes.get(path)
|
|
81
|
+
if (existing === '120000') throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`)
|
|
82
|
+
if (existing === '040000') throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`)
|
|
83
|
+
const blob = (await git(['hash-object', '-w', '--stdin'], { cwd: dir, timeoutMs, input: file.content })).trim()
|
|
84
|
+
await git(['update-index', '--add', '--cacheinfo', `${existing === '100755' ? '100755' : '100644'},${blob},${path}`], { cwd: dir, timeoutMs, env })
|
|
85
|
+
}
|
|
86
|
+
const tree = (await git(['write-tree'], { cwd: dir, timeoutMs, env })).trim()
|
|
87
|
+
const numstat = await git(['diff', ...DIFF_FLAGS, '--numstat', base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
88
|
+
const stats = numstat.split('\n').filter(line => line !== '').map((line): FileStat => {
|
|
89
|
+
const [added = '-', removed = '-', ...path] = line.split('\t')
|
|
90
|
+
return { path: path.join('\t'), ...added === '-' ? {} : { added: Number(added) }, ...removed === '-' ? {} : { removed: Number(removed) } }
|
|
91
|
+
})
|
|
92
|
+
const diff = stats.length === 0 ? '' : await git(['diff', ...DIFF_FLAGS, base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
93
|
+
return { base, tree, files: stats, diff }
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(scratch, { recursive: true, force: true })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the remote already has a branch.
|
|
101
|
+
* @param commands - git runner and timeout.
|
|
102
|
+
* @param dir - clone directory.
|
|
103
|
+
* @param branch - branch name.
|
|
104
|
+
* @param env - network environment of the clone's address.
|
|
105
|
+
* @returns whether `refs/heads/<branch>` exists on `origin`.
|
|
106
|
+
*/
|
|
107
|
+
export async function remoteBranchExists(commands: GitCommands, dir: string, branch: string, env: Readonly<Record<string, string>>): Promise<boolean> {
|
|
108
|
+
const output = await commands.git(['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env })
|
|
109
|
+
return output.trim() !== ''
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Commit a prepared tree on its base commit without touching any ref.
|
|
114
|
+
* @param commands - git runner and timeout.
|
|
115
|
+
* @param dir - clone directory.
|
|
116
|
+
* @param change - prepared tree and its base.
|
|
117
|
+
* @param message - full commit message.
|
|
118
|
+
* @param author - explicit identity; omitted uses the host's git configuration.
|
|
119
|
+
* @returns the new commit id.
|
|
120
|
+
*/
|
|
121
|
+
export async function commitChange(commands: GitCommands, dir: string, change: PreparedChange, message: string, author: CommitAuthor | undefined): Promise<string> {
|
|
122
|
+
const env = author === undefined
|
|
123
|
+
? undefined
|
|
124
|
+
: { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_COMMITTER_NAME: author.name, GIT_COMMITTER_EMAIL: author.email }
|
|
125
|
+
const output = await commands.git(['commit-tree', change.tree, '-p', change.base, '-F', '-'], { cwd: dir, timeoutMs: commands.timeoutMs, input: message, ...env === undefined ? {} : { env } })
|
|
126
|
+
return output.trim()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Push options cannot carry line breaks; collapse every whitespace run. */
|
|
130
|
+
function oneLine(text: string): string {
|
|
131
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Push a commit to a new remote branch and ask GitLab to open a merge request
|
|
136
|
+
* for it (`merge_request.*` push options). The push carries an empty lease on
|
|
137
|
+
* the branch, so it fails instead of updating a branch that appeared after
|
|
138
|
+
* {@link remoteBranchExists} was checked. A server that does not support push
|
|
139
|
+
* options rejects the push.
|
|
140
|
+
* @param commands - git runner and timeout.
|
|
141
|
+
* @param dir - clone directory.
|
|
142
|
+
* @param commit - commit to push.
|
|
143
|
+
* @param branch - new branch name.
|
|
144
|
+
* @param request - merge request target and text.
|
|
145
|
+
* @param env - network environment of the clone's address.
|
|
146
|
+
* @returns the merge request URL the server printed, if any.
|
|
147
|
+
*/
|
|
148
|
+
export async function pushForMergeRequest(commands: GitCommands, dir: string, commit: string, branch: string, request: MergeRequestOptions, env: Readonly<Record<string, string>>): Promise<string | undefined> {
|
|
149
|
+
const options = [
|
|
150
|
+
'merge_request.create',
|
|
151
|
+
`merge_request.target=${request.target}`,
|
|
152
|
+
`merge_request.title=${oneLine(request.title)}`,
|
|
153
|
+
`merge_request.description=${oneLine(request.description)}`,
|
|
154
|
+
].flatMap(option => ['-o', option])
|
|
155
|
+
const output = await commands.git(['push', '--porcelain', `--force-with-lease=refs/heads/${branch}:`, ...options, 'origin', `${commit}:refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env, output: 'all' })
|
|
156
|
+
return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0]
|
|
157
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 子 Agent 写在运行目录里的 SKILL 修改提议:`skill-proposal/reason.md` 说明
|
|
3
|
+
* 理由,`skill-proposal/files/` 按相对路径存放应用 SKILL 目录
|
|
4
|
+
* `.dsh/skills/<skill>/` 下要新增或修改的完整文件。
|
|
5
|
+
*
|
|
6
|
+
* 运行目录对 Agent 可写,所以这里读到的一切都按不可信输入校验:只接受普通
|
|
7
|
+
* 文件和目录(不跟随符号链接)、受限的文件名、UTF-8 文本、文件数与大小上限。
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-guanxiang/skill-update/proposal
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { lstat, readdir, readFile } from 'node:fs/promises'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { disablesUserInvocation } from '../repos/manifest.ts'
|
|
15
|
+
import type { SkillUpdateSettings } from '../settings/contract.ts'
|
|
16
|
+
|
|
17
|
+
/** Proposal directory inside a task's run directory. */
|
|
18
|
+
export const PROPOSAL_DIR = 'skill-proposal'
|
|
19
|
+
|
|
20
|
+
/** Reason file inside {@link PROPOSAL_DIR}. */
|
|
21
|
+
export const REASON_FILE = 'reason.md'
|
|
22
|
+
|
|
23
|
+
/** Directory inside {@link PROPOSAL_DIR} mirroring `.dsh/skills/<skill>/`. */
|
|
24
|
+
export const FILES_DIR = 'files'
|
|
25
|
+
|
|
26
|
+
/** Diff the plugin writes next to the proposal, for the user to read. */
|
|
27
|
+
export const DIFF_FILE = 'skill-proposal.diff'
|
|
28
|
+
|
|
29
|
+
const SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/
|
|
30
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
31
|
+
|
|
32
|
+
/** A proposal that cannot be submitted, with a reason the model and user can act on. */
|
|
33
|
+
export class ProposalError extends Error {}
|
|
34
|
+
|
|
35
|
+
/** One file of a proposal, by its path relative to the skill directory. */
|
|
36
|
+
export interface ProposedFile {
|
|
37
|
+
readonly path: string
|
|
38
|
+
readonly content: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A validated proposal. */
|
|
42
|
+
export interface Proposal {
|
|
43
|
+
readonly reason: string
|
|
44
|
+
readonly files: readonly ProposedFile[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function kindOf(path: string): Promise<'missing' | 'directory' | 'file' | 'other'> {
|
|
48
|
+
try {
|
|
49
|
+
const info = await lstat(path)
|
|
50
|
+
return info.isDirectory() ? 'directory' : info.isFile() ? 'file' : 'other'
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readText(path: string, label: string, maxBytes: number): Promise<string> {
|
|
58
|
+
const info = await lstat(path)
|
|
59
|
+
if (info.size > maxBytes) throw new ProposalError(`${label} 有 ${info.size} 字节,超过上限 ${maxBytes}`)
|
|
60
|
+
const bytes = await readFile(path)
|
|
61
|
+
if (bytes.length > maxBytes) throw new ProposalError(`${label} 超过大小上限 ${maxBytes} 字节`)
|
|
62
|
+
let text: string
|
|
63
|
+
try {
|
|
64
|
+
text = decoder.decode(bytes)
|
|
65
|
+
} catch {
|
|
66
|
+
// TextDecoder reports only that the bytes are not UTF-8.
|
|
67
|
+
throw new ProposalError(`${label} 不是 UTF-8 文本`)
|
|
68
|
+
}
|
|
69
|
+
if (text.includes('\u0000')) throw new ProposalError(`${label} 包含 NUL 字符,不是文本文件`)
|
|
70
|
+
return text
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read and validate the proposal of one task.
|
|
75
|
+
* @param workDir - the task's run directory.
|
|
76
|
+
* @param limits - file count and size ceilings.
|
|
77
|
+
* @returns the proposal, or undefined when the task wrote none.
|
|
78
|
+
* @throws ProposalError when the proposal exists but cannot be submitted.
|
|
79
|
+
*/
|
|
80
|
+
export async function readProposal(workDir: string, limits: Pick<SkillUpdateSettings, 'maxFiles' | 'maxFileBytes'>): Promise<Proposal | undefined> {
|
|
81
|
+
const root = join(workDir, PROPOSAL_DIR)
|
|
82
|
+
const rootKind = await kindOf(root)
|
|
83
|
+
if (rootKind === 'missing') return undefined
|
|
84
|
+
if (rootKind !== 'directory') throw new ProposalError(`${PROPOSAL_DIR} 必须是目录`)
|
|
85
|
+
|
|
86
|
+
const reasonPath = join(root, REASON_FILE)
|
|
87
|
+
if (await kindOf(reasonPath) !== 'file') throw new ProposalError(`缺少 ${PROPOSAL_DIR}/${REASON_FILE}(修改理由)`)
|
|
88
|
+
const reason = (await readText(reasonPath, `${PROPOSAL_DIR}/${REASON_FILE}`, limits.maxFileBytes)).trim()
|
|
89
|
+
if (reason === '') throw new ProposalError(`${PROPOSAL_DIR}/${REASON_FILE} 不能为空`)
|
|
90
|
+
|
|
91
|
+
const filesRoot = join(root, FILES_DIR)
|
|
92
|
+
if (await kindOf(filesRoot) !== 'directory') throw new ProposalError(`缺少 ${PROPOSAL_DIR}/${FILES_DIR}/(修改后的 SKILL 文件)`)
|
|
93
|
+
const files: ProposedFile[] = []
|
|
94
|
+
const walk = async (dir: string, prefix: readonly string[]): Promise<void> => {
|
|
95
|
+
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
96
|
+
const segments = [...prefix, entry.name]
|
|
97
|
+
const relative = segments.join('/')
|
|
98
|
+
if (!SEGMENT.test(entry.name)) throw new ProposalError(`文件名不合法:${relative}(只允许字母、数字、. _ -,且不能以 . 或 - 开头)`)
|
|
99
|
+
const path = join(dir, entry.name)
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
await walk(path, segments)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (!entry.isFile()) throw new ProposalError(`${relative} 不是普通文件(不接受符号链接等)`)
|
|
105
|
+
if (files.length >= limits.maxFiles) throw new ProposalError(`文件数超过上限 ${limits.maxFiles}`)
|
|
106
|
+
files.push({ path: relative, content: await readText(path, relative, limits.maxFileBytes) })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await walk(filesRoot, [])
|
|
110
|
+
if (files.length === 0) throw new ProposalError(`${PROPOSAL_DIR}/${FILES_DIR}/ 中没有文件`)
|
|
111
|
+
const skill = files.find(file => file.path === 'SKILL.md')
|
|
112
|
+
if (skill !== undefined && disablesUserInvocation(skill.content)) {
|
|
113
|
+
throw new ProposalError('SKILL.md 不能设置 user-invocable: false,否则派发时无法用 /<skill> 加载')
|
|
114
|
+
}
|
|
115
|
+
return { reason, files }
|
|
116
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `guanxiang_propose_skill`:把某个派发任务的 SKILL 修改提议提交为合并请求。
|
|
3
|
+
*
|
|
4
|
+
* - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
|
|
5
|
+
* 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
|
|
6
|
+
* - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
|
|
7
|
+
* 父提交,推送到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不推送已有
|
|
8
|
+
* 分支或默认分支。
|
|
9
|
+
* - 推送前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
|
|
10
|
+
* 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才推送;
|
|
11
|
+
* 没有审批服务时工具报错,不推送。
|
|
12
|
+
* - 子会话中不提供本工具。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-guanxiang/skill-update/tool
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomBytes } from 'node:crypto'
|
|
18
|
+
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
19
|
+
import { join, resolve } from 'node:path'
|
|
20
|
+
import { z } from 'zod'
|
|
21
|
+
import { describeIssue } from '../config.ts'
|
|
22
|
+
import { isDelegatedAgent } from '../dispatch/tool.ts'
|
|
23
|
+
import type { ContentBlock, HostApproval, ToolDefinition, ToolRunContext } from '../host.ts'
|
|
24
|
+
import type { GitRunner } from '../repos/git.ts'
|
|
25
|
+
import type { RepoRegistry } from '../repos/registry.ts'
|
|
26
|
+
import type { AppManifest, EditableSettings } from '../settings/contract.ts'
|
|
27
|
+
import { commitChange, prepareChange, pushForMergeRequest, remoteBranchExists } from './git.ts'
|
|
28
|
+
import type { CommitAuthor, FileStat, PreparedChange } from './git.ts'
|
|
29
|
+
import { DIFF_FILE, FILES_DIR, PROPOSAL_DIR, ProposalError, readProposal, REASON_FILE } from './proposal.ts'
|
|
30
|
+
|
|
31
|
+
/** Model-facing tool name. */
|
|
32
|
+
export const PROPOSE_TOOL_NAME = 'guanxiang_propose_skill'
|
|
33
|
+
|
|
34
|
+
/** Longest accepted one-line summary; it becomes the commit subject and merge request title. */
|
|
35
|
+
export const MAX_SUMMARY_CHARS = 120
|
|
36
|
+
|
|
37
|
+
/** Dispatched tasks remembered for submission; older ones are forgotten first. */
|
|
38
|
+
const MAX_REMEMBERED_TASKS = 256
|
|
39
|
+
|
|
40
|
+
/** Characters of the reason shown on an approval prompt. */
|
|
41
|
+
const REASON_PREVIEW_CHARS = 300
|
|
42
|
+
|
|
43
|
+
/** Characters of the reason sent as the merge request description; the commit message keeps all of it. */
|
|
44
|
+
const REASON_DESCRIPTION_CHARS = 2000
|
|
45
|
+
|
|
46
|
+
/** A dispatched task as the dispatch tool recorded it. */
|
|
47
|
+
export interface DispatchedTask {
|
|
48
|
+
/** Absolute run directory of the task. */
|
|
49
|
+
readonly workDir: string
|
|
50
|
+
/** Session that dispatched the task. */
|
|
51
|
+
readonly sessionId: string
|
|
52
|
+
readonly app: AppManifest
|
|
53
|
+
/** Repository address the application came from. */
|
|
54
|
+
readonly url: string
|
|
55
|
+
/** Commit the child analyzed. */
|
|
56
|
+
readonly commit: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A task's proposal as the dispatch result reports it. */
|
|
60
|
+
export type ProposalCheck =
|
|
61
|
+
| { readonly ok: true; readonly files: readonly FileStat[]; readonly diffPath: string }
|
|
62
|
+
| { readonly ok: false; readonly error: string }
|
|
63
|
+
|
|
64
|
+
/** How one submission ended. */
|
|
65
|
+
export type ProposeStatus = 'opened' | 'pushed' | 'exists' | 'rejected' | 'cancelled' | 'unavailable'
|
|
66
|
+
|
|
67
|
+
/** Canonical tool value. */
|
|
68
|
+
export interface ProposeValue {
|
|
69
|
+
status: ProposeStatus
|
|
70
|
+
app: string
|
|
71
|
+
repo: string
|
|
72
|
+
/** New branch the change was or would be pushed to. */
|
|
73
|
+
branch: string
|
|
74
|
+
/** Branch the merge request targets. */
|
|
75
|
+
target: string
|
|
76
|
+
files: FileStat[]
|
|
77
|
+
/** Diff file in the task's run directory. */
|
|
78
|
+
diffPath: string
|
|
79
|
+
mergeRequestUrl?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const argsSchema = z.object({
|
|
83
|
+
workDir: z.string().trim().min(1, 'workDir 不能为空'),
|
|
84
|
+
summary: z.string().trim().min(1, 'summary 不能为空').max(MAX_SUMMARY_CHARS, `summary 不能超过 ${MAX_SUMMARY_CHARS} 个字符`)
|
|
85
|
+
.refine(value => !/[\r\n]/.test(value), 'summary 只能是一行'),
|
|
86
|
+
}).strict()
|
|
87
|
+
|
|
88
|
+
const PARAMETERS: Record<string, unknown> = {
|
|
89
|
+
type: 'object',
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
required: ['workDir', 'summary'],
|
|
92
|
+
properties: {
|
|
93
|
+
workDir: { type: 'string', description: '派发结果中带有 SKILL 修改提议的任务运行目录(绝对路径)。' },
|
|
94
|
+
summary: { type: 'string', description: `一句话说明改了什么,用作提交标题和合并请求标题,不超过 ${MAX_SUMMARY_CHARS} 个字符。` },
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const FILE_STAT_SCHEMA = {
|
|
99
|
+
type: 'object',
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
required: ['path'],
|
|
102
|
+
properties: { path: { type: 'string' }, added: { type: 'number' }, removed: { type: 'number' } },
|
|
103
|
+
} as const
|
|
104
|
+
|
|
105
|
+
const OUTPUT_SCHEMA = {
|
|
106
|
+
type: 'object',
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
required: ['status', 'app', 'repo', 'branch', 'target', 'files', 'diffPath'],
|
|
109
|
+
properties: {
|
|
110
|
+
status: { type: 'string', enum: ['opened', 'pushed', 'exists', 'rejected', 'cancelled', 'unavailable'] },
|
|
111
|
+
app: { type: 'string' },
|
|
112
|
+
repo: { type: 'string' },
|
|
113
|
+
branch: { type: 'string' },
|
|
114
|
+
target: { type: 'string' },
|
|
115
|
+
files: { type: 'array', items: FILE_STAT_SCHEMA },
|
|
116
|
+
diffPath: { type: 'string' },
|
|
117
|
+
mergeRequestUrl: { type: 'string' },
|
|
118
|
+
},
|
|
119
|
+
} as const
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Render line counts as `path(+a −r)`, separated by `、`.
|
|
123
|
+
* @param files - changed files.
|
|
124
|
+
* @returns one line.
|
|
125
|
+
*/
|
|
126
|
+
export function describeFiles(files: readonly FileStat[]): string {
|
|
127
|
+
return files.map(file => file.added === undefined ? `${file.path}(二进制)` : `${file.path}(+${file.added} −${file.removed ?? 0})`).join('、')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Model-facing rendering of a submission.
|
|
132
|
+
* @param value - canonical value.
|
|
133
|
+
* @returns text content.
|
|
134
|
+
*/
|
|
135
|
+
export function renderProposeValue(value: ProposeValue): ContentBlock[] {
|
|
136
|
+
const where = `${value.repo} 的分支 ${value.branch}(目标 ${value.target})`
|
|
137
|
+
const text = {
|
|
138
|
+
opened: `已推送到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ''}`,
|
|
139
|
+
pushed: `已推送到 ${where},但服务器没有返回合并请求链接;请在 GitLab 上确认合并请求是否已创建。`,
|
|
140
|
+
exists: `${where} 已存在,同一改动可能已经提交过,本次没有推送。`,
|
|
141
|
+
rejected: '授权被拒绝,没有推送:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。',
|
|
142
|
+
cancelled: '授权请求被撤回,没有推送。',
|
|
143
|
+
unavailable: '当前没有可以回答授权请求的界面,没有推送。',
|
|
144
|
+
}[value.status]
|
|
145
|
+
return [{ type: 'text', text: `${text}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}` }]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Collaborators of the submission tool. */
|
|
149
|
+
export interface SkillUpdaterDeps {
|
|
150
|
+
readonly registry: RepoRegistry
|
|
151
|
+
settings(): EditableSettings
|
|
152
|
+
readonly git: GitRunner
|
|
153
|
+
readonly gitTimeoutMs: number
|
|
154
|
+
readonly author?: CommitAuthor
|
|
155
|
+
/** DSH's approval service, when the composition has one. */
|
|
156
|
+
approval(): HostApproval | undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function messageOf(error: unknown): string {
|
|
160
|
+
return error instanceof Error ? error.message : String(error)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write a file in an agent-writable directory without following whatever
|
|
165
|
+
* entry the agent left at its path: the content goes to a new file created
|
|
166
|
+
* exclusively, which then replaces the directory entry.
|
|
167
|
+
*/
|
|
168
|
+
async function replaceFile(path: string, content: string): Promise<void> {
|
|
169
|
+
const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`
|
|
170
|
+
try {
|
|
171
|
+
await writeFile(temporary, content, { flag: 'wx' })
|
|
172
|
+
await rename(temporary, path)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
await rm(temporary, { force: true })
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function skillDirOf(app: AppManifest): string {
|
|
180
|
+
return `.dsh/skills/${app.skill}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Remembers dispatched tasks, checks their proposals, and submits them. */
|
|
184
|
+
export class SkillUpdater {
|
|
185
|
+
private readonly tasks = new Map<string, DispatchedTask>()
|
|
186
|
+
|
|
187
|
+
/** @param deps - registry, settings, git, and approval access. */
|
|
188
|
+
constructor(private readonly deps: SkillUpdaterDeps) {}
|
|
189
|
+
|
|
190
|
+
/** @returns whether proposals are enabled in the current settings. */
|
|
191
|
+
enabled(): boolean {
|
|
192
|
+
return this.deps.settings().skillUpdates.enabled
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Remember a finished task and check the proposal it wrote, writing the
|
|
197
|
+
* diff next to it.
|
|
198
|
+
* @param task - the dispatched task.
|
|
199
|
+
* @returns the check, or undefined when the task wrote no proposal.
|
|
200
|
+
*/
|
|
201
|
+
async inspect(task: DispatchedTask): Promise<ProposalCheck | undefined> {
|
|
202
|
+
this.remember(task)
|
|
203
|
+
try {
|
|
204
|
+
const prepared = await this.prepare(task)
|
|
205
|
+
if (prepared === undefined) return undefined
|
|
206
|
+
return { ok: true, files: prepared.change.files, diffPath: prepared.diffPath }
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return { ok: false, error: messageOf(error) }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private remember(task: DispatchedTask): void {
|
|
213
|
+
this.tasks.delete(task.workDir)
|
|
214
|
+
this.tasks.set(task.workDir, task)
|
|
215
|
+
for (const key of this.tasks.keys()) {
|
|
216
|
+
if (this.tasks.size <= MAX_REMEMBERED_TASKS) break
|
|
217
|
+
this.tasks.delete(key)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read the proposal, build its tree, and write the diff file.
|
|
223
|
+
* @returns undefined when the task wrote no proposal.
|
|
224
|
+
* @throws ProposalError or a git failure.
|
|
225
|
+
*/
|
|
226
|
+
private async prepare(task: DispatchedTask): Promise<{ reason: string; change: PreparedChange; target: string; diffPath: string } | undefined> {
|
|
227
|
+
const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates)
|
|
228
|
+
if (proposal === undefined) return undefined
|
|
229
|
+
const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
|
|
230
|
+
const { change, target } = await this.deps.registry.withClone(task.url, async clone => ({
|
|
231
|
+
change: await prepareChange(commands, clone.dir, task.commit, skillDirOf(task.app), proposal.files),
|
|
232
|
+
target: clone.branch,
|
|
233
|
+
}))
|
|
234
|
+
if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`)
|
|
235
|
+
const diffPath = join(task.workDir, DIFF_FILE)
|
|
236
|
+
await replaceFile(diffPath, change.diff)
|
|
237
|
+
return { reason: proposal.reason, change, target, diffPath }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Build the tool definition.
|
|
242
|
+
* @returns the registration.
|
|
243
|
+
*/
|
|
244
|
+
definition(): ToolDefinition<ProposeValue> {
|
|
245
|
+
return {
|
|
246
|
+
name: PROPOSE_TOOL_NAME,
|
|
247
|
+
description: '观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后推送新分支并创建合并请求,不会直接改动默认分支。'
|
|
248
|
+
+ '仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。',
|
|
249
|
+
parameters: PARAMETERS,
|
|
250
|
+
output: {
|
|
251
|
+
schema: OUTPUT_SCHEMA,
|
|
252
|
+
render: (_args, value) => renderProposeValue(value),
|
|
253
|
+
},
|
|
254
|
+
isConcurrencySafe: () => false,
|
|
255
|
+
execute: (args, exec) => this.execute(args, exec),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Submit one proposal.
|
|
261
|
+
* @param rawArgs - model arguments.
|
|
262
|
+
* @param exec - execution context of the calling agent.
|
|
263
|
+
* @returns how the submission ended.
|
|
264
|
+
* @throws when the arguments, task, proposal, or approval service are unusable, or git fails; nothing is pushed then.
|
|
265
|
+
*/
|
|
266
|
+
async execute(rawArgs: unknown, exec: ToolRunContext): Promise<ProposeValue> {
|
|
267
|
+
const parsed = argsSchema.safeParse(rawArgs)
|
|
268
|
+
if (!parsed.success) throw new Error(`${PROPOSE_TOOL_NAME} 参数不合法:${describeIssue(parsed.error)}`)
|
|
269
|
+
const { summary } = parsed.data
|
|
270
|
+
const agent = exec.agent
|
|
271
|
+
if (agent === undefined) throw new Error(`${PROPOSE_TOOL_NAME} 需要由 Agent 调用`)
|
|
272
|
+
if (isDelegatedAgent(agent)) throw new Error(`${PROPOSE_TOOL_NAME} 不能在子 Agent 中调用`)
|
|
273
|
+
if (!this.enabled()) throw new Error('观象的 SKILL 修改提议未开启(设置面板「高级」→「SKILL 改进」)')
|
|
274
|
+
const task = this.tasks.get(resolve(parsed.data.workDir))
|
|
275
|
+
if (task === undefined) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`)
|
|
276
|
+
if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`)
|
|
277
|
+
const approval = this.deps.approval()
|
|
278
|
+
if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时推送')
|
|
279
|
+
|
|
280
|
+
const prepared = await this.prepare(task)
|
|
281
|
+
if (prepared === undefined) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`)
|
|
282
|
+
const { change, target, diffPath, reason } = prepared
|
|
283
|
+
const branch = `guanxiang/skill-${task.app.id}-${change.tree.slice(0, 10)}`
|
|
284
|
+
const base = { app: task.app.id, repo: task.url, branch, target, files: [...change.files], diffPath }
|
|
285
|
+
const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
|
|
286
|
+
|
|
287
|
+
if (await this.deps.registry.withClone(task.url, clone => remoteBranchExists(commands, clone.dir, branch, clone.env))) {
|
|
288
|
+
return { ...base, status: 'exists' }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const preview = reason.replace(/\s+/g, ' ').slice(0, REASON_PREVIEW_CHARS)
|
|
292
|
+
const zh = `观象将向 ${task.url} 推送新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`
|
|
293
|
+
const en = `GuanXiang will push branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map(file => file.path).join(', ')}. Summary: ${summary}`
|
|
294
|
+
const outcome = await approval.request({
|
|
295
|
+
agent,
|
|
296
|
+
toolName: PROPOSE_TOOL_NAME,
|
|
297
|
+
...exec.callId === undefined ? {} : { callId: exec.callId },
|
|
298
|
+
reason: zh,
|
|
299
|
+
displayReason: { en, zh },
|
|
300
|
+
signal: exec.signal,
|
|
301
|
+
})
|
|
302
|
+
if (outcome === 'cancelled') exec.signal.throwIfAborted()
|
|
303
|
+
if (outcome !== 'allowed-once') return { ...base, status: outcome }
|
|
304
|
+
|
|
305
|
+
const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`
|
|
306
|
+
const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`
|
|
307
|
+
const url = await this.deps.registry.withClone(task.url, async (clone) => {
|
|
308
|
+
exec.signal.throwIfAborted()
|
|
309
|
+
const commit = await commitChange(commands, clone.dir, change, message, this.deps.author)
|
|
310
|
+
return await pushForMergeRequest(commands, clone.dir, commit, branch, { target, title: summary, description }, clone.env)
|
|
311
|
+
})
|
|
312
|
+
return url === undefined ? { ...base, status: 'pushed' } : { ...base, status: 'opened', mergeRequestUrl: url }
|
|
313
|
+
}
|
|
314
|
+
}
|