@danypops/pi-papyrus 0.44.0 → 0.45.1
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/README.md
CHANGED
|
@@ -10,13 +10,14 @@ The `papyrus_*` tools are the low-level graph-store API:
|
|
|
10
10
|
- **`papyrus_graph`** — link artifacts, perform bounded traversal, or read the mutation event log
|
|
11
11
|
- **`papyrus_show`** — read nested metadata and bounded edges, optionally running gates
|
|
12
12
|
|
|
13
|
-
Agent-facing domain tools own lifecycle invariants and sit above this store API. `
|
|
13
|
+
Agent-facing domain tools own lifecycle invariants and sit above this store API. `notes`, `docs`, `rules`, `playbooks`, `tasks`, and `discuss` are all projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `playbooks_invoke`, `tasks_complete`, `discuss_open`, and so on) -- no `action` dispatch, each with its own schema. `discuss_open`/`discuss_reply` additionally accept `live: true` for a synchronous human round-trip, via vehicle-client-pi's `interactiveFollowUps` hook rather than a bespoke tool:
|
|
14
14
|
|
|
15
15
|
- **tasks** (`tasks_create`, `tasks_update`, `tasks_list`, `tasks_show`, `tasks_plan`, `tasks_graph`, `tasks_focus`, `tasks_pause`, `tasks_unpause`, `tasks_clear_focus`, `tasks_start`, `tasks_submit`, `tasks_complete`, `tasks_reject`, `tasks_retry`, `tasks_cancel`, `tasks_cancel_subtree`, `tasks_run_gates`, `tasks_set_checklist`, `tasks_set_gates`, `tasks_depend`, `tasks_undepend`, `tasks_contain`, `tasks_uncontain`, `tasks_claim`, `tasks_heartbeat_lease`, `tasks_release_lease`, `tasks_lease`, `tasks_context`, `tasks_event_feed`, `tasks_scope`, `tasks_set_scope`, `tasks_assign_project`, `tasks_active`, `tasks_focused`, `tasks_history`) — manages the singleton active focus, evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort. `project_root` is required wherever a plain `tasks` call would otherwise need one (list/graph/plan/active/focused/scope/context/create) -- there is no ambient Pi cwd server-side. `tasks_focus`/`tasks_pause`/`tasks_unpause`/`tasks_clear_focus` still authorize their write via this session's own cached secret, and still broadcast `papyrus.task-focus.v1` on Pi's own event bus for a sibling extension to observe, exactly as before
|
|
16
16
|
- **notes** (`notes_capture`, `notes_list`, `notes_show`, `notes_consume`, `notes_promote`, `notes_archive`) — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Playbook, or archive it with an explicit disposition
|
|
17
17
|
- **docs** (`docs_create`, `docs_list`, `docs_show`, `docs_activate`, `docs_archive`, `docs_reopen`, `docs_link`, `docs_assign_project`, `docs_update`) — activate/archive/reopen and document-safe graph links; Note mutations remain behind the Notes facade
|
|
18
18
|
- **rules** (`rules_create`, `rules_list`, `rules_show`, `rules_preview`, `rules_enable`, `rules_disable`, `rules_gate`, `rules_assign_project`, `rules_update`) — enable/disable and attach governance gates to tasks
|
|
19
|
-
- **playbooks** (`playbooks_create`, `playbooks_list`, `playbooks_show`, `playbooks_invoke`, `playbooks_preview`, `playbooks_enable`, `playbooks_disable`, `playbooks_assign_project`, `playbooks_update`, `playbooks_contain`, `playbooks_uncontain`, `playbooks_depend`, `playbooks_undepend`) — a trigger and an ordered list of steps. Each step is a plain prose string (a task), or a structured object: `{kind:'doc',title,body?,subtype?,labels?}` creates a Doc, `{kind:'rule',title,body?,condition?,action?,severity?,labels?}` creates a Rule, `{kind:'call',title,playbookId,arguments?}` nests another Playbook's own run as a pipeline step gated in the same sequence, `{kind:'task',title?,body}` is an explicit task step. `playbooks_invoke` recycles the shared blueprint materialization engine: it compiles the steps and any `contain`/`depend` composition into real artifacts (a Task per plain/task step, a Doc/Rule per doc/rule step, a nested run per call step), wires task-like steps with `dependsOn` so completing one auto-focuses the next, and focuses the first real task. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `playbooks_contain`/`playbooks_uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `playbooks_depend`/`playbooks_undepend` chain a prerequisite Playbook before another (it must fully complete first) -- both are whole-Playbook composition, distinct from a `call` step's finer-grained, single-step nesting. `playbooks_preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?, type?('string'|'number'|'boolean', default 'string'), enum?, default?}`, required defaults true; referenced in step text/call arguments as `{{name}}`); invoking with a required one unsupplied creates nothing and reports exactly which are still missing, directing the agent to ask via `
|
|
19
|
+
- **playbooks** (`playbooks_create`, `playbooks_list`, `playbooks_show`, `playbooks_invoke`, `playbooks_preview`, `playbooks_enable`, `playbooks_disable`, `playbooks_assign_project`, `playbooks_update`, `playbooks_contain`, `playbooks_uncontain`, `playbooks_depend`, `playbooks_undepend`) — a trigger and an ordered list of steps. Each step is a plain prose string (a task), or a structured object: `{kind:'doc',title,body?,subtype?,labels?}` creates a Doc, `{kind:'rule',title,body?,condition?,action?,severity?,labels?}` creates a Rule, `{kind:'call',title,playbookId,arguments?}` nests another Playbook's own run as a pipeline step gated in the same sequence, `{kind:'task',title?,body}` is an explicit task step. `playbooks_invoke` recycles the shared blueprint materialization engine: it compiles the steps and any `contain`/`depend` composition into real artifacts (a Task per plain/task step, a Doc/Rule per doc/rule step, a nested run per call step), wires task-like steps with `dependsOn` so completing one auto-focuses the next, and focuses the first real task. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `playbooks_contain`/`playbooks_uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `playbooks_depend`/`playbooks_undepend` chain a prerequisite Playbook before another (it must fully complete first) -- both are whole-Playbook composition, distinct from a `call` step's finer-grained, single-step nesting. `playbooks_preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?, type?('string'|'number'|'boolean', default 'string'), enum?, default?}`, required defaults true; referenced in step text/call arguments as `{{name}}`); invoking with a required one unsupplied creates nothing and reports exactly which are still missing, directing the agent to ask via `discuss_open`/`discuss_reply` with `live:true` rather than guess
|
|
20
|
+
- **discuss** (`discuss_open`, `discuss_reply`, `discuss_defer`, `discuss_resume`, `discuss_settle`, `discuss_block`, `discuss_unblock`, `discuss_show`, `discuss_rounds`, `discuss_list`) — a Discussion persists across multiple rounds and can genuinely block a Task's completion until settled or deferred (`discuss_block`/`discuss_unblock`). `discuss_reply` is refused once deferred or settled -- `discuss_resume` first. `discuss_open`/`discuss_reply` can pose a structured choice via `options` (2-10 entries, each a bare string or `{title, description}`) + `options_mode` (`single`/`multi`); a later `discuss_reply` answers it via `selected`, validated against it. `live: true` on either gets the human's answer synchronously in the same tool call -- the operation itself always durably records the round first (exactly like every other Vehicle operation), then an optional local prompt (the pending choice's picker if one was posed, otherwise a freeform question) runs via a per-operation `interactiveFollowUps` resolver (see `@danypops/vehicle-client-pi`), degrading silently to the plain async round when there's no interactive UI
|
|
20
21
|
|
|
21
22
|
Every tool operation is registered in the daemon's `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
|
|
22
23
|
|
|
@@ -52,7 +53,7 @@ A persistent widget (matching the Task widget) shows a simple `Notes N` count, s
|
|
|
52
53
|
|
|
53
54
|
## Discuss
|
|
54
55
|
|
|
55
|
-
Discuss is a native, persistent deliberation, distinct from a one-shot ask: it survives across turns and sessions, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. A Discussion is a `
|
|
56
|
+
Discuss is a native, persistent deliberation, distinct from a one-shot ask: it survives across turns and sessions, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. A Discussion is a `task` artifact with `subtype: "discussion"` -- real graph citizenship (edges, show/list, `blocks`) without a dedicated enforced artifact kind of its own. Its fine-grained lifecycle (`active`/`deferred`/`settled`) lives in `extra.discussion`, since Papyrus enforces status vocabulary per kind, not per subtype.
|
|
56
57
|
|
|
57
58
|
Rounds are a dedicated append-only child table (mirroring Task history's own shape): `open` records round 1, `reply` appends further rounds, refused once the Discussion is `deferred` or `settled` -- resume first. `defer` is explicitly non-blocking (paused, reason optional, resumable); `settle` is terminal, records an outcome, and archives the Doc. `block`/`unblock` manage the blocking relationship to a Task independently of `open`.
|
|
58
59
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discuss.open/discuss.reply's live:true synchronous human round-trip -- the
|
|
3
|
+
* one piece of discuss's own retired pi.registerTool() the plain
|
|
4
|
+
* request/response Vehicle projection can't express on its own, wired in via
|
|
5
|
+
* vehicle-client-pi's interactiveFollowUps hook (vehicle-notes-client.ts).
|
|
6
|
+
*
|
|
7
|
+
* The primary invoke() always durably records the round first, exactly like
|
|
8
|
+
* every other Vehicle operation -- this only ever adds an OPTIONAL synchronous
|
|
9
|
+
* prompt on top when ctx.hasUI and the caller actually asked for one. No
|
|
10
|
+
* answer (canceled, no UI, live not requested) falls back to the operation's
|
|
11
|
+
* own default content/details, matching the retired tool's exact fallback
|
|
12
|
+
* behavior.
|
|
13
|
+
*/
|
|
14
|
+
import type { DiscussionAndRounds } from "@danypops/papyrus";
|
|
15
|
+
import { readDiscussionExtra } from "@danypops/papyrus";
|
|
16
|
+
import type { PiVehicleInteractiveFollowUp } from "@danypops/vehicle-client-pi";
|
|
17
|
+
import { askQuestion } from "./discuss-ask-view.ts";
|
|
18
|
+
|
|
19
|
+
export const discussLiveFollowUp: PiVehicleInteractiveFollowUp = async (request, output, client) => {
|
|
20
|
+
const live = (request.input as { live?: unknown } | undefined)?.live === true;
|
|
21
|
+
if (!live || !request.context.hasUI) return undefined;
|
|
22
|
+
|
|
23
|
+
const result = output as DiscussionAndRounds;
|
|
24
|
+
const pending = (() => {
|
|
25
|
+
try {
|
|
26
|
+
return readDiscussionExtra(result.discussion.extra);
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
// The just-recorded round's own content IS the real question -- a generic "Reply to
|
|
32
|
+
// <title>:" wrapper as the primary question, with the real content demoted to
|
|
33
|
+
// "Context:", left a human staring at a labeled-backwards prompt (live-observed on the
|
|
34
|
+
// retired tool). The wrapper is now only a fallback for the degenerate case of empty
|
|
35
|
+
// content; the title becomes a plain orientation subtitle instead.
|
|
36
|
+
const question = result.rounds[0]?.content?.trim() || `Reply to "${result.discussion.title}":`;
|
|
37
|
+
const subtitle = result.discussion.title;
|
|
38
|
+
const answer = await askQuestion(request.context, {
|
|
39
|
+
question,
|
|
40
|
+
subtitle,
|
|
41
|
+
onUpdate: request.onUpdate,
|
|
42
|
+
signal: request.signal,
|
|
43
|
+
...(pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
|
|
44
|
+
? {
|
|
45
|
+
options: pending.pendingOptions.map((title, index) => ({
|
|
46
|
+
title,
|
|
47
|
+
description: pending.pendingOptionDescriptions?.[index] || undefined,
|
|
48
|
+
})),
|
|
49
|
+
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
});
|
|
53
|
+
if (!answer) return undefined; // canceled, or no UI capable of answering -- default content/details stand
|
|
54
|
+
|
|
55
|
+
const answered = (await client.invoke("discuss.reply", 1, {
|
|
56
|
+
id: result.discussion.id,
|
|
57
|
+
actor: "human",
|
|
58
|
+
content: answer.content,
|
|
59
|
+
...(answer.selected ? { selected: answer.selected } : {}),
|
|
60
|
+
source: "discuss-live",
|
|
61
|
+
})) as DiscussionAndRounds;
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text", text: `"${answered.discussion.title}": ${answer.content}` }],
|
|
64
|
+
output: answered,
|
|
65
|
+
};
|
|
66
|
+
};
|
package/extension/src/discuss.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* discuss.ts — /discuss interactive panel.
|
|
3
3
|
* Reuses the generic artifact browser (artifact-browser.ts), same as docs.ts/rules.ts/notes.ts:
|
|
4
|
-
* a Discussion is a `
|
|
5
|
-
* applies unchanged. The one real wrinkle is that
|
|
6
|
-
* (active/deferred/settled) lives in extra.discussion, not
|
|
7
|
-
* browser colors its row glyph by (see
|
|
8
|
-
* comment) -- so the real state
|
|
9
|
-
*
|
|
4
|
+
* a Discussion is a `task` artifact (subtype "discussion"), so the browser's
|
|
5
|
+
* list/filter/refresh/select-action loop applies unchanged. The one real wrinkle is that
|
|
6
|
+
* Discuss's meaningful lifecycle state (active/deferred/settled) lives in extra.discussion, not
|
|
7
|
+
* the shared status column the browser colors its row glyph by (see
|
|
8
|
+
* artifact-status-presentation.ts's DISCUSSION_STATE_PRESENTATION comment) -- so the real state
|
|
9
|
+
* is surfaced in rowMeta text instead, the same way rules.ts surfaces severity and notes.ts
|
|
10
|
+
* surfaces history count, both also not the row glyph.
|
|
10
11
|
*
|
|
11
|
-
* Creating a new Discussion is left to the agent (
|
|
12
|
-
* playbooks.ts precedent -- Notes is the one
|
|
13
|
-
* because Notes exists specifically as a
|
|
12
|
+
* Creating a new Discussion is left to the agent (discuss_open, Vehicle-projected -- see
|
|
13
|
+
* vehicle-notes-client.ts), matching docs.ts/rules.ts/playbooks.ts precedent -- Notes is the one
|
|
14
|
+
* kind with a human-facing creation command (/note), because Notes exists specifically as a
|
|
15
|
+
* human-authored inbox.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
import { type Artifact, type DiscussionAndRounds, readDiscussionExtra } from "@danypops/papyrus";
|
|
@@ -82,7 +84,7 @@ function discussionActions(discussion: Artifact): string[] {
|
|
|
82
84
|
|
|
83
85
|
export async function showDiscussions(ctx: ExtensionCommandContext): Promise<void> {
|
|
84
86
|
await showArtifactBrowser(ctx, {
|
|
85
|
-
kind: "
|
|
87
|
+
kind: "discussion",
|
|
86
88
|
title: "Discussions",
|
|
87
89
|
listOperation: "discuss.list",
|
|
88
90
|
statusOrder: ["draft", "active", "archived"],
|
|
@@ -1,98 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import { Type } from "typebox";
|
|
4
|
-
import { askQuestion } from "./discuss-ask-view.ts";
|
|
1
|
+
import type { Artifact, OperationName } from "@danypops/papyrus";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
3
|
import { callService } from "./service-client.ts";
|
|
6
|
-
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
7
|
-
import {
|
|
8
|
-
createArtifactDetails,
|
|
9
|
-
createArtifactListDetails,
|
|
10
|
-
createModelContent,
|
|
11
|
-
createPreviewDetails,
|
|
12
|
-
} from "./tool-rendering/render-model.ts";
|
|
13
|
-
|
|
14
|
-
function text(message: string, details: unknown = {}) {
|
|
15
|
-
const modelContent = createModelContent(message);
|
|
16
|
-
return { content: [{ type: "text" as const, text: modelContent.text }], details };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* live:true's synchronous half: reuses the same Discuss-owned ask UI (discuss-ask-view.ts) the
|
|
21
|
-
* /discuss TUI's own "Reply" action uses when the just-created round posed a structured choice,
|
|
22
|
-
* or a plain freeform prompt otherwise -- so "ask" covers both a completely open question and a
|
|
23
|
-
* choice tied to this specific Discussion. Returns undefined on cancel or when no interactive UI
|
|
24
|
-
* is available, never throws -- an unanswered live prompt still leaves the round it already
|
|
25
|
-
* recorded intact.
|
|
26
|
-
*/
|
|
27
|
-
/**
|
|
28
|
-
* The discuss tool accepts each option as either a bare string (self-evident choices) or
|
|
29
|
-
* {title, description} (a real tradeoff worth spelling out) -- normalizes to the two parallel
|
|
30
|
-
* arrays discuss.open/discuss.reply actually expect (options: string[], option_descriptions:
|
|
31
|
-
* string[], index-aligned, empty string meaning "none for this one"). Mutates params in place.
|
|
32
|
-
*/
|
|
33
|
-
function normalizeDiscussOptions(params: Record<string, unknown>): void {
|
|
34
|
-
const raw = params.options;
|
|
35
|
-
if (!Array.isArray(raw)) return;
|
|
36
|
-
let anyDescription = false;
|
|
37
|
-
const titles: string[] = [];
|
|
38
|
-
const descriptions: string[] = [];
|
|
39
|
-
for (const entry of raw) {
|
|
40
|
-
if (typeof entry === "string") {
|
|
41
|
-
titles.push(entry);
|
|
42
|
-
descriptions.push("");
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).title === "string") {
|
|
46
|
-
const record = entry as Record<string, unknown>;
|
|
47
|
-
titles.push(record.title as string);
|
|
48
|
-
const description = typeof record.description === "string" ? record.description : "";
|
|
49
|
-
if (description) anyDescription = true;
|
|
50
|
-
descriptions.push(description);
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
titles.push(String(entry));
|
|
54
|
-
descriptions.push("");
|
|
55
|
-
}
|
|
56
|
-
params.options = titles;
|
|
57
|
-
if (anyDescription) params.option_descriptions = descriptions;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function liveAnswer(
|
|
61
|
-
ctx: ExtensionContext,
|
|
62
|
-
discussion: Artifact,
|
|
63
|
-
latestContent: string | undefined,
|
|
64
|
-
onUpdate: AgentToolUpdateCallback | undefined,
|
|
65
|
-
signal: AbortSignal | undefined,
|
|
66
|
-
): Promise<{ content: string; selected?: string[] } | undefined> {
|
|
67
|
-
if (!ctx.hasUI) return undefined;
|
|
68
|
-
const pending = (() => {
|
|
69
|
-
try {
|
|
70
|
-
return readDiscussionExtra(discussion.extra);
|
|
71
|
-
} catch {
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
})();
|
|
75
|
-
// The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
|
|
76
|
-
// wrapper as the primary question, with the real content demoted to "Context:", left a human
|
|
77
|
-
// staring at a labeled-backwards prompt (live-observed). The wrapper is now only a fallback for
|
|
78
|
-
// the degenerate case of empty content; the title becomes a plain orientation subtitle instead.
|
|
79
|
-
const question = latestContent?.trim() || `Reply to "${discussion.title}":`;
|
|
80
|
-
const subtitle = discussion.title;
|
|
81
|
-
if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
|
|
82
|
-
return askQuestion(ctx, {
|
|
83
|
-
question,
|
|
84
|
-
subtitle,
|
|
85
|
-
options: pending.pendingOptions.map((title, index) => ({
|
|
86
|
-
title,
|
|
87
|
-
description: pending.pendingOptionDescriptions?.[index] || undefined,
|
|
88
|
-
})),
|
|
89
|
-
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
90
|
-
onUpdate,
|
|
91
|
-
signal,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
return askQuestion(ctx, { question, subtitle, onUpdate, signal });
|
|
95
|
-
}
|
|
96
4
|
|
|
97
5
|
/**
|
|
98
6
|
* Every domain tool's primary interfacing point is an artifact's NAME, not its id -- id is a
|
|
@@ -202,195 +110,8 @@ export async function resolveNameFields(
|
|
|
202
110
|
}
|
|
203
111
|
}
|
|
204
112
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
listOperation: OperationName,
|
|
211
|
-
baseRequest: Record<string, unknown>,
|
|
212
|
-
notes?: string[],
|
|
213
|
-
): Promise<void> {
|
|
214
|
-
const names = params[namesKey];
|
|
215
|
-
if (Array.isArray(names) && names.length > 0 && !params[idsKey]) {
|
|
216
|
-
params[idsKey] = await Promise.all(names.map((entry) => resolveArtifactIdByName(listOperation, baseRequest, String(entry), notes)));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Shared "remove"/"restore" dispatch for every domain tool (tasks/docs/rules/playbooks) --
|
|
222
|
-
* artifact.remove/restore are kind-agnostic composition-root operations (see service.ts),
|
|
223
|
-
* not owned by any one domain module, so every domain tool exposes the same two actions
|
|
224
|
-
* over the same two operations rather than reinventing trash semantics four times.
|
|
225
|
-
* Returns null when action is neither, so callers fall through to their own dispatch.
|
|
226
|
-
*/
|
|
227
|
-
async function _handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
|
|
228
|
-
// Trashed/restored artifacts stay directly showable, so known identities render by title on
|
|
229
|
-
// either side of the action. An unresolved explicit id stays in structured/error channels;
|
|
230
|
-
// normal model text does not turn that backend key into the artifact's public name.
|
|
231
|
-
const titleOf = async (): Promise<string> => {
|
|
232
|
-
try {
|
|
233
|
-
const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params.id });
|
|
234
|
-
return artifact ? `"${artifact.title}"` : "unknown artifact";
|
|
235
|
-
} catch {
|
|
236
|
-
return "unknown artifact";
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
if (action === "remove") {
|
|
240
|
-
const label = await titleOf();
|
|
241
|
-
const record = await callService<
|
|
242
|
-
Record<string, unknown>,
|
|
243
|
-
{ artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }
|
|
244
|
-
>("artifact.remove", params);
|
|
245
|
-
const message = `Trashed ${label}, eligible for purge at ${record.purgeAfter}.`;
|
|
246
|
-
return text(message, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
|
|
247
|
-
}
|
|
248
|
-
if (action === "restore") {
|
|
249
|
-
const label = await titleOf();
|
|
250
|
-
const outcome = await callService<Record<string, unknown>, { restored: boolean }>("artifact.restore", params);
|
|
251
|
-
const output = outcome.restored ? `Restored ${label}.` : `${label} was not trashed.`;
|
|
252
|
-
return text(output, createPreviewDetails("artifact.restore", "Restored", output));
|
|
253
|
-
}
|
|
254
|
-
if (action === "remove_subtree") {
|
|
255
|
-
const label = await titleOf();
|
|
256
|
-
const outcome = await callService<Record<string, unknown>, { removed: string[]; skipped: string[] }>("artifact.remove_subtree", params);
|
|
257
|
-
const message = `Trashed ${label} and ${outcome.removed.length - 1} contained artifact(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-trashed` : ""}.`;
|
|
258
|
-
return text(message, createPreviewDetails("artifact.remove_subtree", "Trashed subtree", JSON.stringify(outcome, null, 2)));
|
|
259
|
-
}
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// notes.*, rules.*, docs.*, playbooks.*, tasks.*, and the shared artifact.* are
|
|
264
|
-
// registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
|
|
265
|
-
// src/vehicle/papyrus-vehicle.ts), not pi.registerTool()s in this file.
|
|
266
|
-
|
|
267
|
-
export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
268
|
-
pi.registerTool({
|
|
269
|
-
name: "discuss",
|
|
270
|
-
label: "Discuss",
|
|
271
|
-
description:
|
|
272
|
-
"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. The live picker docks in the input area itself (falls back to a plain text prompt 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.",
|
|
273
|
-
parameters: Type.Object({
|
|
274
|
-
action: Type.String(),
|
|
275
|
-
id: Type.Optional(Type.String()),
|
|
276
|
-
name: Type.Optional(Type.String()),
|
|
277
|
-
title: Type.Optional(Type.String()),
|
|
278
|
-
actor: Type.Optional(Type.String()),
|
|
279
|
-
content: Type.Optional(Type.String()),
|
|
280
|
-
body: Type.Optional(Type.String()),
|
|
281
|
-
labels: Type.Optional(Type.Array(Type.String())),
|
|
282
|
-
blocks_task_ids: Type.Optional(Type.Array(Type.String())),
|
|
283
|
-
blocks_task_names: Type.Optional(Type.Array(Type.String())),
|
|
284
|
-
task_id: Type.Optional(Type.String()),
|
|
285
|
-
task_name: Type.Optional(Type.String()),
|
|
286
|
-
reason: Type.Optional(Type.String()),
|
|
287
|
-
settlement: Type.Optional(Type.String()),
|
|
288
|
-
state: Type.Optional(Type.String()),
|
|
289
|
-
after_round: Type.Optional(Type.Number()),
|
|
290
|
-
limit: Type.Optional(Type.Number()),
|
|
291
|
-
options: Type.Optional(
|
|
292
|
-
Type.Array(Type.Union([Type.String(), Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) })])),
|
|
293
|
-
),
|
|
294
|
-
options_mode: Type.Optional(Type.String()),
|
|
295
|
-
selected: Type.Optional(Type.Array(Type.String())),
|
|
296
|
-
live: Type.Optional(Type.Boolean()),
|
|
297
|
-
}),
|
|
298
|
-
// Blocks other tool calls in the same assistant turn until live:true's human answer comes
|
|
299
|
-
// back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
|
|
300
|
-
// bash/edit/write and let those run before the human sees the prompt.
|
|
301
|
-
executionMode: "sequential",
|
|
302
|
-
renderCall(args, theme) {
|
|
303
|
-
return renderPapyrusToolCall("Discuss", args, theme);
|
|
304
|
-
},
|
|
305
|
-
renderResult(result, options, theme, context) {
|
|
306
|
-
return renderPapyrusToolResult(result, options, theme, context);
|
|
307
|
-
},
|
|
308
|
-
async execute(_id, rawParams, signal, onUpdate, ctx) {
|
|
309
|
-
try {
|
|
310
|
-
const params: Record<string, unknown> = { ...rawParams };
|
|
311
|
-
const action = params.action;
|
|
312
|
-
const taskScope = { project_root: ctx.cwd };
|
|
313
|
-
await resolveNameFields(params, [
|
|
314
|
-
{ nameKey: "name", idKey: "id", listOperation: "discuss.list", baseRequest: {} },
|
|
315
|
-
{ nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
|
|
316
|
-
]);
|
|
317
|
-
await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
|
|
318
|
-
if (action === "open" || action === "reply") {
|
|
319
|
-
normalizeDiscussOptions(params);
|
|
320
|
-
// Matches the tasks/notes tools' own convention: an agent-driven mutation with no
|
|
321
|
-
// explicit human actor still needs a real, non-generic label for the audit trail,
|
|
322
|
-
// not a bare daemon rejection.
|
|
323
|
-
if (typeof params.actor !== "string" || params.actor.length === 0) params.actor = "agent";
|
|
324
|
-
const operation = action === "open" ? "discuss.open" : "discuss.reply";
|
|
325
|
-
const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
|
|
326
|
-
const fallback =
|
|
327
|
-
action === "open"
|
|
328
|
-
? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
|
|
329
|
-
: text(
|
|
330
|
-
`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`,
|
|
331
|
-
createArtifactDetails("discuss.reply", result.discussion),
|
|
332
|
-
);
|
|
333
|
-
if (params.live !== true) return fallback;
|
|
334
|
-
const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
|
|
335
|
-
if (!answer) return fallback;
|
|
336
|
-
const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
|
|
337
|
-
id: result.discussion.id,
|
|
338
|
-
actor: "human",
|
|
339
|
-
content: answer.content,
|
|
340
|
-
...(answer.selected ? { selected: answer.selected } : {}),
|
|
341
|
-
source: "discuss-live",
|
|
342
|
-
});
|
|
343
|
-
return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
|
|
344
|
-
}
|
|
345
|
-
if (action === "block" || action === "unblock") {
|
|
346
|
-
const operation = action === "block" ? "discuss.block" : "discuss.unblock";
|
|
347
|
-
const [outcome, discussionAndRounds, task] = await Promise.all([
|
|
348
|
-
callService<Record<string, unknown>, { blocked?: boolean; unblocked?: boolean }>(operation, params),
|
|
349
|
-
callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: params.id }),
|
|
350
|
-
callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.task_id }),
|
|
351
|
-
]);
|
|
352
|
-
const discussion = discussionAndRounds.discussion;
|
|
353
|
-
const message =
|
|
354
|
-
action === "unblock" && !outcome.unblocked
|
|
355
|
-
? "No such blocking relationship."
|
|
356
|
-
: `"${discussion.title}" ${action === "block" ? "now blocks" : "no longer blocks"} "${task.title}"`;
|
|
357
|
-
return text(message, createPreviewDetails(operation, action === "block" ? "Blocked" : "Unblocked", message));
|
|
358
|
-
}
|
|
359
|
-
if (action === "show") {
|
|
360
|
-
const result = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", params);
|
|
361
|
-
const rounds = result.rounds.map((round) => ` [round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n");
|
|
362
|
-
return text(`${artifactLine(result.discussion)}\n\n${rounds}`, createArtifactDetails("discuss.show", result.discussion));
|
|
363
|
-
}
|
|
364
|
-
if (action === "rounds") {
|
|
365
|
-
const rounds = await callService<Record<string, unknown>, DiscussionRound[]>("discuss.rounds", params);
|
|
366
|
-
const output = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n") || "No rounds.";
|
|
367
|
-
return text(output, createPreviewDetails("discuss.rounds", "Discussion rounds", output));
|
|
368
|
-
}
|
|
369
|
-
if (action === "list") {
|
|
370
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("discuss.list", params);
|
|
371
|
-
return text(
|
|
372
|
-
rows.length ? artifactLines(rows).join("\n") : "No discussions found.",
|
|
373
|
-
createArtifactListDetails("discuss.list", rows),
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
|
|
377
|
-
const operation = operations[action as keyof typeof operations];
|
|
378
|
-
if (!operation) throw new Error(`unknown discuss action: ${action}`);
|
|
379
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
380
|
-
return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
|
|
381
|
-
} catch (error) {
|
|
382
|
-
throw new Error(`discuss failed: ${error instanceof Error ? error.message : error}`);
|
|
383
|
-
}
|
|
384
|
-
},
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
|
|
389
|
-
// notes, rules, docs, playbooks, and tasks are no longer registered here -- all migrated
|
|
390
|
-
// onto Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
|
|
391
|
-
// index.ts), replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
|
|
392
|
-
// src/vehicle/papyrus-vehicle.ts for the server side. discuss remains here -- live:true needs an
|
|
393
|
-
// interactive UI round-trip a stateless Vehicle operation can't express.
|
|
394
|
-
export function registerDomainTools(pi: ExtensionAPI): void {
|
|
395
|
-
registerDiscussTool(pi);
|
|
396
|
-
}
|
|
113
|
+
// notes.*, rules.*, docs.*, playbooks.*, tasks.*, discuss.*, and the shared artifact.*
|
|
114
|
+
// are all registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
|
|
115
|
+
// src/vehicle/papyrus-vehicle.ts) -- no domain has a hand-rolled pi.registerTool() left
|
|
116
|
+
// in this file. What remains here (above) is name-resolution machinery still used
|
|
117
|
+
// directly by index.ts's own low-level tools (papyrus_graph).
|
package/extension/src/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { buildTaskItemTree, computeContextBudget } from "./context-budget.ts";
|
|
|
37
37
|
import { PAPYRUS_CONTEXT_HUB_PRODUCER_NAME, papyrusContextSegment } from "./context-hub-contribution.ts";
|
|
38
38
|
import { buildContextInjection } from "./context-injection-telemetry.ts";
|
|
39
39
|
import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
|
|
40
|
-
import {
|
|
40
|
+
import { resolveNameFields } from "./domain-tools.ts";
|
|
41
41
|
import { renderNoteWidgetLines } from "./note-widget.ts";
|
|
42
42
|
import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
|
|
43
43
|
import { callService, subscribeTaskPushChannel } from "./service-client.ts";
|
|
@@ -351,7 +351,6 @@ export class NoteOverlay {
|
|
|
351
351
|
|
|
352
352
|
export default async function (pi: ExtensionAPI) {
|
|
353
353
|
setTaskFocusEventBus(pi);
|
|
354
|
-
registerDomainTools(pi);
|
|
355
354
|
registerPlaybookBridge(pi);
|
|
356
355
|
let contextInjectionSequence = 0;
|
|
357
356
|
const contextInjectionProducerId = randomUUID();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, playbooks.*,
|
|
3
|
-
* tasks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
|
|
4
|
-
* src/vehicle/papyrus-vehicle.ts.
|
|
3
|
+
* tasks.*, discuss.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
|
|
4
|
+
* src/vehicle/papyrus-vehicle.ts. discuss.* is the last of the six domains to
|
|
5
|
+
* migrate off pi-papyrus's own retired hand-rolled pi.registerTool() mega-tool.
|
|
5
6
|
*
|
|
6
7
|
* Fails silently on a stale/unreachable daemon handle instead of aborting extension
|
|
7
8
|
* setup: Papyrus's daemon doesn't auto-spawn, and a tool that failed to register
|
|
@@ -23,6 +24,7 @@ import { createReconnectingVehicleClient } from "@danypops/vehicle-client/daemon
|
|
|
23
24
|
import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
|
|
24
25
|
import { registerVehicleTools } from "@danypops/vehicle-client-pi";
|
|
25
26
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { discussLiveFollowUp } from "./discuss-live-follow-up.ts";
|
|
26
28
|
import { currentVehicleClientTarget } from "./service-client.ts";
|
|
27
29
|
import { sessionSecretField } from "./session-identity.ts";
|
|
28
30
|
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
@@ -39,6 +41,8 @@ const REGISTERED_PERMISSIONS = [
|
|
|
39
41
|
"playbooks:write",
|
|
40
42
|
"tasks:read",
|
|
41
43
|
"tasks:write",
|
|
44
|
+
"discuss:read",
|
|
45
|
+
"discuss:write",
|
|
42
46
|
"artifact:read",
|
|
43
47
|
"artifact:write",
|
|
44
48
|
];
|
|
@@ -92,6 +96,15 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
|
|
|
92
96
|
// token-cost router correlating its own telemetry with the currently focused task)
|
|
93
97
|
// -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
|
|
94
98
|
// than from the operation's own output.
|
|
99
|
+
// discuss.open/discuss.reply's own live:true synchronous human round-trip --
|
|
100
|
+
// see discuss-live-follow-up.ts. Every other operation's resolver call
|
|
101
|
+
// returns undefined, meaning zero behavior change for the other 5 domains.
|
|
102
|
+
interactiveFollowUps: (descriptor) =>
|
|
103
|
+
descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? discussLiveFollowUp : undefined,
|
|
104
|
+
// The retired discuss tool declared executionMode: "sequential" so the model
|
|
105
|
+
// couldn't batch a live ask alongside other tool calls in the same turn and
|
|
106
|
+
// let those run before the human sees the prompt -- same reasoning here.
|
|
107
|
+
executionMode: (descriptor) => (descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? "sequential" : undefined),
|
|
95
108
|
onInvoked: ({ descriptor }, output) => {
|
|
96
109
|
if (descriptor.name === "tasks.focus") {
|
|
97
110
|
const artifact = output as { id: string } | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.1",
|
|
4
4
|
"description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@danypops/jittor": "^0.14.0",
|
|
21
|
-
"@danypops/papyrus": "^0.
|
|
21
|
+
"@danypops/papyrus": "^0.44.0",
|
|
22
22
|
"@danypops/vehicle-client": "^0.5.0",
|
|
23
|
-
"@danypops/vehicle-client-pi": "^0.
|
|
23
|
+
"@danypops/vehicle-client-pi": "^0.15.0",
|
|
24
24
|
"@danypops/vehicle-core": "^0.10.0",
|
|
25
|
-
"@danypops/vehicle-server": "^0.
|
|
25
|
+
"@danypops/vehicle-server": "^0.13.0",
|
|
26
26
|
"beautiful-mermaid": "1.1.3",
|
|
27
27
|
"malevich-tui-components": "^0.20.2"
|
|
28
28
|
},
|