feat(core): wire runtime skills into chat

This commit is contained in:
Ma
2026-06-30 19:39:12 +08:00
parent 8a33504509
commit 3595f23e5a
8 changed files with 247 additions and 8 deletions
+32
View File
@@ -93,6 +93,38 @@ This routes through the same conversation executor used by the project TUI, so O
Atomic commands (`plan chapter` / `compose chapter` / `draft` / `audit` / `revise` / `write next`) are still available, but they are now lower-level tools rather than the preferred OpenClaw entry. You can also browse it on [ClawHub](https://clawhub.ai) by searching `inkos`.
### InkOS Runtime Skills
Runtime skills are professional capability packs used by InkOS Chat / Play / long-form writing. They are not the same thing as the ClawHub skill above. A runtime skill provides domain guidance, context needs, and prompt packs; it does not grant extra execution authority. Creating, writing, editing, and image generation still go through Studio tools and confirmation gates.
How to use them:
- Put `SKILL.md` files under `.inkos/skills/<skill-id>/` in a project. Studio Chat loads them at runtime.
- Or set `INKOS_SKILL_DIRS=/abs/path/to/skills`; the path may point to one skill directory or a directory containing multiple skill subdirectories. Use the platform path delimiter for multiple paths.
- Force one for a turn with `@skill-id`, for example: `@detective-play create an evidence-chain open world`.
- Without `@skill-id`, InkOS can auto-select built-in skills from the session kind and trigger phrases, such as long-form writing, open-world play, or interactive film authoring.
Minimal `SKILL.md`:
```md
---
id: detective-play
name: Detective Play
description: Detective evidence and suspect-board play.
whenToUse: Use for open-world detective play and evidence ledgers.
triggers: [detective, evidence]
sessionKinds: [play]
contextNeeds:
- id: evidence-ledger
purpose: Preserve suspect, clue, and evidence chain state.
sources: [world/evidence.md]
tier: protected
appliesTo: [play_step]
retrieval: semantic
---
Use evidence chains; do not turn clues into generic atmosphere.
```
### Configure
InkOS now separates two configuration paths: **Studio uses visual service settings**, while **CLI / daemon / deployment can still use env overrides**. They do not silently overwrite each other.
+32
View File
@@ -103,6 +103,38 @@ inkos interact --json --message "继续当前书,但把节奏再收紧一点"
`plan chapter` / `compose chapter` / `draft` / `audit` / `revise` / `write next` 这些原子命令仍然保留,但更适合作为底层工具,而不是 OpenClaw 的首选入口。也可以在 [ClawHub](https://clawhub.ai) 搜索 `inkos` 在线查看。
### InkOS 运行时 Skill
这里的 skill 指 InkOS Chat/Play/长篇写作内部可使用的专业能力包,和上面的 ClawHub Skill 不是同一个概念。它不会给模型额外执行权限,只提供专业规则、上下文需求和 prompt pack;创建、写入、编辑、生成图片仍然走 Studio 的工具权限和确认闸门。
可用方式:
- 在项目目录放置 `.inkos/skills/<skill-id>/SKILL.md`Studio Chat 会在运行时自动加载。
- 或设置 `INKOS_SKILL_DIRS=/abs/path/to/skills`,可指向单个 skill 目录,也可指向包含多个 skill 子目录的目录。多个目录按系统分隔符分隔。
- 在 Chat 里用 `@skill-id` 强制本轮使用,例如:`@detective-play 做一个证据链驱动的开放世界`
- 不写 `@skill-id` 时,系统会根据 session 类型和触发词自动选择内置 skill,例如长篇、开放世界、互动影游。
最小 `SKILL.md` 示例:
```md
---
id: detective-play
name: Detective Play
description: Detective evidence and suspect-board play.
whenToUse: Use for open-world detective play and evidence ledgers.
triggers: [侦探, evidence]
sessionKinds: [play]
contextNeeds:
- id: evidence-ledger
purpose: Preserve suspect, clue, and evidence chain state.
sources: [world/evidence.md]
tier: protected
appliesTo: [play_step]
retrieval: semantic
---
Use evidence chains; do not turn clues into generic atmosphere.
```
### 配置
当前 InkOS 将 LLM 配置分成两条清晰路径:**Studio 用可视化服务配置****CLI / daemon / 部署环境支持 env 覆盖**。两者不会互相污染。
@@ -96,6 +96,32 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("它不授予执行权限");
expect(prompt).toContain("play.start");
});
it("includes the selected skill body as active guidance", () => {
const skills = createSkillRegistry({
skills: [{
id: "detective-play",
name: "Detective Play",
description: "Detective evidence play.",
whenToUse: "Use for detective evidence chains.",
triggers: ["侦探"],
sessionKinds: ["play"],
promptPacks: [],
toolHints: [],
contextNeeds: [],
body: "Evidence must form a recoverable chain; never turn clues into generic atmosphere.",
source: "external",
}],
}).resolveSkills({
requestedSkills: ["detective-play"],
sessionKind: "chat",
});
const prompt = buildAgentSystemPrompt(null, "en", "chat", { skills });
expect(prompt).toContain("detective-play (forced)");
expect(prompt).toContain("Evidence must form a recoverable chain");
});
});
describe("book-create mode", () => {
@@ -1,9 +1,10 @@
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { delimiter, join, relative } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createSkillRegistry,
loadConfiguredCapabilitySkills,
loadExternalCapabilitySkills,
} from "../skills/index.js";
@@ -106,4 +107,67 @@ describe("external skill loader", () => {
await expect(loadExternalCapabilitySkills({ externalDirs: [relative(process.cwd(), root)] }))
.rejects.toThrow(/absolute/);
});
it("loads project-local skills from .inkos/skills without explicit configuration", async () => {
const skillDir = join(root, ".inkos", "skills", "detective-play");
await mkdir(skillDir, { recursive: true });
await writeFile(
join(skillDir, "SKILL.md"),
[
"---",
"id: detective-play",
"name: Detective Play",
"description: Detective evidence play.",
"whenToUse: Use for detective play.",
"triggers: [侦探]",
"sessionKinds: [play]",
"---",
"Preserve evidence chains.",
].join("\n"),
"utf-8",
);
const loaded = await loadConfiguredCapabilitySkills({
projectRoot: root,
env: {},
userRoot: join(root, "missing-user-root"),
});
expect(loaded.diagnostics).toEqual([]);
expect(loaded.skills.map((skill) => skill.id)).toContain("detective-play");
});
it("loads external skills from INKOS_SKILL_DIRS and reports bad paths without throwing", async () => {
const externalRoot = join(root, "external-skills");
const skillDir = join(externalRoot, "romance-play");
await mkdir(skillDir, { recursive: true });
await writeFile(
join(skillDir, "SKILL.md"),
[
"---",
"id: romance-play",
"name: Romance Play",
"description: Romance interaction skill.",
"whenToUse: Use for romance play.",
"triggers: [恋爱]",
"sessionKinds: [play]",
"---",
"Keep emotional continuity.",
].join("\n"),
"utf-8",
);
const loaded = await loadConfiguredCapabilitySkills({
projectRoot: join(root, "project"),
userRoot: join(root, "user"),
env: {
INKOS_SKILL_DIRS: [externalRoot, join(root, "does-not-exist")].join(delimiter),
},
});
const registry = createSkillRegistry({ skills: loaded.skills });
expect(loaded.skills.map((skill) => skill.id)).toContain("romance-play");
expect(loaded.diagnostics.some((diagnostic) => diagnostic.path.includes("does-not-exist"))).toBe(true);
expect(registry.resolveSkills({ requestedSkills: ["romance-play"] }).forcedSkillIds).toEqual(["romance-play"]);
});
});
+17 -4
View File
@@ -52,7 +52,7 @@ import type { TranscriptEvent, TranscriptRole } from "../interaction/session-tra
import type { PlayMode, SessionKind } from "../interaction/session.js";
import type { ActionPayload, ActionSource, RequestedIntent } from "../interaction/action-envelope.js";
import type { ContextCompressionCallback } from "../models/context-compression.js";
import { createSkillRegistry } from "../skills/index.js";
import { createSkillRegistry, loadConfiguredCapabilitySkills } from "../skills/index.js";
import { assertSafeBookId } from "../utils/book-id.js";
import { PlayStore } from "../play/play-store.js";
import { isLlmStubEnabled, stubAgentStream } from "./llm-stub.js";
@@ -209,13 +209,25 @@ function actionPayloadCacheKey(payload: ActionPayload | undefined): string {
}
function skillResolutionCacheKey(value: {
readonly usedSkills: ReadonlyArray<{ readonly id: string }>;
readonly usedSkills: ReadonlyArray<{
readonly id: string;
readonly source?: string;
readonly whenToUse?: string;
readonly promptPacks?: ReadonlyArray<string>;
readonly body?: string;
}>;
readonly forcedSkillIds: ReadonlyArray<string>;
readonly missingSkillIds: ReadonlyArray<string>;
readonly disabledSkillIds: ReadonlyArray<string>;
}): string {
return JSON.stringify({
used: value.usedSkills.map((skill) => skill.id),
used: value.usedSkills.map((skill) => ({
id: skill.id,
source: skill.source,
whenToUse: skill.whenToUse,
promptPacks: skill.promptPacks ?? [],
body: skill.body ?? "",
})),
forced: value.forcedSkillIds,
missing: value.missingSkillIds,
disabled: value.disabledSkillIds,
@@ -808,7 +820,8 @@ async function runAgentSessionUnlocked(
const requestedIntent = config.requestedIntent;
const actionPayload = config.actionPayload;
const actionPayloadKey = actionPayloadCacheKey(actionPayload);
const skillResolution = createSkillRegistry().resolveSkills({
const configuredSkills = await loadConfiguredCapabilitySkills({ projectRoot });
const skillResolution = createSkillRegistry({ skills: configuredSkills.skills }).resolveSkills({
requestedSkills: config.requestedSkills,
disabledSkills: config.disabledSkills,
sessionKind,
+15 -2
View File
@@ -65,10 +65,16 @@ ${commonOutputRules(false)}`;
function appendSkillGuidance(prompt: string, isZh: boolean, skills: SkillResolutionResult | undefined): string {
if (!skills || skills.usedSkills.length === 0) return prompt;
const skillLines = skills.usedSkills.map((skill) => {
const skillLines = skills.usedSkills.flatMap((skill) => {
const prefix = skills.forcedSkillIds.includes(skill.id) ? (isZh ? "强制" : "forced") : (isZh ? "自动" : "auto");
const packs = skill.promptPacks.length > 0 ? `; promptPacks=${skill.promptPacks.join(", ")}` : "";
return `- ${skill.id} (${prefix}): ${skill.whenToUse}${packs}`;
const line = `- ${skill.id} (${prefix}): ${skill.whenToUse}${packs}`;
const body = skill.body.trim();
if (!body) return [line];
return [
line,
isZh ? ` 领域规则:\n${indentSkillBody(body, " ")}` : ` Domain guidance:\n${indentSkillBody(body, " ")}`,
];
});
const unavailable = skills.missingSkillIds.length > 0
? (isZh
@@ -102,6 +108,13 @@ function appendSkillGuidance(prompt: string, isZh: boolean, skills: SkillResolut
return `${prompt}\n\n${guidance}`;
}
function indentSkillBody(body: string, prefix: string): string {
return body
.split(/\r?\n/)
.map((line) => `${prefix}${line}`)
.join("\n");
}
function buildBookCreatePrompt(isZh: boolean, confirmed: boolean): string {
if (!confirmed) {
return isZh
+58 -1
View File
@@ -1,5 +1,6 @@
import { readFile, readdir, stat } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { homedir } from "node:os";
import { delimiter, isAbsolute, join } from "node:path";
import yaml from "js-yaml";
import {
CapabilitySkillManifestSchema,
@@ -20,6 +21,12 @@ export interface LoadExternalCapabilitySkillsResult {
readonly diagnostics: ReadonlyArray<ExternalSkillDiagnostic>;
}
export interface LoadConfiguredCapabilitySkillsInput {
readonly projectRoot: string;
readonly env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
readonly userRoot?: string;
}
export async function loadExternalCapabilitySkills(
input: LoadExternalCapabilitySkillsInput,
): Promise<LoadExternalCapabilitySkillsResult> {
@@ -42,6 +49,56 @@ export async function loadExternalCapabilitySkills(
return { skills, diagnostics };
}
export async function loadConfiguredCapabilitySkills(
input: LoadConfiguredCapabilitySkillsInput,
): Promise<LoadExternalCapabilitySkillsResult> {
const candidates = configuredSkillDirs(input);
const skills: CapabilitySkillManifest[] = [];
const diagnostics: ExternalSkillDiagnostic[] = [];
for (const candidate of candidates) {
try {
const result = await loadExternalCapabilitySkills({ externalDirs: [candidate.path] });
skills.push(...result.skills);
diagnostics.push(...result.diagnostics);
} catch (error) {
if (!candidate.explicit && isMissingPathError(error)) continue;
diagnostics.push({
path: candidate.path,
message: error instanceof Error ? error.message : String(error),
});
}
}
return { skills, diagnostics };
}
interface ConfiguredSkillDir {
readonly path: string;
readonly explicit: boolean;
}
function configuredSkillDirs(input: LoadConfiguredCapabilitySkillsInput): ConfiguredSkillDir[] {
const env = input.env ?? process.env;
const envDirs = (env.INKOS_SKILL_DIRS ?? "")
.split(delimiter)
.map((value) => value.trim())
.filter(Boolean);
const userRoot = input.userRoot ?? join(homedir(), ".inkos");
return [
{ path: join(input.projectRoot, ".inkos", "skills"), explicit: false },
{ path: join(userRoot, "skills"), explicit: false },
...envDirs.map((path) => ({ path, explicit: true })),
];
}
function isMissingPathError(error: unknown): boolean {
return typeof error === "object"
&& error !== null
&& "code" in error
&& (error as { code?: unknown }).code === "ENOENT";
}
async function discoverSkillDirs(externalDirs: ReadonlyArray<string>): Promise<string[]> {
const dirs: string[] = [];
for (const dir of externalDirs) {
+2
View File
@@ -8,8 +8,10 @@ export {
type SkillContextPlanInput,
} from "./context-planner.js";
export {
loadConfiguredCapabilitySkills,
loadExternalCapabilitySkills,
type ExternalSkillDiagnostic,
type LoadConfiguredCapabilitySkillsInput,
type LoadExternalCapabilitySkillsInput,
type LoadExternalCapabilitySkillsResult,
} from "./external-loader.js";