@danypops/papyrus 0.28.2 → 0.29.0
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.
|
@@ -9,7 +9,7 @@ import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
|
9
9
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
10
10
|
import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
|
|
11
11
|
import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
|
|
12
|
-
import { askQuestion } from "./discuss-ask-view.ts";
|
|
12
|
+
import { askQuestion, type AskDisplayMode } from "./discuss-ask-view.ts";
|
|
13
13
|
import type { OperationName } from "../../src/service.ts";
|
|
14
14
|
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
15
15
|
import { sessionSecretField } from "./session-identity.ts";
|
|
@@ -68,7 +68,11 @@ function normalizeDiscussOptions(params: Record<string, unknown>): void {
|
|
|
68
68
|
if (anyDescription) params.option_descriptions = descriptions;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
function parseDisplayMode(value: unknown): AskDisplayMode | undefined {
|
|
72
|
+
return value === "overlay" || value === "inline" || value === "editor" ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestContent: string | undefined, onUpdate: AgentToolUpdateCallback | undefined, signal: AbortSignal | undefined, displayMode: AskDisplayMode | undefined): Promise<{ content: string; selected?: string[] } | undefined> {
|
|
72
76
|
if (!ctx.hasUI) return undefined;
|
|
73
77
|
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
74
78
|
// The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
|
|
@@ -85,9 +89,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestCon
|
|
|
85
89
|
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
86
90
|
onUpdate,
|
|
87
91
|
signal,
|
|
92
|
+
displayMode,
|
|
88
93
|
});
|
|
89
94
|
}
|
|
90
|
-
return askQuestion(ctx, { question, subtitle, onUpdate, signal });
|
|
95
|
+
return askQuestion(ctx, { question, subtitle, onUpdate, signal, displayMode });
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
/**
|
|
@@ -710,7 +715,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
710
715
|
pi.registerTool({
|
|
711
716
|
name: "discuss",
|
|
712
717
|
label: "Discuss",
|
|
713
|
-
description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Each option is either a bare string or {title, description}; description is optional for exactly 2 options (a self-evident yes/no) but REQUIRED and non-empty for every option once there are 3 or more -- rejected otherwise. One line: the real pro/con/risk/consequence, never padding that just restates the title. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
|
|
718
|
+
description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Each option is either a bare string or {title, description}; description is optional for exactly 2 options (a self-evident yes/no) but REQUIRED and non-empty for every option once there are 3 or more -- rejected otherwise. One line: the real pro/con/risk/consequence, never padding that just restates the title. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. display_mode picks how the live picker renders: 'overlay' (default, a floating dialog), 'inline' (renders in the normal transcript flow, no floating), or 'editor' (hosted in place of the input box itself, like a slash-command menu; falls back to 'inline' if unsupported in the current UI mode). PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
|
|
714
719
|
parameters: Type.Object({
|
|
715
720
|
action: Type.String(),
|
|
716
721
|
id: Type.Optional(Type.String()),
|
|
@@ -733,6 +738,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
733
738
|
options_mode: Type.Optional(Type.String()),
|
|
734
739
|
selected: Type.Optional(Type.Array(Type.String())),
|
|
735
740
|
live: Type.Optional(Type.Boolean()),
|
|
741
|
+
display_mode: Type.Optional(Type.String()),
|
|
736
742
|
}),
|
|
737
743
|
// Blocks other tool calls in the same assistant turn until live:true's human answer comes
|
|
738
744
|
// back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
|
|
@@ -758,7 +764,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
758
764
|
? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
|
|
759
765
|
: text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
|
|
760
766
|
if (params.live !== true) return fallback;
|
|
761
|
-
const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
|
|
767
|
+
const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal, parseDisplayMode(rawParams.display_mode));
|
|
762
768
|
if (!answer) return fallback;
|
|
763
769
|
const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
|
|
764
770
|
id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
|
|
@@ -1,90 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* playbook-bridge.ts — materializes active Papyrus Playbooks as
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
24
|
const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
|
|
21
|
-
const PLAYBOOK_BRIDGE_DESCRIPTION_MAX_CHARACTERS = 1000;
|
|
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
|
|
29
|
-
|
|
30
|
-
return join(base, "papyrus", "playbooks");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function stringList(value: unknown): string[] {
|
|
34
|
-
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
31
|
+
async function activePlaybooks(): Promise<Artifact[]> {
|
|
32
|
+
return callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS });
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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");
|
|
35
|
+
/** Exported for direct testing without a real ExtensionAPI. */
|
|
36
|
+
export function playbookCommandName(title: string): string {
|
|
37
|
+
return `playbook:${slugify(title)}`;
|
|
58
38
|
}
|
|
59
39
|
|
|
60
|
-
/** Exported for direct testing without a real ExtensionAPI. */
|
|
61
|
-
export async function
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
|
40
|
+
/** Exported for direct testing without a real ExtensionAPI: what would be registered right now. */
|
|
41
|
+
export async function planPlaybookCommandRegistrations(): Promise<Array<{ name: string; id: string; title: string; trigger: string }>> {
|
|
42
|
+
const playbooks = await activePlaybooks();
|
|
43
|
+
const usedNames = new Set<string>();
|
|
44
|
+
return playbooks.map((playbook) => {
|
|
45
|
+
let name = playbookCommandName(playbook.title);
|
|
46
|
+
if (usedNames.has(name)) name = `${name}-${playbook.id.slice(0, 8)}`; // a real title collision, not the common case
|
|
47
|
+
usedNames.add(name);
|
|
48
|
+
const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
|
|
49
|
+
return { name, id: playbook.id, title: playbook.title, trigger };
|
|
50
|
+
});
|
|
80
51
|
}
|
|
81
52
|
|
|
82
53
|
export function registerPlaybookBridge(pi: ExtensionAPI): void {
|
|
83
|
-
|
|
54
|
+
const refresh = async () => {
|
|
84
55
|
try {
|
|
85
|
-
|
|
56
|
+
const registrations = await planPlaybookCommandRegistrations();
|
|
57
|
+
for (const { name, id, title, trigger } of registrations) {
|
|
58
|
+
pi.registerCommand(name, {
|
|
59
|
+
description: trigger,
|
|
60
|
+
handler: async (_args, ctx) => {
|
|
61
|
+
try {
|
|
62
|
+
// Re-fetched live, not captured at registration time: a lingering stale
|
|
63
|
+
// command (renamed or disabled since, since registerCommand can't be
|
|
64
|
+
// unregistered) must fail cleanly, never run deleted/stale content.
|
|
65
|
+
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id });
|
|
66
|
+
ctx.ui.setEditorText(invocation);
|
|
67
|
+
ctx.ui.notify(`"${title}" invocation placed in the editor`, "info");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
86
74
|
} catch {
|
|
87
|
-
|
|
75
|
+
// A Papyrus daemon hiccup must never break Pi's own resource discovery -- degrades to
|
|
76
|
+
// "no new/updated playbook commands this cycle", not a broken session start.
|
|
88
77
|
}
|
|
89
|
-
}
|
|
78
|
+
};
|
|
79
|
+
pi.on("resources_discover", async () => { await refresh(); return {}; });
|
|
90
80
|
}
|
package/package.json
CHANGED