mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
feat(core): wire runtime skills into chat
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user