@danypops/papyrus 0.29.0 → 0.29.2

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,
@@ -1126,6 +1126,53 @@ export function isLiveAskPending(): boolean {
1126
1126
  return livePendingCount > 0;
1127
1127
  }
1128
1128
 
1129
+ const DISCUSS_TYPING_COURTESY_POLL_MS = 400;
1130
+ const DISCUSS_TYPING_COURTESY_DEFAULT_MAX_WAIT_MS = 15_000;
1131
+
1132
+ function resolveTypingCourtesyMaxWaitMs(): number {
1133
+ const raw = process.env["PAPYRUS_DISCUSS_TYPING_COURTESY_MS"];
1134
+ if (raw === undefined) return DISCUSS_TYPING_COURTESY_DEFAULT_MAX_WAIT_MS;
1135
+ const parsed = Number(raw);
1136
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DISCUSS_TYPING_COURTESY_DEFAULT_MAX_WAIT_MS;
1137
+ }
1138
+
1139
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1140
+ return new Promise((resolve) => {
1141
+ if (signal?.aborted) { resolve(); return; }
1142
+ const timer = setTimeout(resolve, ms);
1143
+ signal?.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
1144
+ });
1145
+ }
1146
+
1147
+ /**
1148
+ * Whether there is a genuine editor draft to wait out right now -- a plain synchronous check so
1149
+ * the common case (no draft) never forces the caller through an extra microtask. Deliberately not
1150
+ * folded into waitForEditorCourtesy itself: an unconditional `await` there -- even one that
1151
+ * resolves immediately -- still yields once, which is enough to let a signal aborted synchronously
1152
+ * right after invoking askQuestion race past the abort listener registered deeper in
1153
+ * askQuestionBlocking and get missed entirely.
1154
+ */
1155
+ export function hasEditorCourtesyDraft(ctx: ExtensionContext): boolean {
1156
+ return typeof ctx.ui.getEditorText === "function" && ctx.ui.getEditorText().length > 0;
1157
+ }
1158
+
1159
+ /**
1160
+ * Waits for a non-empty editor draft to clear before popping the live ask over it. Bounded
1161
+ * (PAPYRUS_DISCUSS_TYPING_COURTESY_MS, default 15s, 0 disables): a draft left sitting unattended
1162
+ * must not withhold the question indefinitely, only a genuinely in-progress reply gets the
1163
+ * courtesy. Only call when hasEditorCourtesyDraft(ctx) is already true.
1164
+ */
1165
+ export async function waitForEditorCourtesy(ctx: ExtensionContext, params: Pick<AskQuestionParams, "onUpdate" | "signal">): Promise<void> {
1166
+ const maxWaitMs = resolveTypingCourtesyMaxWaitMs();
1167
+ if (maxWaitMs <= 0) return;
1168
+ const deadline = Date.now() + maxWaitMs;
1169
+ params.onUpdate?.({ content: [{ type: "text", text: "Waiting for you to finish typing before asking..." }], details: undefined });
1170
+ while (Date.now() < deadline && !params.signal?.aborted) {
1171
+ await sleep(DISCUSS_TYPING_COURTESY_POLL_MS, params.signal);
1172
+ if (typeof ctx.ui.getEditorText !== "function" || ctx.ui.getEditorText().length === 0) return;
1173
+ }
1174
+ }
1175
+
1129
1176
  /**
1130
1177
  * Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
1131
1178
  * dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
@@ -1147,9 +1194,12 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
1147
1194
  const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
1148
1195
  const normalizedContext = params.context?.trim() || undefined;
1149
1196
 
1150
- params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1151
1197
  livePendingCount += 1;
1152
1198
  try {
1199
+ // Only actually awaits (yielding a microtask) when there's a real draft to wait out --
1200
+ // see hasEditorCourtesyDraft's own comment for why the common case must stay synchronous.
1201
+ if (hasEditorCourtesyDraft(ctx)) await waitForEditorCourtesy(ctx, params);
1202
+ params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
1153
1203
  return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
1154
1204
  } finally {
1155
1205
  livePendingCount -= 1;
@@ -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,
@@ -21,7 +21,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
21
  import type { Artifact } from "../../src/domain/artifact.ts";
22
22
  import { callService } from "./service-client.ts";
23
23
 
24
- const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
24
+ export const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
25
25
 
26
26
  function slugify(title: string): string {
27
27
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
@@ -37,6 +37,17 @@ export function playbookCommandName(title: string): string {
37
37
  return `playbook:${slugify(title)}`;
38
38
  }
39
39
 
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 {
47
+ const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
48
+ return `• ${playbook.title} (when: ${trigger})`;
49
+ }
50
+
40
51
  /** Exported for direct testing without a real ExtensionAPI: what would be registered right now. */
41
52
  export async function planPlaybookCommandRegistrations(): Promise<Array<{ name: string; id: string; title: string; trigger: string }>> {
42
53
  const playbooks = await activePlaybooks();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.29.0",
3
+ "version": "0.29.2",
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"],