@danypops/papyrus 0.33.2 → 0.33.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.
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared idempotent start/stop wrapper over setInterval, extracted once TaskOverlay and
|
|
3
|
+
* NoteOverlay both needed the identical "fallback refresh for a mutation no event announces"
|
|
4
|
+
* behavior -- a second start() is a no-op rather than a competing timer, and stop() is safe
|
|
5
|
+
* to call even if never started.
|
|
6
|
+
*/
|
|
7
|
+
export class BoundedPoll {
|
|
8
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
9
|
+
|
|
10
|
+
start(intervalMs: number, tick: () => void): void {
|
|
11
|
+
if (this.timer) return;
|
|
12
|
+
this.timer = setInterval(tick, intervalMs);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
stop(): void {
|
|
16
|
+
if (!this.timer) return;
|
|
17
|
+
clearInterval(this.timer);
|
|
18
|
+
this.timer = undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -164,7 +164,7 @@ export function normalizeJsonEncodedField(params: Record<string, unknown>, key:
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/** Resolves every {nameKey -> idKey} pair present and not already satisfied by an explicit id, in place. */
|
|
167
|
-
async function resolveNameFields(
|
|
167
|
+
export async function resolveNameFields(
|
|
168
168
|
params: Record<string, unknown>,
|
|
169
169
|
fields: ReadonlyArray<{ nameKey: string; idKey: string; listOperation: OperationName; baseRequest: Record<string, unknown> }>,
|
|
170
170
|
): Promise<void> {
|
|
@@ -474,7 +474,7 @@ export function registerNotesTool(pi: ExtensionAPI): void {
|
|
|
474
474
|
pi.registerTool({
|
|
475
475
|
name: "notes",
|
|
476
476
|
label: "Notes",
|
|
477
|
-
description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id. Archive requires an explicit disposition. PREFER `name` (the note's exact title) over `id` for show/consume/promote/archive --
|
|
477
|
+
description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id (or target_name). Archive requires an explicit disposition. PREFER `name` (the note's exact title) over `id` for show/consume/promote/archive, and `target_name` over `target_id` for promote -- all are backend implementation details, resolved from name automatically (target_name searches across every kind, since a promotion target can be a task, doc, rule, or skill).",
|
|
478
478
|
parameters: Type.Object({
|
|
479
479
|
action: Type.String(),
|
|
480
480
|
id: Type.Optional(Type.String()),
|
|
@@ -485,6 +485,7 @@ export function registerNotesTool(pi: ExtensionAPI): void {
|
|
|
485
485
|
text: Type.Optional(Type.String()),
|
|
486
486
|
limit: Type.Optional(Type.Number()),
|
|
487
487
|
target_id: Type.Optional(Type.String()),
|
|
488
|
+
target_name: Type.Optional(Type.String()),
|
|
488
489
|
disposition: Type.Optional(Type.Union(NOTE_DISPOSITIONS.map((value) => Type.Literal(value)))),
|
|
489
490
|
reason: Type.Optional(Type.String()),
|
|
490
491
|
session_id: Type.Optional(Type.String()),
|
|
@@ -497,7 +498,11 @@ export function registerNotesTool(pi: ExtensionAPI): void {
|
|
|
497
498
|
const params: Record<string, unknown> = { ...rawParams };
|
|
498
499
|
const action = params.action;
|
|
499
500
|
const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
|
|
500
|
-
await resolveNameFields(params, [
|
|
501
|
+
await resolveNameFields(params, [
|
|
502
|
+
{ nameKey: "name", idKey: "id", listOperation: "notes.list", baseRequest },
|
|
503
|
+
// Kind-agnostic: a promotion target can be a task, doc, rule, or skill, so this searches every kind rather than only notes.
|
|
504
|
+
{ nameKey: "target_name", idKey: "target_id", listOperation: "artifact.query", baseRequest },
|
|
505
|
+
]);
|
|
501
506
|
const request = { ...params, ...baseRequest };
|
|
502
507
|
if (action === "capture") {
|
|
503
508
|
const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
|
package/extension/src/index.ts
CHANGED
|
@@ -17,12 +17,16 @@ import {
|
|
|
17
17
|
PAPYRUS_CONTEXT_INJECTION_CHANNEL,
|
|
18
18
|
CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
19
19
|
TASK_WIDGET_POLL_INTERVAL_MS,
|
|
20
|
+
NOTE_WIDGET_POLL_INTERVAL_MS,
|
|
21
|
+
NOTE_LIST_MAX_LIMIT,
|
|
20
22
|
} from "../../src/constants.ts";
|
|
21
23
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
22
24
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
23
25
|
import { formatMetadata } from "./artifact-format.ts";
|
|
24
26
|
import { callService } from "./service-client.ts";
|
|
25
|
-
import { registerDomainTools } from "./domain-tools.ts";
|
|
27
|
+
import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
|
|
28
|
+
import { BoundedPoll } from "./bounded-poll.ts";
|
|
29
|
+
import { renderNoteWidgetLines } from "./note-widget.ts";
|
|
26
30
|
import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
|
|
27
31
|
import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
|
|
28
32
|
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
@@ -103,7 +107,7 @@ export class TaskOverlay {
|
|
|
103
107
|
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
104
108
|
private projectRoot: string | undefined;
|
|
105
109
|
private sessionId: string | undefined;
|
|
106
|
-
private
|
|
110
|
+
private readonly poll = new BoundedPoll();
|
|
107
111
|
|
|
108
112
|
setUI(ctx: ExtensionUIContext): void {
|
|
109
113
|
if (ctx !== this.uiCtx) {
|
|
@@ -179,18 +183,14 @@ export class TaskOverlay {
|
|
|
179
183
|
|
|
180
184
|
/**
|
|
181
185
|
* Fallback for a Task mutation no event announces -- the CLI run directly from a shell, or
|
|
182
|
-
* a second concurrent Pi session against the same daemon.
|
|
183
|
-
* no-op rather than starting a competing timer.
|
|
186
|
+
* a second concurrent Pi session against the same daemon.
|
|
184
187
|
*/
|
|
185
188
|
startPolling(intervalMs: number = TASK_WIDGET_POLL_INTERVAL_MS): void {
|
|
186
|
-
|
|
187
|
-
this.pollTimer = setInterval(() => { void this.refresh(); }, intervalMs);
|
|
189
|
+
this.poll.start(intervalMs, () => { void this.refresh(); });
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
stopPolling(): void {
|
|
191
|
-
|
|
192
|
-
clearInterval(this.pollTimer);
|
|
193
|
-
this.pollTimer = undefined;
|
|
193
|
+
this.poll.stop();
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
dispose(): void {
|
|
@@ -204,6 +204,98 @@ export class TaskOverlay {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
const NOTE_WIDGET_KEY = "pi-papyrus-notes";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Deliberately simple, unlike TaskOverlay's tree: just an open-note count for this session's own
|
|
211
|
+
* CWD -- notes.list already scopes to project_root exactly (a note's projectRoot is fixed at
|
|
212
|
+
* capture time), so passing this overlay's projectRoot is what makes the count CWD-aware by
|
|
213
|
+
* default.
|
|
214
|
+
*/
|
|
215
|
+
export class NoteOverlay {
|
|
216
|
+
private uiCtx: ExtensionUIContext | undefined;
|
|
217
|
+
private registered = false;
|
|
218
|
+
private tui: any | undefined;
|
|
219
|
+
private openCount = 0;
|
|
220
|
+
private projectRoot: string | undefined;
|
|
221
|
+
private readonly poll = new BoundedPoll();
|
|
222
|
+
|
|
223
|
+
setUI(ctx: ExtensionUIContext): void {
|
|
224
|
+
if (ctx !== this.uiCtx) {
|
|
225
|
+
this.uiCtx = ctx;
|
|
226
|
+
this.registered = false;
|
|
227
|
+
this.tui = undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
|
|
232
|
+
|
|
233
|
+
async refresh(): Promise<void> {
|
|
234
|
+
if (!this.projectRoot) return;
|
|
235
|
+
try {
|
|
236
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", { project_root: this.projectRoot, limit: NOTE_LIST_MAX_LIMIT });
|
|
237
|
+
this.openCount = rows.length;
|
|
238
|
+
} catch {
|
|
239
|
+
this.openCount = 0;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
this.render();
|
|
243
|
+
} catch {
|
|
244
|
+
// A rendering bug must not crash the extension host over a best-effort status widget.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private render(): void {
|
|
249
|
+
if (!this.uiCtx) return;
|
|
250
|
+
|
|
251
|
+
if (this.openCount === 0) {
|
|
252
|
+
if (this.registered) {
|
|
253
|
+
this.uiCtx.setWidget(NOTE_WIDGET_KEY, undefined);
|
|
254
|
+
this.registered = false;
|
|
255
|
+
this.tui = undefined;
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!this.registered) {
|
|
261
|
+
this.uiCtx.setWidget(
|
|
262
|
+
NOTE_WIDGET_KEY,
|
|
263
|
+
(tui: any, theme: Theme) => {
|
|
264
|
+
this.tui = tui;
|
|
265
|
+
return {
|
|
266
|
+
render: (width: number) => renderNoteWidgetLines(theme, this.openCount, width),
|
|
267
|
+
invalidate: () => {
|
|
268
|
+
this.registered = false;
|
|
269
|
+
this.tui = undefined;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
{ placement: "aboveEditor" },
|
|
274
|
+
);
|
|
275
|
+
this.registered = true;
|
|
276
|
+
} else {
|
|
277
|
+
this.tui?.requestRender?.();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
|
|
282
|
+
this.poll.start(intervalMs, () => { void this.refresh(); });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
stopPolling(): void {
|
|
286
|
+
this.poll.stop();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
dispose(): void {
|
|
290
|
+
this.stopPolling();
|
|
291
|
+
this.uiCtx?.setWidget(NOTE_WIDGET_KEY, undefined);
|
|
292
|
+
this.registered = false;
|
|
293
|
+
this.tui = undefined;
|
|
294
|
+
this.uiCtx = undefined;
|
|
295
|
+
this.projectRoot = undefined;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
207
299
|
// ---------------------------------------------------------------------------
|
|
208
300
|
// Entry point
|
|
209
301
|
// ---------------------------------------------------------------------------
|
|
@@ -331,12 +423,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
331
423
|
"ACTIONS: link (from+relation+to), unlink (from+relation+to — idempotent, no error if already absent; for Task depends_on/contains prefer the tasks tool's undepend/uncontain), " +
|
|
332
424
|
"tree (id → bounded BFS subgraph), " +
|
|
333
425
|
"history (who did what, when — requires id, actor, or session_id). " +
|
|
334
|
-
"status (id+status) exists at the protocol level but is refused for every kind with its own lifecycle (Doc/Rule/Skill/Playbook/Task/Note all reject it) -- use that kind's own domain tool for status changes (docs.activate, rules.enable, tasks.start, etc), never this."
|
|
426
|
+
"status (id+status) exists at the protocol level but is refused for every kind with its own lifecycle (Doc/Rule/Skill/Playbook/Task/Note all reject it) -- use that kind's own domain tool for status changes (docs.activate, rules.enable, tasks.start, etc), never this. " +
|
|
427
|
+
"PREFER `from_name`/`to_name` over `from`/`to` for link/unlink -- both are backend implementation details, resolved from name automatically, searching across every kind since either end of an edge can be any artifact.",
|
|
335
428
|
parameters: Type.Object({
|
|
336
429
|
action: Type.String({ description: "link | unlink | tree | status | history" }),
|
|
337
430
|
from: Type.Optional(Type.String()),
|
|
431
|
+
from_name: Type.Optional(Type.String()),
|
|
338
432
|
relation: Type.Optional(Type.String()),
|
|
339
433
|
to: Type.Optional(Type.String()),
|
|
434
|
+
to_name: Type.Optional(Type.String()),
|
|
340
435
|
id: Type.Optional(Type.String()),
|
|
341
436
|
status: Type.Optional(Type.String()),
|
|
342
437
|
depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
|
|
@@ -348,18 +443,26 @@ export default async function (pi: ExtensionAPI) {
|
|
|
348
443
|
}),
|
|
349
444
|
renderCall(args, theme) { return renderPapyrusToolCall("Artifact graph", args, theme); },
|
|
350
445
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
351
|
-
async execute(_id,
|
|
446
|
+
async execute(_id, rawParams, _signal, _onUpdate, _ctx) {
|
|
352
447
|
try {
|
|
448
|
+
const params: Record<string, unknown> = { ...rawParams };
|
|
449
|
+
if (params.action === "link" || params.action === "unlink") {
|
|
450
|
+
// Kind-agnostic: either end of an edge can be a task, doc, rule, skill, or playbook.
|
|
451
|
+
await resolveNameFields(params, [
|
|
452
|
+
{ nameKey: "from_name", idKey: "from", listOperation: "artifact.query", baseRequest: {} },
|
|
453
|
+
{ nameKey: "to_name", idKey: "to", listOperation: "artifact.query", baseRequest: {} },
|
|
454
|
+
]);
|
|
455
|
+
}
|
|
353
456
|
if (params.action === "link") {
|
|
354
|
-
await callService("graph.link", { from: params.from
|
|
355
|
-
const names = await artifactNamesById([params.from
|
|
356
|
-
const output = `Linked "${names.get(params.from
|
|
457
|
+
await callService("graph.link", { from: params.from as string, relation: params.relation as string, to: params.to as string });
|
|
458
|
+
const names = await artifactNamesById([params.from as string, params.to as string]);
|
|
459
|
+
const output = `Linked "${names.get(params.from as string) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to as string) ?? "unknown artifact"}"`;
|
|
357
460
|
return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
|
|
358
461
|
}
|
|
359
462
|
if (params.action === "unlink") {
|
|
360
|
-
const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from
|
|
361
|
-
const names = await artifactNamesById([params.from
|
|
362
|
-
const relationship = `"${names.get(params.from
|
|
463
|
+
const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from as string, relation: params.relation as string, to: params.to as string });
|
|
464
|
+
const names = await artifactNamesById([params.from as string, params.to as string]);
|
|
465
|
+
const relationship = `"${names.get(params.from as string) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to as string) ?? "unknown artifact"}"`;
|
|
363
466
|
const output = result.removed ? `Unlinked ${relationship}` : `No such relationship: ${relationship}`;
|
|
364
467
|
return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
|
|
365
468
|
}
|
|
@@ -455,6 +558,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
455
558
|
import("./discuss.ts"),
|
|
456
559
|
]);
|
|
457
560
|
let overlay: TaskOverlay | undefined;
|
|
561
|
+
let noteOverlay: NoteOverlay | undefined;
|
|
458
562
|
|
|
459
563
|
pi.registerCommand("tasks", {
|
|
460
564
|
description: "Browse and manage Papyrus tasks (interactive)",
|
|
@@ -471,11 +575,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
471
575
|
});
|
|
472
576
|
pi.registerCommand("note", {
|
|
473
577
|
description: "Capture a deferred request directly in Papyrus",
|
|
474
|
-
handler: async (args, ctx) => {
|
|
578
|
+
handler: async (args, ctx) => {
|
|
579
|
+
await notesModule.captureNote(args, ctx);
|
|
580
|
+
await noteOverlay?.refresh();
|
|
581
|
+
},
|
|
475
582
|
});
|
|
476
583
|
pi.registerCommand("notes", {
|
|
477
584
|
description: "Browse and triage the project Notes inbox",
|
|
478
|
-
handler: async (_args, ctx) => {
|
|
585
|
+
handler: async (_args, ctx) => {
|
|
586
|
+
noteOverlay?.setProjectRoot(ctx.cwd);
|
|
587
|
+
await notesModule.showNotes(ctx);
|
|
588
|
+
await noteOverlay?.refresh();
|
|
589
|
+
},
|
|
479
590
|
});
|
|
480
591
|
pi.registerCommand("rules", {
|
|
481
592
|
description: "Browse, preview, and toggle Papyrus rules (interactive)",
|
|
@@ -569,14 +680,22 @@ export default async function (pi: ExtensionAPI) {
|
|
|
569
680
|
overlay.setSessionId(ctx.sessionManager.getSessionId());
|
|
570
681
|
await overlay.refresh();
|
|
571
682
|
overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
|
|
683
|
+
|
|
684
|
+
noteOverlay ??= new NoteOverlay();
|
|
685
|
+
noteOverlay.setUI(ctx.ui);
|
|
686
|
+
noteOverlay.setProjectRoot(ctx.cwd);
|
|
687
|
+
await noteOverlay.refresh();
|
|
688
|
+
noteOverlay.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
|
|
572
689
|
});
|
|
573
690
|
|
|
574
691
|
pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
|
|
575
|
-
pi.on("session_compact", async () => { await overlay?.refresh(); });
|
|
576
|
-
pi.on("session_tree", async () => { await overlay?.refresh(); });
|
|
692
|
+
pi.on("session_compact", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
|
|
693
|
+
pi.on("session_tree", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
|
|
577
694
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
578
695
|
overlay?.dispose();
|
|
579
696
|
overlay = undefined;
|
|
697
|
+
noteOverlay?.dispose();
|
|
698
|
+
noteOverlay = undefined;
|
|
580
699
|
try {
|
|
581
700
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
582
701
|
await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
@@ -586,11 +705,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
586
705
|
}
|
|
587
706
|
});
|
|
588
707
|
|
|
589
|
-
// Update
|
|
708
|
+
// Update widgets after any papyrus tool call
|
|
590
709
|
pi.on("tool_execution_end", async (event) => {
|
|
591
710
|
if (event.toolName.startsWith("papyrus_") || event.toolName === "tasks") {
|
|
592
711
|
await overlay?.refresh();
|
|
593
712
|
}
|
|
713
|
+
if (event.toolName === "notes") {
|
|
714
|
+
await noteOverlay?.refresh();
|
|
715
|
+
}
|
|
594
716
|
});
|
|
595
717
|
|
|
596
718
|
// ── Keep driving active work after Pi has exhausted built-in continuations ──
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/** Hidden at 0, matching TaskOverlay's own "nothing open" hiding rule. */
|
|
5
|
+
export function renderNoteWidgetLines(theme: Theme, openCount: number, width: number): string[] {
|
|
6
|
+
if (openCount === 0) return [];
|
|
7
|
+
return [truncateToWidth(`${theme.fg("muted", "Notes")} ${theme.fg("accent", String(openCount))}`, width, "…")];
|
|
8
|
+
}
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -71,6 +71,8 @@ export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
|
71
71
|
* directly from a shell. This bounded poll is the fallback for exactly that gap.
|
|
72
72
|
*/
|
|
73
73
|
export const TASK_WIDGET_POLL_INTERVAL_MS = 20_000;
|
|
74
|
+
/** Same fallback purpose as TASK_WIDGET_POLL_INTERVAL_MS, for the Notes widget's own count. */
|
|
75
|
+
export const NOTE_WIDGET_POLL_INTERVAL_MS = 20_000;
|
|
74
76
|
export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
75
77
|
export const TASK_DETAIL_RESERVED_ROWS = 8;
|
|
76
78
|
export const TASK_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;
|