@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;
|
package/extension/src/index.ts
CHANGED
|
@@ -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