@danypops/papyrus 0.12.0 → 0.13.1
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/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +242 -0
- package/extension/src/context-view.ts +177 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/index.ts +43 -1
- package/extension/src/notes.ts +2 -3
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-widget.ts +13 -1
- package/package.json +1 -1
- package/src/constants.ts +39 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain-services.ts +69 -3
- package/src/skill-execution.ts +169 -75
package/extension/src/rules.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { RULE_STATUS_PRESENTATION, severityColor } from "./artifact-status-presentation.ts";
|
|
4
5
|
import { callService } from "./service-client.ts";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"].toUpperCase() : "INFO";
|
|
7
|
+
export function ruleRowMeta(rule: Artifact, theme: Theme): string {
|
|
8
|
+
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"] : "info";
|
|
9
|
+
const severityText = theme.fg(severityColor(severity), severity.toUpperCase());
|
|
10
10
|
const condition = typeof rule.extra["condition"] === "string" ? `when ${rule.extra["condition"]}` : "always";
|
|
11
|
-
return `${
|
|
11
|
+
return `${severityText} · ${condition}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function ruleInjectionPreview(rule: Pick<Artifact, "title" | "body" | "extra">): string {
|
|
@@ -23,7 +23,7 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
23
23
|
title: "Rules",
|
|
24
24
|
listOperation: "rules.list",
|
|
25
25
|
statusOrder: ["active", "deprecated"],
|
|
26
|
-
|
|
26
|
+
presentation: RULE_STATUS_PRESENTATION,
|
|
27
27
|
rowMeta: ruleRowMeta,
|
|
28
28
|
actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
29
29
|
handleAction: async (choice, rule, commandCtx) => {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pi-native skills (SKILL.md) carry a real, permanent context tax independent of Papyrus:
|
|
7
|
+
* per Pi's own docs, every discovered skill's name+description is injected into the system
|
|
8
|
+
* prompt unconditionally at startup (the Agent Skills spec's "catalog" tier, ~50-100 tokens
|
|
9
|
+
* per skill). This module measures that tax by replicating Pi's own documented discovery
|
|
10
|
+
* rules (docs/skills.md "Locations" section) directly against the filesystem, rather than
|
|
11
|
+
* trying to parse it back out of the assembled system prompt -- Pi does not document (and
|
|
12
|
+
* this repo must not depend on) the exact wire format it uses to inject the catalog, so
|
|
13
|
+
* re-deriving the same inputs Pi itself reads is the robust approach, not a fragile one.
|
|
14
|
+
* Package-declared skills (pi.skills in package.json / packages' own skills/ directories)
|
|
15
|
+
* are deliberately out of scope: enumerating every installed package for skill declarations
|
|
16
|
+
* is a materially larger, slower scan than reading a handful of known directories, and this
|
|
17
|
+
* tool is a budget estimate, not an exhaustive audit.
|
|
18
|
+
*/
|
|
19
|
+
export interface SkillCatalogEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
location: string;
|
|
23
|
+
characters: number;
|
|
24
|
+
estimatedTokens: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SkillCatalogFootprint {
|
|
28
|
+
entries: SkillCatalogEntry[];
|
|
29
|
+
totalCharacters: number;
|
|
30
|
+
totalEstimatedTokens: number;
|
|
31
|
+
scannedDirectories: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SKILL_SCAN_MAX_DEPTH = 6;
|
|
35
|
+
export const SKILL_SCAN_MAX_DIRECTORIES = 2000;
|
|
36
|
+
export const SKILL_SCAN_MAX_SKILLS = 500;
|
|
37
|
+
|
|
38
|
+
function unquote(value: string): string {
|
|
39
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
40
|
+
return value.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extracts `name` and `description` from a SKILL.md's YAML frontmatter, tolerating the
|
|
47
|
+
* folded (`>`) and literal (`|`) block-scalar forms real-world skills commonly use for
|
|
48
|
+
* multi-line descriptions. Deliberately not a general YAML parser -- only the two fields
|
|
49
|
+
* the Agent Skills spec requires are extracted; anything else in the frontmatter is ignored.
|
|
50
|
+
*/
|
|
51
|
+
export function parseSkillFrontmatter(content: string): { name: string; description: string } | null {
|
|
52
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
const lines = match[1]!.split(/\r?\n/);
|
|
55
|
+
let name = "";
|
|
56
|
+
let description = "";
|
|
57
|
+
for (let index = 0; index < lines.length; index++) {
|
|
58
|
+
const line = lines[index]!;
|
|
59
|
+
const nameMatch = line.match(/^name:\s*(.*)$/);
|
|
60
|
+
if (nameMatch) {
|
|
61
|
+
name = unquote(nameMatch[1]!.trim());
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const descriptionMatch = line.match(/^description:\s*(.*)$/);
|
|
65
|
+
if (!descriptionMatch) continue;
|
|
66
|
+
const rest = descriptionMatch[1]!.trim();
|
|
67
|
+
if (rest === ">" || rest === ">-" || rest === "|" || rest === "|-") {
|
|
68
|
+
const collected: string[] = [];
|
|
69
|
+
let cursor = index + 1;
|
|
70
|
+
while (cursor < lines.length && (lines[cursor] === "" || /^\s+/.test(lines[cursor]!))) {
|
|
71
|
+
collected.push(lines[cursor]!.trim());
|
|
72
|
+
cursor++;
|
|
73
|
+
}
|
|
74
|
+
description = collected.join(rest.startsWith("|") ? "\n" : " ").trim();
|
|
75
|
+
index = cursor - 1;
|
|
76
|
+
} else {
|
|
77
|
+
description = unquote(rest);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!name || !description) return null;
|
|
81
|
+
return { name, description };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True at the filesystem root on POSIX (`/`) and Windows (`C:\`, `D:\`, ...). */
|
|
85
|
+
function isFilesystemRoot(path: string): boolean {
|
|
86
|
+
return dirname(path) === path;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Global and project skill directories per Pi's own documented discovery rules, plus any
|
|
91
|
+
* explicit paths configured in settings.json's `skills` array. Project directories are
|
|
92
|
+
* collected walking from `cwd` up to the git repository root (or filesystem root when not
|
|
93
|
+
* in a repo), matching "up to git repo root, or filesystem root when not in a repo" exactly.
|
|
94
|
+
*/
|
|
95
|
+
export function discoverSkillDirectories(homeDirectory: string, cwd: string, settingsSkills: readonly string[] = []): string[] {
|
|
96
|
+
const directories = [join(homeDirectory, ".pi", "agent", "skills"), join(homeDirectory, ".agents", "skills")];
|
|
97
|
+
let current = cwd;
|
|
98
|
+
for (let depth = 0; depth < SKILL_SCAN_MAX_DIRECTORIES; depth++) {
|
|
99
|
+
directories.push(join(current, ".pi", "skills"), join(current, ".agents", "skills"));
|
|
100
|
+
if (existsSync(join(current, ".git")) || isFilesystemRoot(current)) break;
|
|
101
|
+
current = dirname(current);
|
|
102
|
+
}
|
|
103
|
+
directories.push(...settingsSkills);
|
|
104
|
+
return [...new Set(directories)];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface ScanContext {
|
|
108
|
+
entries: SkillCatalogEntry[];
|
|
109
|
+
seenLocations: Set<string>;
|
|
110
|
+
directoriesVisited: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Root-level .md files count as individual skills only in these two locations, per Pi's docs. */
|
|
114
|
+
function allowsRootMarkdownFiles(directory: string): boolean {
|
|
115
|
+
return directory.endsWith(join(".pi", "agent", "skills")) || directory.endsWith(join(".pi", "skills"));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function recordSkillFile(context: ScanContext, path: string): void {
|
|
119
|
+
if (context.seenLocations.has(path) || context.entries.length >= SKILL_SCAN_MAX_SKILLS) return;
|
|
120
|
+
let content: string;
|
|
121
|
+
try {
|
|
122
|
+
content = readFileSync(path, "utf8");
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseSkillFrontmatter(content);
|
|
127
|
+
if (!parsed) return;
|
|
128
|
+
context.seenLocations.add(path);
|
|
129
|
+
const characters = parsed.name.length + parsed.description.length;
|
|
130
|
+
context.entries.push({
|
|
131
|
+
name: parsed.name,
|
|
132
|
+
description: parsed.description,
|
|
133
|
+
location: path,
|
|
134
|
+
characters,
|
|
135
|
+
estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function walk(context: ScanContext, directory: string, depth: number, allowRootMarkdown: boolean): void {
|
|
140
|
+
if (depth > SKILL_SCAN_MAX_DEPTH || context.directoriesVisited >= SKILL_SCAN_MAX_DIRECTORIES) return;
|
|
141
|
+
context.directoriesVisited++;
|
|
142
|
+
let names: string[];
|
|
143
|
+
try {
|
|
144
|
+
names = readdirSync(directory);
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (name === "node_modules" || name === ".git") continue;
|
|
150
|
+
const path = join(directory, name);
|
|
151
|
+
let stat: ReturnType<typeof statSync>;
|
|
152
|
+
try {
|
|
153
|
+
stat = statSync(path);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (stat.isDirectory()) {
|
|
158
|
+
const skillFile = join(path, "SKILL.md");
|
|
159
|
+
if (existsSync(skillFile)) recordSkillFile(context, skillFile);
|
|
160
|
+
else walk(context, path, depth + 1, false);
|
|
161
|
+
} else if (allowRootMarkdown && depth === 0 && name.toLowerCase().endsWith(".md")) {
|
|
162
|
+
recordSkillFile(context, path);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Bounded, best-effort scan: a missing or unreadable directory is silently skipped, not an error. */
|
|
168
|
+
export function scanSkillCatalogFootprint(directories: readonly string[]): SkillCatalogFootprint {
|
|
169
|
+
const context: ScanContext = { entries: [], seenLocations: new Set(), directoriesVisited: 0 };
|
|
170
|
+
const scanned: string[] = [];
|
|
171
|
+
for (const directory of directories) {
|
|
172
|
+
if (!existsSync(directory) || !statSync(directory).isDirectory()) continue;
|
|
173
|
+
scanned.push(directory);
|
|
174
|
+
walk(context, directory, 0, allowsRootMarkdownFiles(directory));
|
|
175
|
+
}
|
|
176
|
+
const entries = context.entries.sort((a, b) => b.characters - a.characters);
|
|
177
|
+
return {
|
|
178
|
+
entries,
|
|
179
|
+
totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
|
|
180
|
+
totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
|
|
181
|
+
scannedDirectories: scanned,
|
|
182
|
+
};
|
|
183
|
+
}
|
package/extension/src/skills.ts
CHANGED
|
@@ -3,11 +3,10 @@ import type { Artifact } from "../../src/domain/artifact.ts";
|
|
|
3
3
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
4
4
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
5
5
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
6
|
+
import { SKILL_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
6
7
|
import { callService } from "./service-client.ts";
|
|
7
8
|
import { showTaskGraph } from "./task-graph.ts";
|
|
8
9
|
|
|
9
|
-
const SKILL_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
|
|
10
|
-
|
|
11
10
|
function strings(value: unknown): string[] {
|
|
12
11
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
13
12
|
}
|
|
@@ -73,7 +72,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
73
72
|
title: "Skills",
|
|
74
73
|
listOperation: "skills.list",
|
|
75
74
|
statusOrder: ["active", "deprecated"],
|
|
76
|
-
|
|
75
|
+
presentation: SKILL_STATUS_PRESENTATION,
|
|
77
76
|
rowMeta: skillRowMeta,
|
|
78
77
|
actions: (skill) => [
|
|
79
78
|
"Show details",
|
|
@@ -8,6 +8,18 @@ export interface TaskWidgetRow {
|
|
|
8
8
|
hasOpenChildren: boolean;
|
|
9
9
|
active: boolean;
|
|
10
10
|
focusStatus?: "active" | "paused";
|
|
11
|
+
/**
|
|
12
|
+
* Task containment is a DAG, not a tree: a task may have more than one parent (design
|
|
13
|
+
* decision -- see decide-and-execute... no single-parent enforcement was ever wanted).
|
|
14
|
+
* This bounded widget still renders one spanning tree (it only has room for one position
|
|
15
|
+
* per task), so a multi-parent task is only ever shown once, under whichever parent this
|
|
16
|
+
* walk reaches first -- exactly the git-log-graph / npm-ls-dedup pattern of picking one
|
|
17
|
+
* canonical position and flagging the rest, rather than silently dropping the information.
|
|
18
|
+
* parentCount > 1 means "this task also lives under other parents not shown here" --
|
|
19
|
+
* the full DAG (every parent edge, not just one) is always available via the task graph's
|
|
20
|
+
* composition view, which renders true multi-parent edges through Mermaid's flowchart layout.
|
|
21
|
+
*/
|
|
22
|
+
parentCount: number;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
25
|
export interface TaskWidgetProjection {
|
|
@@ -36,7 +48,7 @@ export function buildTaskWidgetProjection(
|
|
|
36
48
|
if (!node) return;
|
|
37
49
|
visited.add(id);
|
|
38
50
|
const open = isOpen(node.task);
|
|
39
|
-
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus });
|
|
51
|
+
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus, parentCount: node.parentIds.length });
|
|
40
52
|
const childDepth = open ? openDepth + 1 : openDepth;
|
|
41
53
|
for (const childId of node.childIds) visit(childId, childDepth);
|
|
42
54
|
};
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -30,6 +30,23 @@ export const PAPYRUS_TASK_FOCUS_CHANNEL = "papyrus.task-focus.v1";
|
|
|
30
30
|
export const PAPYRUS_TASK_FOCUS_SCHEMA = "papyrus.task-focus/v1";
|
|
31
31
|
export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system
|
|
35
|
+
* prompt for the lifetime of the rule -- the same permanent, always-on-context role as an
|
|
36
|
+
* Agent Skill's name+description (per the Agent Skills spec's progressive-disclosure model:
|
|
37
|
+
* metadata ~100 tokens, always loaded; full instructions <5000 tokens, loaded only on
|
|
38
|
+
* activation). Anthropic's own context-engineering guidance is not a hard length rule but a
|
|
39
|
+
* signal-density principle -- "the smallest possible set of high-signal tokens", explicitly
|
|
40
|
+
* NOT "minimal means short" -- so RULE_TEXT_SOFT_TARGET_CHARACTERS is a target to aim for,
|
|
41
|
+
* not a rejection threshold. RULE_TEXT_HARD_LIMIT_CHARACTERS is the actual enforced ceiling,
|
|
42
|
+
* generous enough to allow a real rule to breathe, but catching genuinely runaway bloat that
|
|
43
|
+
* would tax every single turn. Above the hard limit, split into a short Rule (condition +
|
|
44
|
+
* the invariant) plus a linked Doc for full reasoning -- the pattern this codebase's own
|
|
45
|
+
* active rules already use via "Source: Lexicon <path>" references.
|
|
46
|
+
*/
|
|
47
|
+
export const RULE_TEXT_SOFT_TARGET_CHARACTERS = 600;
|
|
48
|
+
export const RULE_TEXT_HARD_LIMIT_CHARACTERS = 4000;
|
|
49
|
+
|
|
33
50
|
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
34
51
|
export const TASK_CONTEXT_CURRENT_LIMIT = 3;
|
|
35
52
|
export const TASK_CONTEXT_REJECTED_LIMIT = 3;
|
|
@@ -57,6 +74,28 @@ export const SKILL_MAX_ENUM_VALUES = 32;
|
|
|
57
74
|
export const SKILL_MAX_BLUEPRINTS = 100;
|
|
58
75
|
export const SKILL_MAX_LINKS = 500;
|
|
59
76
|
export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Skills are special: invoking one queries Papyrus for whatever it's actually graph-linked
|
|
80
|
+
* to (existing Tasks/Rules/Docs via ordinary edges, not just its own static body/extra
|
|
81
|
+
* fields), and a Skill can link to and invoke other Skills. Both traversals are bounded and
|
|
82
|
+
* cycle-safe -- a skill-calls-skill edge cycle must not infinite-loop invocation, matching
|
|
83
|
+
* the cycle-safety discipline already established for ConversationJournal reply chains and
|
|
84
|
+
* task dependency graphs.
|
|
85
|
+
*/
|
|
86
|
+
export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
|
|
87
|
+
export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
|
|
91
|
+
* a Jenkins job, not just a text prompt. A pipeline step can itself trigger another workflow
|
|
92
|
+
* Skill's run (a nested sub-pipeline, like a Jenkins job triggering a downstream job and
|
|
93
|
+
* waiting for it), bounded and cycle-safe: a real skill-calls-skill cycle during EXECUTION
|
|
94
|
+
* (not just invocation preview) must fail loudly and roll back the whole atomic run, not
|
|
95
|
+
* silently truncate, since a silently-truncated pipeline would leave a confusing partial
|
|
96
|
+
* Task graph behind.
|
|
97
|
+
*/
|
|
98
|
+
export const SKILL_WORKFLOW_MAX_NESTING_DEPTH = 4;
|
|
60
99
|
export const SKILL_RUN_ID_MAX_LENGTH = 64;
|
|
61
100
|
/** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
|
|
62
101
|
export const TASK_DRIVER_MAX_TURNS = 20;
|
|
@@ -46,10 +46,29 @@ export interface SkillTaskBlueprint {
|
|
|
46
46
|
extra?: Record<string, unknown>;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A pipeline step that nests another workflow Skill's run inside this one -- the Jenkins
|
|
51
|
+
* "trigger downstream job and wait" / Ansible "include_tasks" primitive. `skillId` is late-
|
|
52
|
+
* bound: existence and workflow-subtype are checked at execution time (skill-execution.ts),
|
|
53
|
+
* not here, since this validator has no store access. `dependsOn`/`parent` place this step in
|
|
54
|
+
* the SAME dependency graph as ordinary task blueprints -- a task can depend on a skill-call
|
|
55
|
+
* ref (meaning: depend on every task the nested run creates), and a skill-call's own `parent`
|
|
56
|
+
* contains the nested run's root tasks under an outer task.
|
|
57
|
+
*/
|
|
58
|
+
export interface SkillCallBlueprint {
|
|
59
|
+
ref: string;
|
|
60
|
+
title: string;
|
|
61
|
+
skillId: string;
|
|
62
|
+
arguments?: Record<string, unknown>;
|
|
63
|
+
dependsOn?: string[];
|
|
64
|
+
parent?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
export interface SkillBlueprints {
|
|
50
68
|
docs: SkillDocBlueprint[];
|
|
51
69
|
rules: SkillRuleBlueprint[];
|
|
52
70
|
tasks: SkillTaskBlueprint[];
|
|
71
|
+
skills: SkillCallBlueprint[];
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
export interface SkillBlueprintLink {
|
|
@@ -142,19 +161,34 @@ function placeholders(value: unknown, result: Set<string> = new Set()): Set<stri
|
|
|
142
161
|
return result;
|
|
143
162
|
}
|
|
144
163
|
|
|
145
|
-
|
|
146
|
-
|
|
164
|
+
/** Steps sharing one dependency graph: ordinary tasks and skill-call pipeline steps alike. */
|
|
165
|
+
interface DependentStep {
|
|
166
|
+
ref: string;
|
|
167
|
+
dependsOn?: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertAcyclic(steps: DependentStep[]): void {
|
|
171
|
+
const byRef = new Map(steps.map((step) => [step.ref, step]));
|
|
147
172
|
const visiting = new Set<string>();
|
|
148
173
|
const visited = new Set<string>();
|
|
149
174
|
const visit = (ref: string): void => {
|
|
150
|
-
if (visiting.has(ref)) throw new Error(`skill
|
|
175
|
+
if (visiting.has(ref)) throw new Error(`skill step dependency cycle includes "${ref}"`);
|
|
151
176
|
if (visited.has(ref)) return;
|
|
152
177
|
visiting.add(ref);
|
|
153
178
|
for (const dependency of byRef.get(ref)?.dependsOn ?? []) visit(dependency);
|
|
154
179
|
visiting.delete(ref);
|
|
155
180
|
visited.add(ref);
|
|
156
181
|
};
|
|
157
|
-
for (const
|
|
182
|
+
for (const step of steps) visit(step.ref);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateSkillCallBlueprint(value: unknown): SkillCallBlueprint {
|
|
186
|
+
const source = record(value, "skill call blueprint");
|
|
187
|
+
const ref = string(source["ref"], "skill call blueprint ref");
|
|
188
|
+
if (!NAME_PATTERN.test(ref)) throw new Error(`invalid skill blueprint ref "${ref}"`);
|
|
189
|
+
const title = string(source["title"], "skill call blueprint title");
|
|
190
|
+
const skillId = string(source["skillId"], "skill call blueprint skillId");
|
|
191
|
+
return { ...source, ref, title, skillId } as SkillCallBlueprint;
|
|
158
192
|
}
|
|
159
193
|
|
|
160
194
|
export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
@@ -165,23 +199,38 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
|
165
199
|
const docs = array(rawBlueprints["docs"] ?? [], "skill doc blueprints").map((entry) => validateBlueprint<SkillDocBlueprint>(entry, "doc"));
|
|
166
200
|
const rules = array(rawBlueprints["rules"] ?? [], "skill rule blueprints").map((entry) => validateBlueprint<SkillRuleBlueprint>(entry, "rule"));
|
|
167
201
|
const tasks = array(rawBlueprints["tasks"] ?? [], "skill task blueprints").map((entry) => validateBlueprint<SkillTaskBlueprint>(entry, "task"));
|
|
168
|
-
const
|
|
202
|
+
const skillCalls = array(rawBlueprints["skills"] ?? [], "skill call blueprints").map(validateSkillCallBlueprint);
|
|
203
|
+
const all = [...docs, ...rules, ...tasks, ...skillCalls];
|
|
169
204
|
if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
|
|
170
205
|
const refs = new Set<string>();
|
|
171
206
|
for (const blueprint of all) {
|
|
172
207
|
if (refs.has(blueprint.ref)) throw new Error(`duplicate skill blueprint ref "${blueprint.ref}"`);
|
|
173
208
|
refs.add(blueprint.ref);
|
|
174
209
|
}
|
|
210
|
+
// Tasks and skill-call pipeline steps share one dependency graph: a task may depend on a
|
|
211
|
+
// skill-call ref (meaning: depend on every task that nested run creates), and vice versa.
|
|
212
|
+
const stepRefs = new Set<string>([...tasks.map((task) => task.ref), ...skillCalls.map((call) => call.ref)]);
|
|
175
213
|
for (const task of tasks) {
|
|
176
214
|
if (task.dependsOn !== undefined && !Array.isArray(task.dependsOn)) throw new Error(`skill task "${task.ref}" dependsOn must be an array`);
|
|
177
215
|
for (const dependency of task.dependsOn ?? []) {
|
|
178
|
-
if (!
|
|
216
|
+
if (!stepRefs.has(dependency)) throw new Error(`unknown skill task dependency ref "${dependency}"`);
|
|
179
217
|
}
|
|
218
|
+
// parent stays task-only: containment under a skill-call step's exploded task SET has no
|
|
219
|
+
// single natural parent, so parent must name an actual task blueprint.
|
|
180
220
|
if (task.parent !== undefined && !tasks.some((candidate) => candidate.ref === task.parent)) {
|
|
181
221
|
throw new Error(`unknown skill task parent ref "${task.parent}"`);
|
|
182
222
|
}
|
|
183
223
|
}
|
|
184
|
-
|
|
224
|
+
for (const call of skillCalls) {
|
|
225
|
+
if (call.dependsOn !== undefined && !Array.isArray(call.dependsOn)) throw new Error(`skill call "${call.ref}" dependsOn must be an array`);
|
|
226
|
+
for (const dependency of call.dependsOn ?? []) {
|
|
227
|
+
if (!stepRefs.has(dependency)) throw new Error(`unknown skill call dependency ref "${dependency}"`);
|
|
228
|
+
}
|
|
229
|
+
if (call.parent !== undefined && !tasks.some((candidate) => candidate.ref === call.parent)) {
|
|
230
|
+
throw new Error(`unknown skill call parent ref "${call.parent}"`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
assertAcyclic([...tasks, ...skillCalls]);
|
|
185
234
|
for (const name of placeholders(all)) {
|
|
186
235
|
if (!Object.hasOwn(inputs, name)) throw new Error(`unknown skill input placeholder "${name}"`);
|
|
187
236
|
}
|
|
@@ -196,7 +245,7 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
|
196
245
|
return { from, relation, to };
|
|
197
246
|
});
|
|
198
247
|
if (links.length > SKILL_MAX_LINKS) throw new Error(`skill links exceed ${SKILL_MAX_LINKS}`);
|
|
199
|
-
return { version: 1, inputs, blueprints: { docs, rules, tasks }, links };
|
|
248
|
+
return { version: 1, inputs, blueprints: { docs, rules, tasks, skills: skillCalls }, links };
|
|
200
249
|
}
|
|
201
250
|
|
|
202
251
|
export function resolveSkillArguments(definition: SkillDefinition, value: unknown): Record<string, SkillArgumentValue> {
|
package/src/domain-services.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ARTIFACT_SCOPE_MAX_ARTIFACTS,
|
|
3
|
+
RULE_TEXT_HARD_LIMIT_CHARACTERS,
|
|
4
|
+
SKILL_INVOCATION_MAX_CALL_DEPTH,
|
|
5
|
+
SKILL_INVOCATION_MAX_LINKED_ARTIFACTS,
|
|
6
|
+
} from "./constants.ts";
|
|
2
7
|
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
3
8
|
import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
4
9
|
import { normalizeProjectRoot } from "./domain/task-scope.ts";
|
|
@@ -174,7 +179,27 @@ export interface CreateRuleInput {
|
|
|
174
179
|
|
|
175
180
|
export type RuleTransition = "enable" | "disable";
|
|
176
181
|
|
|
182
|
+
/**
|
|
183
|
+
* A Rule's condition+action+body is injected into every relevant turn for the rule's entire
|
|
184
|
+
* lifetime -- a permanent tax on every future turn's context budget, not a one-time cost.
|
|
185
|
+
* Rejects (rather than silently truncating or merely warning) once a rule is unambiguously
|
|
186
|
+
* bloated, since a silently-truncated rule would inject different text than what its author
|
|
187
|
+
* reviewed, and a warning nobody reads is not a bound. See RULE_TEXT_HARD_LIMIT_CHARACTERS's
|
|
188
|
+
* own comment in constants.ts for the research this threshold is grounded in.
|
|
189
|
+
*/
|
|
190
|
+
function assertRuleTextWithinBounds(condition: string | undefined, action: string | undefined, body: string | undefined): void {
|
|
191
|
+
const combined = (condition ?? "").length + (action ?? "").length + (body ?? "").length;
|
|
192
|
+
if (combined > RULE_TEXT_HARD_LIMIT_CHARACTERS) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`rule condition+action+body is ${combined} characters, exceeding the ${RULE_TEXT_HARD_LIMIT_CHARACTERS}-character bound. ` +
|
|
195
|
+
"A Rule is injected into every relevant turn for its entire lifetime -- this is a permanent context-budget tax, not a one-time cost. " +
|
|
196
|
+
"Split it: keep a short Rule (the condition and the invariant itself), and move the full reasoning, examples, and research into a linked Doc.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
177
201
|
export function createRule(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateRuleInput, context?: ArtifactEventContext): Artifact {
|
|
202
|
+
assertRuleTextWithinBounds(input.condition, input.action, input.body);
|
|
178
203
|
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
179
204
|
const rule = artifacts.create({
|
|
180
205
|
kind: "rule",
|
|
@@ -328,8 +353,7 @@ export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
|
|
|
328
353
|
return artifacts.get(id, { tree: true })!;
|
|
329
354
|
}
|
|
330
355
|
|
|
331
|
-
|
|
332
|
-
const skill = requireKind(artifacts, id, "skill");
|
|
356
|
+
function skillInvocationBody(skill: Artifact): string {
|
|
333
357
|
if (skill.subtype === "artifact-template") {
|
|
334
358
|
return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
|
|
335
359
|
}
|
|
@@ -356,6 +380,48 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
|
|
|
356
380
|
].join("\n");
|
|
357
381
|
}
|
|
358
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Skills are special: invoking one queries Papyrus for the skill's real outgoing graph edges
|
|
385
|
+
* -- not just its own static body/extra fields -- so a Skill linked to existing Tasks, Rules,
|
|
386
|
+
* or Docs surfaces that linked context on invocation. A Skill can also link to and invoke
|
|
387
|
+
* OTHER Skills (any relation whose target is itself a Skill, e.g. the same "triggers" relation
|
|
388
|
+
* workflow execution already uses for skill-to-task edges): invoking the parent recursively
|
|
389
|
+
* composes the linked skill's own invocation. Bounded and cycle-safe -- a skill-calls-skill
|
|
390
|
+
* edge cycle degrades to a marker instead of infinite-looping, matching the cycle-safety
|
|
391
|
+
* discipline already established for ConversationJournal reply chains and task dependency
|
|
392
|
+
* graphs. `visited` and `depth` are recursion-internal; callers should not pass them.
|
|
393
|
+
*/
|
|
394
|
+
export function skillInvocation(artifacts: ArtifactStore, id: string, visited: Set<string> = new Set(), depth = 0): string {
|
|
395
|
+
const skill = requireKind(artifacts, id, "skill");
|
|
396
|
+
visited.add(id);
|
|
397
|
+
const sections = [skillInvocationBody(skill)];
|
|
398
|
+
|
|
399
|
+
const edges = artifacts.relationships({ artifactIds: [id] }).filter((edge) => edge.from === id).slice(0, SKILL_INVOCATION_MAX_LINKED_ARTIFACTS);
|
|
400
|
+
const linkedArtifactLines: string[] = [];
|
|
401
|
+
const linkedSkillSections: string[] = [];
|
|
402
|
+
for (const edge of edges) {
|
|
403
|
+
const target = artifacts.get(edge.to);
|
|
404
|
+
if (!target) continue; // dangling edge -- defensive, should not happen
|
|
405
|
+
if (target.kind !== "skill") {
|
|
406
|
+
linkedArtifactLines.push(`- ${edge.relation} ${target.kind} "${target.title}" (${target.id})`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (visited.has(target.id)) {
|
|
410
|
+
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" (${target.id}) -- already invoked above in this chain, not repeated.`);
|
|
411
|
+
} else if (depth + 1 > SKILL_INVOCATION_MAX_CALL_DEPTH) {
|
|
412
|
+
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" (${target.id}) -- call depth limit reached, invoke it separately.`);
|
|
413
|
+
} else {
|
|
414
|
+
const nested = skillInvocation(artifacts, target.id, visited, depth + 1);
|
|
415
|
+
linkedSkillSections.push(`Also invoke linked skill (${edge.relation}) "${target.title}" (${target.id}):\n${nested}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (linkedArtifactLines.length > 0) {
|
|
419
|
+
sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedArtifactLines].join("\n"));
|
|
420
|
+
}
|
|
421
|
+
for (const section of linkedSkillSections) sections.push(section);
|
|
422
|
+
return sections.join("\n\n");
|
|
423
|
+
}
|
|
424
|
+
|
|
359
425
|
export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition, context?: ArtifactEventContext): Artifact {
|
|
360
426
|
const skill = requireKind(artifacts, id, "skill");
|
|
361
427
|
const expected = action === "enable" ? "deprecated" : "active";
|