@danypops/papyrus 0.28.3 → 0.29.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.
@@ -5,6 +5,7 @@ import {
5
5
  } from "../../src/constants.ts";
6
6
  import type { Artifact } from "../../src/domain/artifact.ts";
7
7
  import { ruleInjectionPreview } from "./rules.ts";
8
+ import { playbookInjectionPreview } from "./playbook-bridge.ts";
8
9
 
9
10
  export interface ContextPayloadSize {
10
11
  characters: number;
@@ -18,6 +19,7 @@ export interface PapyrusContextInjectionObservation {
18
19
  producerId: string;
19
20
  before: ContextPayloadSize;
20
21
  rules: ContextPayloadSize & { count: number };
22
+ playbooks: ContextPayloadSize & { count: number };
21
23
  tasks: ContextPayloadSize;
22
24
  injected: ContextPayloadSize;
23
25
  after: ContextPayloadSize;
@@ -30,6 +32,7 @@ export interface PapyrusContextInjectionObservation {
30
32
  export interface BuildContextInjectionInput {
31
33
  basePrompt: string;
32
34
  rules: Array<Pick<Artifact, "title" | "body" | "extra">>;
35
+ playbooks: Array<Pick<Artifact, "title" | "extra">>;
33
36
  taskSummary: string | null;
34
37
  observedAt: number;
35
38
  sequence: number;
@@ -46,13 +49,16 @@ function size(value: string): ContextPayloadSize {
46
49
  export function buildContextInjection(input: BuildContextInjectionInput): {
47
50
  prompt: string;
48
51
  ruleBlock: string;
52
+ playbookBlock: string;
49
53
  taskBlock: string;
50
54
  observation: PapyrusContextInjectionObservation;
51
55
  } {
52
56
  const ruleContent = input.rules.map(ruleInjectionPreview).join("\n");
53
57
  const ruleBlock = ruleContent ? `\n\n## Active rules (Papyrus)\n\n${ruleContent}\n` : "";
58
+ const playbookContent = input.playbooks.map(playbookInjectionPreview).join("\n");
59
+ const playbookBlock = playbookContent ? `\n\n## Available playbooks (Papyrus)\n\n${playbookContent}\n` : "";
54
60
  const taskBlock = input.taskSummary ? `\n\n## Open tasks (Papyrus)\n\n${input.taskSummary}\n` : "";
55
- const injected = `${ruleBlock}${taskBlock}`;
61
+ const injected = `${ruleBlock}${playbookBlock}${taskBlock}`;
56
62
  const prompt = `${input.basePrompt}${injected}`;
57
63
  const fingerprint = createHash("sha256").update(injected).digest("hex");
58
64
  const injectedSize = size(injected);
@@ -60,6 +66,7 @@ export function buildContextInjection(input: BuildContextInjectionInput): {
60
66
  return {
61
67
  prompt,
62
68
  ruleBlock,
69
+ playbookBlock,
63
70
  taskBlock,
64
71
  observation: {
65
72
  schema: PAPYRUS_CONTEXT_INJECTION_SCHEMA,
@@ -68,6 +75,7 @@ export function buildContextInjection(input: BuildContextInjectionInput): {
68
75
  producerId: input.producerId,
69
76
  before: size(input.basePrompt),
70
77
  rules: { ...size(ruleBlock), count: input.rules.length },
78
+ playbooks: { ...size(playbookBlock), count: input.playbooks.length },
71
79
  tasks: size(taskBlock),
72
80
  injected: injectedSize,
73
81
  after: afterSize,
@@ -23,7 +23,7 @@ import { formatMetadata } from "./artifact-format.ts";
23
23
  import { callService } from "./service-client.ts";
24
24
  import { registerDomainTools } from "./domain-tools.ts";
25
25
  import { isLiveAskPending } from "./discuss-ask-view.ts";
26
- import { registerPlaybookBridge } from "./playbook-bridge.ts";
26
+ import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
27
27
  import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
28
28
  import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
29
29
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
@@ -626,13 +626,15 @@ export default async function (pi: ExtensionAPI) {
626
626
  pi.on("before_agent_start", async (event, ctx) => {
627
627
  try {
628
628
  const sessionId = ctx.sessionManager.getSessionId();
629
- const [rules, summary] = await Promise.all([
629
+ const [rules, playbooks, summary] = await Promise.all([
630
630
  callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
631
+ callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "extra">>>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS }),
631
632
  callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
632
633
  ]);
633
634
  const injection = buildContextInjection({
634
635
  basePrompt: event.systemPrompt ?? "",
635
636
  rules,
637
+ playbooks,
636
638
  taskSummary: summary,
637
639
  observedAt: Date.now(),
638
640
  sequence: ++contextInjectionSequence,
@@ -1,90 +1,91 @@
1
1
  /**
2
- * playbook-bridge.ts — materializes active Papyrus Playbooks as real SKILL.md files so they
3
- * show up in Pi's own native skill catalog and become /skill:name-invocable, through Pi's
4
- * unmodified, documented resources_discover mechanism (no Pi source touched).
2
+ * playbook-bridge.ts — materializes active Papyrus Playbooks as their own /playbook:name slash
3
+ * commands, one per playbook, the same one-entry-per-item autocomplete experience Pi's own
4
+ * /skill:name gives real skills.
5
5
  *
6
- * Playbooks live in SQLite, not on disk, so they can't satisfy Pi's skill-loading pipeline
7
- * directly (Skill.filePath is required there). This bridges the gap the other direction:
8
- * on every resources_discover (session start and /reload), wipe and rebuild a cache directory
9
- * from the current playbooks.list, so a disabled/removed/renamed Playbook's stale file is never
10
- * served. Any failure degrades to "no extra skills this cycle" -- a Papyrus daemon hiccup must
11
- * never break Pi's own resource discovery.
6
+ * /skill:name itself is a hardcoded core mechanism (Pi's interactive mode builds it directly
7
+ * from its own skill loader) -- not something an extension can retarget to a different prefix.
8
+ * pi.registerCommand(name, ...) accepts any string, colons included, so "playbook:<slug>"
9
+ * registers and invokes as literally /playbook:<slug> -- a real, supported extension API, no
10
+ * core touched.
11
+ *
12
+ * Real limitation: ExtensionAPI has no unregisterCommand. A disabled or renamed playbook's old
13
+ * /playbook:<slug> command lingers until a full Pi restart -- registerCommand can only add or
14
+ * overwrite, never remove. Mitigated two ways: registrations refresh on every resources_discover
15
+ * (session start and /reload), so a renamed playbook's NEW slug appears promptly even though the
16
+ * old one lingers; and each command's handler re-fetches the live playbook by id at invocation
17
+ * time rather than baking in stale content, so even a lingering stale name fails cleanly with a
18
+ * real error instead of running deleted content.
12
19
  */
13
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
14
- import { homedir } from "node:os";
15
- import { join } from "node:path";
16
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
21
  import type { Artifact } from "../../src/domain/artifact.ts";
18
22
  import { callService } from "./service-client.ts";
19
23
 
20
- const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
21
- const PLAYBOOK_BRIDGE_DESCRIPTION_MAX_CHARACTERS = 1000;
24
+ export const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
22
25
 
23
26
  function slugify(title: string): string {
24
27
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
25
28
  return slug.length > 0 ? slug : "playbook";
26
29
  }
27
30
 
28
- function playbookCacheDir(): string {
29
- const base = process.env["XDG_CACHE_HOME"] ?? join(homedir(), ".cache");
30
- return join(base, "papyrus", "playbooks");
31
+ async function activePlaybooks(): Promise<Artifact[]> {
32
+ return callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS });
31
33
  }
32
34
 
33
- function stringList(value: unknown): string[] {
34
- return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
35
+ /** Exported for direct testing without a real ExtensionAPI. */
36
+ export function playbookCommandName(title: string): string {
37
+ return `playbook:${slugify(title)}`;
35
38
  }
36
39
 
37
- function playbookSkillMarkdown(playbook: Artifact): string {
40
+ /**
41
+ * One line per active Playbook for context injection -- the passive, every-turn surfacing that
42
+ * gives the model the same "this exists and might match my task" awareness Pi's own Skill catalog
43
+ * gives real Skills, without a file-based bridge. See buildContextInjection (rules and open tasks
44
+ * already work this way).
45
+ */
46
+ export function playbookInjectionPreview(playbook: Pick<Artifact, "title" | "extra">): string {
38
47
  const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
39
- const steps = stringList(playbook.extra["steps"]);
40
- const tools = stringList(playbook.extra["tools"]);
41
- const description = trigger.replace(/\n/g, " ").slice(0, PLAYBOOK_BRIDGE_DESCRIPTION_MAX_CHARACTERS);
42
- return [
43
- "---",
44
- `name: ${slugify(playbook.title)}`,
45
- `description: ${description}`,
46
- "---",
47
- "",
48
- `# ${playbook.title}`,
49
- "",
50
- `Materialized from a live Papyrus playbook; edits here are lost on the next refresh. Edit the playbook itself instead (the playbooks tool, action=update), then /reload.`,
51
- "",
52
- `Trigger: ${trigger}`,
53
- "",
54
- ...(playbook.body ? [`Context: ${playbook.body}`, ""] : []),
55
- ...(steps.length > 0 ? ["## Steps", "", ...steps.map((step, index) => `${index + 1}. ${step}`), ""] : []),
56
- ...(tools.length > 0 ? [`Tools: ${tools.join(", ")}`, ""] : []),
57
- ].join("\n");
48
+ return `• ${playbook.title} (when: ${trigger})`;
58
49
  }
59
50
 
60
- /** Exported for direct testing without a real ExtensionAPI. */
61
- export async function materializePlaybookSkillPaths(): Promise<string[]> {
62
- const dir = playbookCacheDir();
63
- if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
64
- const playbooks = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS });
65
- if (playbooks.length === 0) return [];
66
- mkdirSync(dir, { recursive: true });
67
- const usedSlugs = new Set<string>();
68
- const paths: string[] = [];
69
- for (const playbook of playbooks) {
70
- let slug = slugify(playbook.title);
71
- if (usedSlugs.has(slug)) slug = `${slug}-${playbook.id.slice(0, 8)}`; // a real title collision, not the common case
72
- usedSlugs.add(slug);
73
- const skillDir = join(dir, slug);
74
- mkdirSync(skillDir, { recursive: true });
75
- const filePath = join(skillDir, "SKILL.md");
76
- writeFileSync(filePath, playbookSkillMarkdown(playbook), "utf8");
77
- paths.push(filePath);
78
- }
79
- return paths;
51
+ /** Exported for direct testing without a real ExtensionAPI: what would be registered right now. */
52
+ export async function planPlaybookCommandRegistrations(): Promise<Array<{ name: string; id: string; title: string; trigger: string }>> {
53
+ const playbooks = await activePlaybooks();
54
+ const usedNames = new Set<string>();
55
+ return playbooks.map((playbook) => {
56
+ let name = playbookCommandName(playbook.title);
57
+ if (usedNames.has(name)) name = `${name}-${playbook.id.slice(0, 8)}`; // a real title collision, not the common case
58
+ usedNames.add(name);
59
+ const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
60
+ return { name, id: playbook.id, title: playbook.title, trigger };
61
+ });
80
62
  }
81
63
 
82
64
  export function registerPlaybookBridge(pi: ExtensionAPI): void {
83
- pi.on("resources_discover", async () => {
65
+ const refresh = async () => {
84
66
  try {
85
- return { skillPaths: await materializePlaybookSkillPaths() };
67
+ const registrations = await planPlaybookCommandRegistrations();
68
+ for (const { name, id, title, trigger } of registrations) {
69
+ pi.registerCommand(name, {
70
+ description: trigger,
71
+ handler: async (_args, ctx) => {
72
+ try {
73
+ // Re-fetched live, not captured at registration time: a lingering stale
74
+ // command (renamed or disabled since, since registerCommand can't be
75
+ // unregistered) must fail cleanly, never run deleted/stale content.
76
+ const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id });
77
+ ctx.ui.setEditorText(invocation);
78
+ ctx.ui.notify(`"${title}" invocation placed in the editor`, "info");
79
+ } catch (error) {
80
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
81
+ }
82
+ },
83
+ });
84
+ }
86
85
  } catch {
87
- return {};
86
+ // A Papyrus daemon hiccup must never break Pi's own resource discovery -- degrades to
87
+ // "no new/updated playbook commands this cycle", not a broken session start.
88
88
  }
89
- });
89
+ };
90
+ pi.on("resources_discover", async () => { await refresh(); return {}; });
90
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.28.3",
3
+ "version": "0.29.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],