Skip to content

Commit e8f8a8e

Browse files
authored
Merge pull request #134 from phantom5099/skill
增加桌面端@skill能力
2 parents 3a11cb8 + 9b5a22b commit e8f8a8e

25 files changed

Lines changed: 396 additions & 112 deletions

‎packages/codingcode/src/agent/agent.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { SessionService } from '../session/port.js';
1717
import { SkillService } from '../skills/port.js';
1818
import { TodoService } from '../todo/port.js';
1919
import { ToolExecutorService } from '../tools/port.js';
20-
import { buildSystemPrompt } from './prompt.js';
20+
import { buildSystemPrompt, renderSkillBlock } from './prompt.js';
2121
import type { EndTransition, FrameBody, FrameError, ResponseMeta, ToolOutcome } from '../contracts/frame.js';
2222
import { isTurnEnd } from '../contracts/frame.js';
2323
import type { SessionRef } from '../contracts/session.js';
@@ -123,9 +123,19 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () {
123123

124124
const toolEnv = yield* toolEnvPort.getToolEnv();
125125

126-
// record user (increments turn) + extract skill
127-
const [, actualInput] = yield* skills.extractSkill(state.cwd, input);
128-
const turnId = (yield* session.recordUser(state, actualInput)).turnId;
126+
const turnId = (yield* session.recordUser(state, input)).turnId;
127+
128+
// 用户显式 @ 的 skill:按 path 回查权威数据,正文拼块后作为同回合的第二条 user 事件
129+
if (opts.skills?.length) {
130+
const all = yield* skills.getAll(state.cwd);
131+
const chosen = all.filter((s) => opts.skills!.some((m) => m.path === s.skillPath));
132+
if (chosen.length) {
133+
const entries = yield* Effect.forEach(chosen, (s) =>
134+
skills.readContent(s.skillPath).pipe(Effect.map((body) => ({ skill: s, body })))
135+
);
136+
yield* session.recordSystem(state, renderSkillBlock(entries));
137+
}
138+
}
129139

130140
// checkpoint baseline
131141
yield* checkpoint.snapshotBaseline(state.cwd, sessionId, turnId);

‎packages/codingcode/src/agent/port.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface RunTurnOptions {
1515
parentSessionId?: string;
1616
agentName?: string;
1717
systemPrompt?: string;
18+
skills?: ReadonlyArray<{ name: string; path: string }>;
1819
}
1920

2021
export interface AgentShape {

‎packages/codingcode/src/agent/prompt.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BUILD_PROMPT } from './profile.js';
2+
import type { Skill } from '../contracts/skill.js';
23
import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js';
34

45
interface SystemPromptOptions {
@@ -39,3 +40,20 @@ export function buildSystemPrompt(opts: SystemPromptOptions): string {
3940

4041
return prompt;
4142
}
43+
44+
export interface SkillBlockEntry {
45+
skill: Skill;
46+
body: string;
47+
}
48+
49+
/** 用户显式 @ 的 skill 提示块;正文内嵌,path 供模型解析正文里的相对路径。 */
50+
export function renderSkillBlock(entries: ReadonlyArray<SkillBlockEntry>): string {
51+
if (entries.length === 0) return '';
52+
const blocks = entries
53+
.map(
54+
({ skill, body }) =>
55+
`<skill>\n<name>${skill.name}</name>\n<path>${skill.skillPath}</path>\n${body}\n</skill>`
56+
)
57+
.join('\n\n');
58+
return `The user explicitly referenced the following skill(s). Follow the instructions below.\n\n${blocks}`;
59+
}

‎packages/codingcode/src/layer.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@ const AgentWithDeps = AgentLayer.pipe(
5050
// subagent runner (depends on agent)
5151
const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps));
5252

53-
// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧。
53+
// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧、
54+
// 要 hooks 在子代理终态时触发 agent.subagent.complete。
5455
// 不依赖 SessionLayer —— 它不写盘,写盘由父回合循环在 drain 点做。
5556
const SubagentRunRegistryWithDeps = SubagentRunRegistryLayer.pipe(
56-
Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer))
57+
Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer, HookLayer))
5758
);
5859

5960
export const AppLayer = Layer.mergeAll(

‎packages/codingcode/src/server/routes/messages.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void {
1515

1616
router.post('/api/sessions/:id/messages', async (c) => {
1717
let sessionId = c.req.param('id');
18-
const { input, cwd, model } = await c.req.json<{ input: string; cwd: string; model?: string }>();
18+
const { input, cwd, model, skills } = await c.req.json<{
19+
input: string;
20+
cwd: string;
21+
model?: string;
22+
skills?: Array<{ name: string; path: string }>;
23+
}>();
1924
// 模型是回合的必要输入,缺失即拒绝,不允许在 agent 层兜底成空串
2025
if (!model?.trim()) {
2126
return c.json(errorBody('CONFIG_MISSING', 'model is required'), 400);
@@ -28,6 +33,7 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void {
2833
cwd: normalizedCwd,
2934
signal: c.req.raw.signal,
3035
model,
36+
skills,
3137
};
3238
if (isNew) {
3339
// 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准

‎packages/codingcode/src/server/routes/skills.ts‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { Hono } from 'hono';
2+
import { dirname } from 'path';
23
import { Effect, ManagedRuntime } from 'effect';
34
import { SkillService } from '../../skills/port.js';
45
import { isGlobalCwd, resolveCwd } from '../cwd.js';
5-
import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js';
6+
import { discoverGlobalSkillDirs } from '../../skills/source.js';
67
import { createRunWithLayer } from '../util.js';
78

89
type ManagedRt = ManagedRuntime.ManagedRuntime<any, any>;
@@ -29,28 +30,20 @@ export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void
2930
);
3031
}
3132
const cwd = resolveCwd(rawCwd);
32-
const globalDirs = discoverGlobalSkillDirs();
33-
const projectDirs = discoverProjectSkillDirs(cwd);
34-
const globalNames = new Set(globalDirs.map((d) => d.name));
35-
const projectNames = new Set(projectDirs.map((d) => d.name));
33+
const globalDirPaths = new Set(discoverGlobalSkillDirs().map((d) => d.dirPath));
3634
const result = await runWithLayer(
3735
Effect.gen(function* () {
3836
const skill = yield* SkillService;
3937
return yield* skill.getAll(cwd);
4038
})
4139
);
4240
const skills = result.ok ? result.value : [];
41+
// 按 SKILL.md 所在目录判定来源;同名同时存在于全局与项目时,两条各自标注真实来源
4342
return c.json(
44-
skills.map((s) => {
45-
const isFromProject = projectNames.has(s.name);
46-
const isFromGlobal = globalNames.has(s.name);
47-
const hasProjectOverride = isFromProject && isFromGlobal;
48-
return {
49-
...s,
50-
source: isFromProject ? 'project' : 'global',
51-
hasProjectOverride,
52-
};
53-
})
43+
skills.map((s) => ({
44+
...s,
45+
source: globalDirPaths.has(dirname(s.skillPath)) ? ('global' as const) : ('project' as const),
46+
}))
5447
);
5548
});
5649
}

‎packages/codingcode/src/skills/port.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Skill } from '../contracts/skill.js';
44

55
export interface SkillShape {
66
getAll(projectPath: string): Effect.Effect<Skill[]>;
7-
extractSkill(projectPath: string, query: string): Effect.Effect<[Skill | undefined, string]>;
7+
readContent(skillPath: string): Effect.Effect<string>;
88
}
99

1010
export class SkillService extends Context.Tag('Skill')<SkillService, SkillShape>() {}

‎packages/codingcode/src/skills/skills.ts‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Layer, Effect } from 'effect';
2-
import { discoverSkillDirs } from './source.js';
2+
import { discoverSkillDirs, readSkillBody } from './source.js';
33
import { loadSkill } from './loader.js';
44
import type { Skill } from '../contracts/skill.js';
55
import { SkillService } from './port.js';
@@ -23,16 +23,6 @@ export const SkillLayer = Layer.effect(SkillService, Effect.gen(function* () {
2323
return {
2424
getAll: (projectPath: string) => Effect.sync(() => readAll(projectPath)),
2525

26-
extractSkill: (projectPath: string, query: string) =>
27-
Effect.sync(() => {
28-
const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/);
29-
let skill: Skill | undefined;
30-
if (match) {
31-
const name = match[1]!;
32-
skill = readAll(projectPath).find((s) => s.name === name);
33-
}
34-
const actualQuery = query.replace(/^@[a-zA-Z0-9-]+\s*/, '');
35-
return [skill, actualQuery] as [Skill | undefined, string];
36-
}),
26+
readContent: (skillPath: string) => Effect.sync(() => readSkillBody(skillPath)),
3727
};
3828
}));

‎packages/codingcode/src/skills/source.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ export interface SkillDirectory {
1515
name: string;
1616
}
1717

18-
/** 全局在前、项目在后:同名 skill 由后者覆盖(消费方按 name 去重,后写者胜)。 */
1918
export function discoverSkillDirs(projectRoot: string): SkillDirectory[] {
2019
return [...discoverGlobalSkillDirs(), ...discoverProjectSkillDirs(projectRoot)];
2120
}
2221

22+
/** 读取 SKILL.md 全文(含 front matter),供拼 skill 提示块使用。 */
23+
export function readSkillBody(skillPath: string): string {
24+
return readFileSync(skillPath, 'utf8');
25+
}
26+
2327
/** Parse only the SKILL.md front matter used for skill discovery. */
2428
export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null {
2529
const skillMdPath = join(dirPath, 'SKILL.md');
@@ -41,7 +45,7 @@ export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null {
4145

4246
// ---- 辅助函数:分别获取全局/项目级 Skill 目录 ----
4347

44-
/** `~/.codingcode/skills/` —— 先加载,同名的项目级 skill 会覆盖它 */
48+
/** `~/.codingcode/skills/` —— 全局 skill 目录 */
4549
export function discoverGlobalSkillDirs(): SkillDirectory[] {
4650
const dirs: SkillDirectory[] = [];
4751
const globalSkillsDir = join(getGlobalDir(), 'skills');
@@ -56,7 +60,7 @@ export function discoverGlobalSkillDirs(): SkillDirectory[] {
5660
return dirs;
5761
}
5862

59-
/** `<projectRoot>/.codingcode/skills/` —— 后加载,优先级更高 */
63+
/** `<projectRoot>/.codingcode/skills/` —— 项目级 skill 目录 */
6064
export function discoverProjectSkillDirs(projectRoot: string): SkillDirectory[] {
6165
const dirs: SkillDirectory[] = [];
6266
const projectSkillsDir = join(projectRoot, CODINGCODE_DIRNAME, 'skills');

‎packages/codingcode/src/subagent/registry.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { loadConfig } from '../infra/config.js';
88
import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js';
99
import { MailboxService } from '../session/mailbox.js';
1010
import { EventSinkService } from '../sink/port.js';
11+
import { HookService } from '../hooks/port.js';
1112
import { SubagentRunnerService } from './port.js';
1213

1314
export type SubagentRunStatus =
@@ -46,6 +47,7 @@ export class SubagentRunRegistryService extends Context.Tag('SubagentRunRegistry
4647
interface SubagentRun {
4748
readonly sessionId: string;
4849
readonly parentSessionId: string;
50+
readonly parentCwd: string;
4951
readonly agentName: string;
5052
readonly status: SubscriptionRef.SubscriptionRef<SubagentRunStatus>;
5153
readonly abort: AbortController;
@@ -116,6 +118,7 @@ export const SubagentRunRegistryLayer = Layer.scoped(
116118
const mailbox = yield* MailboxService;
117119
const runner = yield* SubagentRunnerService;
118120
const sink = yield* EventSinkService;
121+
const hooks = yield* HookService;
119122
const runs = new Map<string, SubagentRun>(); // 键 = 子会话 sessionId;parentSessionId 只是条目上的字段
120123

121124
/** 帧直投父会话的出站队列:EventSink 的键就是收件人会话,不需要任何回调透传 */
@@ -159,6 +162,12 @@ export const SubagentRunRegistryLayer = Layer.scoped(
159162
run.parentSessionId, run.sessionId, run.agentName,
160163
outcome.end.reason === 'done' ? 'completed' : 'failed',
161164
);
165+
yield* hooks.emit('agent.subagent.complete', {
166+
projectPath: run.parentCwd,
167+
childSessionId: run.sessionId,
168+
agentName: run.agentName,
169+
status: outcome.end.reason === 'done' ? 'completed' : 'failed',
170+
});
162171
});
163172

164173
const spawn = (opts: SpawnOptions) =>
@@ -183,8 +192,8 @@ export const SubagentRunRegistryLayer = Layer.scoped(
183192

184193
const status = yield* SubscriptionRef.make<SubagentRunStatus>({ kind: 'running' });
185194
const run: SubagentRun = {
186-
sessionId, parentSessionId: opts.parentSessionId, agentName: opts.agentName,
187-
status, abort,
195+
sessionId, parentSessionId: opts.parentSessionId, parentCwd: opts.parentCwd,
196+
agentName: opts.agentName, status, abort,
188197
};
189198
runs.set(sessionId, run);
190199
yield* emitSubagent(opts.parentSessionId, sessionId, opts.agentName, 'spawned');

0 commit comments

Comments
 (0)