@danypops/papyrus 0.29.2 → 0.29.4
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/discuss-ask-view.ts +78 -31
- package/extension/src/domain-tools.ts +18 -0
- package/extension/src/index.ts +5 -1
- package/package.json +1 -1
- package/src/cli.ts +12 -2
|
@@ -1126,14 +1126,26 @@ export function isLiveAskPending(): boolean {
|
|
|
1126
1126
|
return livePendingCount > 0;
|
|
1127
1127
|
}
|
|
1128
1128
|
|
|
1129
|
-
const
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1129
|
+
const DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS = 100;
|
|
1130
|
+
const DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS = 1_500;
|
|
1131
|
+
const DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS = 300;
|
|
1132
|
+
const DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS = 10_000;
|
|
1133
|
+
|
|
1134
|
+
let typingCourtesyPollMs = DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS;
|
|
1135
|
+
let typingCourtesyInitialQuietMs = DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS;
|
|
1136
|
+
let typingCourtesyQuietFloorMs = DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS;
|
|
1137
|
+
let typingCourtesyDecayHorizonMs = DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS;
|
|
1138
|
+
|
|
1139
|
+
/** Test-only: the real decay curve runs over seconds, too slow to exercise at its real scale in a unit test. */
|
|
1140
|
+
export function setTypingCourtesyTimingForTests(overrides?: { pollMs?: number; initialQuietMs?: number; floorMs?: number; decayHorizonMs?: number }): void {
|
|
1141
|
+
typingCourtesyPollMs = overrides?.pollMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS;
|
|
1142
|
+
typingCourtesyInitialQuietMs = overrides?.initialQuietMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS;
|
|
1143
|
+
typingCourtesyQuietFloorMs = overrides?.floorMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS;
|
|
1144
|
+
typingCourtesyDecayHorizonMs = overrides?.decayHorizonMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function isTypingCourtesyEnabled(): boolean {
|
|
1148
|
+
return parseBooleanPreference(process.env["PAPYRUS_DISCUSS_TYPING_COURTESY"]) ?? true;
|
|
1137
1149
|
}
|
|
1138
1150
|
|
|
1139
1151
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
@@ -1145,31 +1157,65 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
1145
1157
|
}
|
|
1146
1158
|
|
|
1147
1159
|
/**
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
|
|
1160
|
+
* Required quiet gap (no keystroke) before a live ask may open, as a function of how long we've
|
|
1161
|
+
* already been waiting. Starts wide (a natural inter-word pause shouldn't count as "done typing")
|
|
1162
|
+
* and decays toward a floor -- someone typing continuously gets pickier treatment over time
|
|
1163
|
+
* rather than never being asked. No outer cap: someone typing with sub-floor gaps forever waits
|
|
1164
|
+
* forever, same as the picker itself already waits indefinitely for a real human answer once open.
|
|
1165
|
+
*/
|
|
1166
|
+
function requiredQuietMsAt(elapsedMs: number): number {
|
|
1167
|
+
const t = Math.min(1, Math.max(0, elapsedMs / typingCourtesyDecayHorizonMs));
|
|
1168
|
+
return typingCourtesyInitialQuietMs - t * (typingCourtesyInitialQuietMs - typingCourtesyQuietFloorMs);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Ambient, session-lifetime keystroke clock -- deliberately NOT scoped per-ask. A per-ask listener
|
|
1173
|
+
* would only see keystrokes from the moment the tool call happens to start, missing typing already
|
|
1174
|
+
* in progress when it began (the exact case this feature exists to protect). Attached once per
|
|
1175
|
+
* distinct ui instance (reference equality; a session's real ui object is stable for its lifetime)
|
|
1176
|
+
* and left attached -- there is no unregister, matching onTerminalInput's own listener-return-value
|
|
1177
|
+
* contract elsewhere in this file.
|
|
1178
|
+
*/
|
|
1179
|
+
let lastKeystrokeAt = 0;
|
|
1180
|
+
let trackedUi: ExtensionContext["ui"] | undefined;
|
|
1181
|
+
|
|
1182
|
+
export function ensureTypingCourtesyTracking(ui: ExtensionContext["ui"]): void {
|
|
1183
|
+
if (typeof ui.onTerminalInput !== "function" || trackedUi === ui) return;
|
|
1184
|
+
trackedUi = ui;
|
|
1185
|
+
ui.onTerminalInput(() => { lastKeystrokeAt = Date.now(); return undefined; });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** Test-only: clears the ambient keystroke clock so one test's simulated typing can't bleed into another's. */
|
|
1189
|
+
export function resetTypingCourtesyTrackingForTests(): void {
|
|
1190
|
+
lastKeystrokeAt = 0;
|
|
1191
|
+
trackedUi = undefined;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Whether there is real, recent typing activity to wait out right now -- a plain synchronous read
|
|
1196
|
+
* of the ambient keystroke clock so the common case (nobody typing) never forces the caller
|
|
1197
|
+
* through an extra microtask. Deliberately not folded into waitForTypingCourtesy itself: an
|
|
1198
|
+
* unconditional `await` there -- even one that resolves immediately -- still yields once, which is
|
|
1199
|
+
* enough to let a signal aborted synchronously right after invoking askQuestion race past the
|
|
1200
|
+
* abort listener registered deeper in askQuestionBlocking and get missed entirely.
|
|
1154
1201
|
*/
|
|
1155
|
-
export function
|
|
1156
|
-
return
|
|
1202
|
+
export function isRecentlyTyping(): boolean {
|
|
1203
|
+
return lastKeystrokeAt > 0 && Date.now() - lastKeystrokeAt < typingCourtesyInitialQuietMs;
|
|
1157
1204
|
}
|
|
1158
1205
|
|
|
1159
1206
|
/**
|
|
1160
|
-
* Waits
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
* courtesy. Only call when hasEditorCourtesyDraft(ctx) is already true.
|
|
1207
|
+
* Waits out real keystroke activity (not editor text content -- that can't distinguish "actively
|
|
1208
|
+
* typing" from "a stale draft sitting there", and misses a mid-thought erase-and-resume) before
|
|
1209
|
+
* popping the live ask over it. Only call when isRecentlyTyping() is already true.
|
|
1164
1210
|
*/
|
|
1165
|
-
export async function
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1211
|
+
export async function waitForTypingCourtesy(params: Pick<AskQuestionParams, "onUpdate" | "signal">): Promise<void> {
|
|
1212
|
+
const startedAt = Date.now();
|
|
1213
|
+
let announced = false;
|
|
1214
|
+
while (lastKeystrokeAt > 0 && !params.signal?.aborted) {
|
|
1215
|
+
const elapsed = Date.now() - startedAt;
|
|
1216
|
+
if (Date.now() - lastKeystrokeAt >= requiredQuietMsAt(elapsed)) return;
|
|
1217
|
+
if (!announced) { announced = true; params.onUpdate?.({ content: [{ type: "text", text: "Waiting for you to finish typing before asking..." }], details: undefined }); }
|
|
1218
|
+
await sleep(typingCourtesyPollMs, params.signal);
|
|
1173
1219
|
}
|
|
1174
1220
|
}
|
|
1175
1221
|
|
|
@@ -1194,11 +1240,12 @@ async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionPa
|
|
|
1194
1240
|
const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
|
|
1195
1241
|
const normalizedContext = params.context?.trim() || undefined;
|
|
1196
1242
|
|
|
1243
|
+
if (isTypingCourtesyEnabled()) ensureTypingCourtesyTracking(ctx.ui);
|
|
1197
1244
|
livePendingCount += 1;
|
|
1198
1245
|
try {
|
|
1199
|
-
// Only actually awaits (yielding a microtask) when there's
|
|
1200
|
-
// see
|
|
1201
|
-
if (
|
|
1246
|
+
// Only actually awaits (yielding a microtask) when there's real typing activity to wait out --
|
|
1247
|
+
// see isRecentlyTyping's own comment for why the common case must stay synchronous.
|
|
1248
|
+
if (isTypingCourtesyEnabled() && isRecentlyTyping()) await waitForTypingCourtesy(params);
|
|
1202
1249
|
params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
|
|
1203
1250
|
return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, displayMode, normalizedContext);
|
|
1204
1251
|
} finally {
|
|
@@ -150,6 +150,23 @@ async function resolveArtifactIdByName(listOperation: OperationName, baseRequest
|
|
|
150
150
|
return matchArtifactByName(candidates, name);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Playbook `arguments` is intentionally untyped in this tool's schema (an array on create, a
|
|
155
|
+
* {name: value} map on invoke) -- unlike every other JSON-shaped field here, which has a concrete
|
|
156
|
+
* array/record schema the calling layer can serialize correctly. A genuinely schema-less field can
|
|
157
|
+
* arrive pre-serialized as JSON text instead of a parsed value; parse it back in place before it
|
|
158
|
+
* reaches the service, the same tolerance the CLI's own --arguments-json/--*-json flags already give.
|
|
159
|
+
*/
|
|
160
|
+
export function normalizeJsonEncodedField(params: Record<string, unknown>, key: string): void {
|
|
161
|
+
const value = params[key];
|
|
162
|
+
if (typeof value !== "string") return;
|
|
163
|
+
try {
|
|
164
|
+
params[key] = JSON.parse(value);
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error(`${key} must be valid JSON`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
153
170
|
/** Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in place. */
|
|
154
171
|
async function resolveNameFields(
|
|
155
172
|
params: Record<string, unknown>,
|
|
@@ -610,6 +627,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
610
627
|
await resolveNameFields(params, [
|
|
611
628
|
{ nameKey: "name", idKey: "id", listOperation: "playbooks.list", baseRequest: { project_root: params.project_root } },
|
|
612
629
|
]);
|
|
630
|
+
if (action === "create" || action === "invoke") normalizeJsonEncodedField(params, "arguments");
|
|
613
631
|
if (action === "create") {
|
|
614
632
|
const artifact = await callService<Record<string, unknown>, Artifact>("playbooks.create", params);
|
|
615
633
|
return text(`Created playbook ${artifactLine(artifact)}`, createArtifactDetails("playbooks.create", artifact));
|
package/extension/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { GateResult } from "../../src/domain/gate.ts";
|
|
|
22
22
|
import { formatMetadata } from "./artifact-format.ts";
|
|
23
23
|
import { callService } from "./service-client.ts";
|
|
24
24
|
import { registerDomainTools } from "./domain-tools.ts";
|
|
25
|
-
import { isLiveAskPending } from "./discuss-ask-view.ts";
|
|
25
|
+
import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
|
|
26
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";
|
|
@@ -566,6 +566,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
566
566
|
// intentionally silent -- see comment above
|
|
567
567
|
}
|
|
568
568
|
if (!ctx.hasUI) return;
|
|
569
|
+
// Attached from session start, not lazily on first ask -- a per-ask listener would only see
|
|
570
|
+
// keystrokes from the moment that tool call happens to begin, missing typing already in
|
|
571
|
+
// progress when it started (the exact case Discuss's typing-courtesy wait protects against).
|
|
572
|
+
ensureTypingCourtesyTracking(ctx.ui);
|
|
569
573
|
overlay ??= new TaskOverlay();
|
|
570
574
|
overlay.setUI(ctx.ui);
|
|
571
575
|
overlay.setProjectRoot(ctx.cwd);
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -112,7 +112,7 @@ const USAGE = `Usage:
|
|
|
112
112
|
papyrus skills instantiate <template-id> [--title <title>] [--body <body>] [--status <status>] [--labels-json <json>] [--extra-json <json>] [--json]
|
|
113
113
|
papyrus skills assign-project <id> [project-root] [--json]
|
|
114
114
|
papyrus skills update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
|
|
115
|
-
papyrus playbooks create --title <title> [--body <body>] [--trigger <text>] [--steps-json <json>] [--tools-json <json>] [--labels-json <json>] [--extra-json <json>] [--project-root <path>] [--json]
|
|
115
|
+
papyrus playbooks create --title <title> [--body <body>] [--trigger <text>] [--steps-json <json>] [--tools-json <json>] [--labels-json <json>] [--extra-json <json>] [--arguments-json <json>] [--project-root <path>] [--json]
|
|
116
116
|
papyrus playbooks list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
|
|
117
117
|
papyrus playbooks show <id> [--json]
|
|
118
118
|
papyrus playbooks invoke <id> [--json]
|
|
@@ -201,6 +201,14 @@ function parseJsonStringArrayFlag(value: string | undefined, flag: string): stri
|
|
|
201
201
|
return parsed as string[];
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/** Top-level shape only -- element shape (e.g. {name, description?, required?}) is validated server-side. */
|
|
205
|
+
function parseJsonArrayFlag(value: string | undefined, flag: string): unknown[] {
|
|
206
|
+
if (value === undefined) throw new Error(`${flag} requires a value`);
|
|
207
|
+
const parsed = JSON.parse(value) as unknown;
|
|
208
|
+
if (!Array.isArray(parsed)) throw new Error(`${flag} must be a JSON array`);
|
|
209
|
+
return parsed;
|
|
210
|
+
}
|
|
211
|
+
|
|
204
212
|
function artifactLabel(artifact: CliArtifact): string {
|
|
205
213
|
return `${artifact.id} ${artifact.title}`;
|
|
206
214
|
}
|
|
@@ -786,6 +794,7 @@ export async function runPlaybooksCli(args: string[], client: TaskCliClient): Pr
|
|
|
786
794
|
let tools: string[] | undefined;
|
|
787
795
|
let labels: string[] | undefined;
|
|
788
796
|
let extra: Record<string, unknown> | undefined;
|
|
797
|
+
let playbookArguments: unknown[] | undefined;
|
|
789
798
|
let status: string | undefined;
|
|
790
799
|
let text: string | undefined;
|
|
791
800
|
let limit: number | undefined;
|
|
@@ -800,6 +809,7 @@ export async function runPlaybooksCli(args: string[], client: TaskCliClient): Pr
|
|
|
800
809
|
if (argument === "--tools-json") { tools = parseJsonStringArrayFlag(args[++index], "--tools-json"); continue; }
|
|
801
810
|
if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
|
|
802
811
|
if (argument === "--extra-json") { extra = parseJsonObjectFlag(args[++index], "--extra-json"); continue; }
|
|
812
|
+
if (argument === "--arguments-json") { playbookArguments = parseJsonArrayFlag(args[++index], "--arguments-json"); continue; }
|
|
803
813
|
if (argument === "--status") { status = args[++index]; if (!status) throw new Error("--status requires a value"); continue; }
|
|
804
814
|
if (argument === "--text") { text = args[++index]; if (text === undefined) throw new Error("--text requires a value"); continue; }
|
|
805
815
|
if (argument === "--project-root") { playbookProjectRoot = args[++index]; if (!playbookProjectRoot) throw new Error("--project-root requires a value"); continue; }
|
|
@@ -819,7 +829,7 @@ export async function runPlaybooksCli(args: string[], client: TaskCliClient): Pr
|
|
|
819
829
|
case "create": {
|
|
820
830
|
if (id) throw new Error("playbooks create accepts no positional arguments");
|
|
821
831
|
if (!title) throw new Error("playbooks create requires --title");
|
|
822
|
-
const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.create", { title, body, trigger, steps, tools, labels, extra, project_root: playbookProjectRoot });
|
|
832
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.create", { title, body, trigger, steps, tools, labels, extra, arguments: playbookArguments, project_root: playbookProjectRoot });
|
|
823
833
|
result = artifact;
|
|
824
834
|
human = `Created playbook: ${artifactLabel(artifact)}`;
|
|
825
835
|
break;
|