@danypops/pi-papyrus 0.43.1 → 0.43.3
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/artifact-browser.ts +54 -31
- package/extension/src/artifact-detail-view.ts +52 -42
- package/extension/src/artifact-format.ts +11 -5
- package/extension/src/artifact-relationship-lines.ts +4 -5
- package/extension/src/beautiful-mermaid-renderer.ts +3 -5
- package/extension/src/context-budget.ts +8 -5
- package/extension/src/context-hub-contribution.ts +6 -2
- package/extension/src/context-injection-telemetry.ts +2 -2
- package/extension/src/discuss-ask-layout.ts +3 -1
- package/extension/src/discuss-ask-view.ts +437 -111
- package/extension/src/discuss.ts +64 -15
- package/extension/src/discussion-detail-view.ts +44 -22
- package/extension/src/docs.ts +3 -2
- package/extension/src/domain-tools.ts +79 -34
- package/extension/src/index.ts +170 -66
- package/extension/src/markdown.ts +3 -7
- package/extension/src/note-widget.ts +1 -1
- package/extension/src/notes.ts +3 -8
- package/extension/src/playbook-bridge.ts +17 -6
- package/extension/src/playbooks.ts +17 -7
- package/extension/src/rules.ts +5 -5
- package/extension/src/service-client.ts +23 -8
- package/extension/src/skill-catalog-footprint.ts +1 -1
- package/extension/src/task-detail-format.ts +9 -9
- package/extension/src/task-detail-view.ts +36 -29
- package/extension/src/task-focus-events.ts +3 -2
- package/extension/src/task-graph.ts +16 -12
- package/extension/src/task-presentation.ts +2 -6
- package/extension/src/task-widget.ts +12 -8
- package/extension/src/tasks.ts +148 -57
- package/extension/src/tool-rendering/artifact-card.ts +1 -4
- package/extension/src/tool-rendering/artifact-list.ts +23 -24
- package/extension/src/tool-rendering/index.ts +2 -6
- package/extension/src/tool-rendering/render-model.ts +69 -55
- package/extension/src/vehicle-artifact-renderers.ts +58 -0
- package/extension/src/vehicle-notes-client.ts +35 -7
- package/package.json +5 -5
package/extension/src/discuss.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* playbooks.ts precedent -- Notes is the one kind with a human-facing creation command (/note),
|
|
13
13
|
* because Notes exists specifically as a human-authored inbox.
|
|
14
14
|
*/
|
|
15
|
+
|
|
16
|
+
import { type Artifact, type DiscussionAndRounds, readDiscussionExtra } from "@danypops/papyrus";
|
|
15
17
|
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { readDiscussionExtra, type Artifact, type DiscussionAndRounds } from "@danypops/papyrus";
|
|
17
|
-
import { askQuestion } from "./discuss-ask-view.ts";
|
|
18
18
|
import { showArtifactBrowser } from "./artifact-browser.ts";
|
|
19
19
|
import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
20
|
+
import { askQuestion } from "./discuss-ask-view.ts";
|
|
20
21
|
import { discussionRoundCountOf, discussionStateOf, showDiscussionDetailView } from "./discussion-detail-view.ts";
|
|
21
22
|
import { callService } from "./service-client.ts";
|
|
22
23
|
|
|
@@ -33,14 +34,22 @@ export async function openTaskChoices(cwd: string): Promise<Artifact[]> {
|
|
|
33
34
|
export async function blockedTaskChoices(discussionId: string): Promise<Artifact[]> {
|
|
34
35
|
const tree = await callService<Record<string, unknown>, Artifact>("graph.tree", { id: discussionId, depth: 1 });
|
|
35
36
|
const blockedIds = (tree.edges ?? []).filter((edge) => edge.relation === "blocks" && edge.from === discussionId).map((edge) => edge.to);
|
|
36
|
-
const tasks = await Promise.all(
|
|
37
|
+
const tasks = await Promise.all(
|
|
38
|
+
blockedIds.map((id) => callService<Record<string, unknown>, Artifact | null>("tasks.show", { id }).catch(() => null)),
|
|
39
|
+
);
|
|
37
40
|
return tasks.filter((task): task is Artifact => task !== null);
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/** Picking a task by title, the same ui.select pattern used for Discuss's own single-choice options -- no ecosystem extension (Pi's own docs/examples, pi-tasks) builds a bespoke fuzzy picker for a plain "choose one named thing" list. */
|
|
41
44
|
export async function pickTaskByName(ctx: ExtensionCommandContext, title: string, tasks: Artifact[]): Promise<Artifact | undefined> {
|
|
42
|
-
if (tasks.length === 0) {
|
|
43
|
-
|
|
45
|
+
if (tasks.length === 0) {
|
|
46
|
+
ctx.ui.notify("No open tasks to choose from.", "info");
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const label = await ctx.ui.select(
|
|
50
|
+
title,
|
|
51
|
+
tasks.map((task) => `${task.title} [${task.status}]`),
|
|
52
|
+
);
|
|
44
53
|
if (!label) return undefined;
|
|
45
54
|
const index = tasks.map((task) => `${task.title} [${task.status}]`).indexOf(label);
|
|
46
55
|
return index === -1 ? undefined : tasks[index];
|
|
@@ -49,9 +58,17 @@ export async function pickTaskByName(ctx: ExtensionCommandContext, title: string
|
|
|
49
58
|
export function discussionRowMeta(discussion: Artifact, theme: Theme): string {
|
|
50
59
|
const state = discussionStateOf(discussion);
|
|
51
60
|
const presentation = DISCUSSION_STATE_PRESENTATION[state];
|
|
52
|
-
const stateText = presentation
|
|
61
|
+
const stateText = presentation
|
|
62
|
+
? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`)
|
|
63
|
+
: theme.fg("muted", "state unknown");
|
|
53
64
|
const rounds = discussionRoundCountOf(discussion);
|
|
54
|
-
const pending = (() => {
|
|
65
|
+
const pending = (() => {
|
|
66
|
+
try {
|
|
67
|
+
return readDiscussionExtra(discussion.extra).pendingOptions;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
55
72
|
const pendingText = pending && pending.length > 0 ? theme.fg("accent", ` · awaiting: ${pending.join("/")}`) : "";
|
|
56
73
|
return `${stateText} · ${rounds} round${rounds === 1 ? "" : "s"}${pendingText}`;
|
|
57
74
|
}
|
|
@@ -79,18 +96,39 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
|
|
|
79
96
|
return;
|
|
80
97
|
}
|
|
81
98
|
if (choice === "Reply") {
|
|
82
|
-
const pending = (() => {
|
|
99
|
+
const pending = (() => {
|
|
100
|
+
try {
|
|
101
|
+
return readDiscussionExtra(discussion.extra);
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
83
106
|
// Same fix as the live discuss tool: the most recent round's own content IS the real
|
|
84
107
|
// question -- the title becomes a plain orientation subtitle, not a labeled-backwards
|
|
85
108
|
// "Context:" section under a generic "Reply to <title>:" wrapper.
|
|
86
109
|
const transcript = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
|
|
87
110
|
const question = transcript.rounds.at(-1)?.content?.trim() || `Reply to "${discussion.title}":`;
|
|
88
111
|
const subtitle = discussion.title;
|
|
89
|
-
const answer =
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
const answer =
|
|
113
|
+
pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
|
|
114
|
+
? await askQuestion(commandCtx, {
|
|
115
|
+
question,
|
|
116
|
+
subtitle,
|
|
117
|
+
options: pending.pendingOptions.map((title, index) => ({
|
|
118
|
+
title,
|
|
119
|
+
description: pending.pendingOptionDescriptions?.[index] || undefined,
|
|
120
|
+
})),
|
|
121
|
+
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
122
|
+
})
|
|
123
|
+
: await askQuestion(commandCtx, { question, subtitle });
|
|
92
124
|
if (!answer) return; // canceled
|
|
93
|
-
await callService("discuss.reply", {
|
|
125
|
+
await callService("discuss.reply", {
|
|
126
|
+
id: discussion.id,
|
|
127
|
+
actor: ACTOR,
|
|
128
|
+
content: answer.content,
|
|
129
|
+
...(answer.selected ? { selected: answer.selected } : {}),
|
|
130
|
+
source: SOURCE,
|
|
131
|
+
});
|
|
94
132
|
commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
|
|
95
133
|
return;
|
|
96
134
|
}
|
|
@@ -121,11 +159,22 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
|
|
|
121
159
|
}
|
|
122
160
|
if (choice === "Unblock a task") {
|
|
123
161
|
const blocked = await blockedTaskChoices(discussion.id);
|
|
124
|
-
if (blocked.length === 0) {
|
|
162
|
+
if (blocked.length === 0) {
|
|
163
|
+
commandCtx.ui.notify("This discussion isn't blocking any task.", "info");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
125
166
|
const target = await pickTaskByName(commandCtx, "Unblock which task?", blocked);
|
|
126
167
|
if (!target) return;
|
|
127
|
-
const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", {
|
|
128
|
-
|
|
168
|
+
const result = await callService<Record<string, unknown>, { unblocked: boolean }>("discuss.unblock", {
|
|
169
|
+
id: discussion.id,
|
|
170
|
+
task_id: target.id,
|
|
171
|
+
actor: ACTOR,
|
|
172
|
+
source: SOURCE,
|
|
173
|
+
});
|
|
174
|
+
commandCtx.ui.notify(
|
|
175
|
+
result.unblocked ? `"${discussion.title}" no longer blocks "${target.title}"` : "No such blocking relationship.",
|
|
176
|
+
"info",
|
|
177
|
+
);
|
|
129
178
|
}
|
|
130
179
|
},
|
|
131
180
|
});
|
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
* underlying reason (task-detail-view.ts) -- this mirrors that scrolling-viewport idiom rather
|
|
9
9
|
* than inventing a new one.
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
13
12
|
import {
|
|
14
13
|
ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
|
|
15
14
|
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
16
15
|
ARTIFACT_DETAIL_RESERVED_ROWS,
|
|
17
|
-
readDiscussionExtra,
|
|
18
16
|
type Artifact,
|
|
19
17
|
type DiscussionRound,
|
|
18
|
+
readDiscussionExtra,
|
|
20
19
|
} from "@danypops/papyrus";
|
|
21
|
-
import {
|
|
20
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
22
22
|
import { DISCUSSION_STATE_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
23
|
+
import { type ActiveTheme, renderMarkdownBody } from "./markdown.ts";
|
|
23
24
|
|
|
24
25
|
interface TranscriptLine {
|
|
25
26
|
text: string;
|
|
@@ -61,7 +62,9 @@ class DiscussionTranscriptViewport {
|
|
|
61
62
|
);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
invalidate(): void {
|
|
65
|
+
invalidate(): void {
|
|
66
|
+
this.renderedWidth = 0;
|
|
67
|
+
}
|
|
65
68
|
|
|
66
69
|
render(width: number): string[] {
|
|
67
70
|
const contentWidth = Math.max(1, width - 2);
|
|
@@ -70,10 +73,9 @@ class DiscussionTranscriptViewport {
|
|
|
70
73
|
const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
|
|
71
74
|
const theme = this.activeTheme();
|
|
72
75
|
const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
73
|
-
const footer = [
|
|
74
|
-
|
|
75
|
-
"
|
|
76
|
-
].filter(Boolean).join(" · ");
|
|
76
|
+
const footer = [this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "", "Esc back"]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(" · ");
|
|
77
79
|
return [
|
|
78
80
|
border,
|
|
79
81
|
truncateToWidth(theme.fg("accent", theme.bold("Discussion transcript")), width, ""),
|
|
@@ -85,10 +87,14 @@ class DiscussionTranscriptViewport {
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
handleInput(data: string): void {
|
|
88
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
90
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
91
|
+
this.close();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
89
94
|
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
90
95
|
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
|
|
91
|
-
else if (matchesKey(data, "pageDown"))
|
|
96
|
+
else if (matchesKey(data, "pageDown"))
|
|
97
|
+
this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + this.visibleLines);
|
|
92
98
|
else if (matchesKey(data, "pageUp")) this.offsetY = Math.max(0, this.offsetY - this.visibleLines);
|
|
93
99
|
else return;
|
|
94
100
|
this.tui.requestRender();
|
|
@@ -98,7 +104,13 @@ class DiscussionTranscriptViewport {
|
|
|
98
104
|
if (this.renderedWidth === width) return;
|
|
99
105
|
this.renderedWidth = width;
|
|
100
106
|
const theme = this.activeTheme();
|
|
101
|
-
const extra = (() => {
|
|
107
|
+
const extra = (() => {
|
|
108
|
+
try {
|
|
109
|
+
return readDiscussionExtra(this.discussion.extra);
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
102
114
|
const presentation = extra ? DISCUSSION_STATE_PRESENTATION[extra.state] : undefined;
|
|
103
115
|
const stateLine = presentation
|
|
104
116
|
? theme.fg(presentation.color, `${presentation.glyph} ${presentation.label}`)
|
|
@@ -111,14 +123,19 @@ class DiscussionTranscriptViewport {
|
|
|
111
123
|
{ text: "" },
|
|
112
124
|
];
|
|
113
125
|
const transcript: TranscriptLine[] = this.rounds.flatMap((round, index) => {
|
|
114
|
-
const roundHeader =
|
|
126
|
+
const roundHeader =
|
|
127
|
+
theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
|
|
115
128
|
const body = renderMarkdownBody(round.content, width - 2, this.activeTheme).map((line) => ({ text: ` ${line}` }));
|
|
116
|
-
const posed =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
const posed =
|
|
130
|
+
round.options && round.options.length > 0
|
|
131
|
+
? [
|
|
132
|
+
{
|
|
133
|
+
text: ` ${theme.fg("muted", `Posed (${round.optionsMode === "multi" ? "pick several" : "pick one"}): ${round.options.join(", ")}`)}`,
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
: [];
|
|
137
|
+
const picked =
|
|
138
|
+
round.selected && round.selected.length > 0 ? [{ text: ` ${theme.fg("success", `Selected: ${round.selected.join(", ")}`)}` }] : [];
|
|
122
139
|
return [{ text: roundHeader }, ...body, ...posed, ...picked, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
|
|
123
140
|
});
|
|
124
141
|
this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
|
|
@@ -126,12 +143,17 @@ class DiscussionTranscriptViewport {
|
|
|
126
143
|
}
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
export async function showDiscussionDetailView(
|
|
146
|
+
export async function showDiscussionDetailView(
|
|
147
|
+
ctx: ExtensionCommandContext,
|
|
148
|
+
discussion: Artifact,
|
|
149
|
+
rounds: DiscussionRound[],
|
|
150
|
+
): Promise<void> {
|
|
130
151
|
if (ctx.mode !== "tui") {
|
|
131
152
|
const lines = rounds.map((round) => `[round ${round.roundNumber}] ${round.actor}: ${round.content}`);
|
|
132
153
|
ctx.ui.notify([discussion.title, ...lines].join("\n"), "info");
|
|
133
154
|
return;
|
|
134
155
|
}
|
|
135
|
-
await ctx.ui.custom<void>(
|
|
136
|
-
new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done)
|
|
156
|
+
await ctx.ui.custom<void>(
|
|
157
|
+
(tui, theme, _keybindings, done) => new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done),
|
|
158
|
+
);
|
|
137
159
|
}
|
package/extension/src/docs.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import type { Artifact } from "@danypops/papyrus";
|
|
2
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
4
|
import { DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
5
5
|
import { callService } from "./service-client.ts";
|
|
@@ -48,7 +48,8 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
48
48
|
commandCtx.ui.notify(`Linked "${document.title}" via ${relation}`, "info");
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
|
-
const operation =
|
|
51
|
+
const operation =
|
|
52
|
+
choice === "Activate" ? "docs.activate" : choice === "Archive" ? "docs.archive" : choice === "Reopen" ? "docs.reopen" : undefined;
|
|
52
53
|
if (operation) {
|
|
53
54
|
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: document.id });
|
|
54
55
|
commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
+
import { type Artifact, type DiscussionAndRounds, type DiscussionRound, type OperationName, readDiscussionExtra } from "@danypops/papyrus";
|
|
1
2
|
import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
4
|
-
readDiscussionExtra,
|
|
5
|
-
type Artifact,
|
|
6
|
-
type DiscussionAndRounds,
|
|
7
|
-
type DiscussionRound,
|
|
8
|
-
type OperationName,
|
|
9
|
-
} from "@danypops/papyrus";
|
|
10
4
|
import { askQuestion } from "./discuss-ask-view.ts";
|
|
11
5
|
import { callService } from "./service-client.ts";
|
|
12
6
|
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
@@ -43,7 +37,11 @@ function normalizeDiscussOptions(params: Record<string, unknown>): void {
|
|
|
43
37
|
const titles: string[] = [];
|
|
44
38
|
const descriptions: string[] = [];
|
|
45
39
|
for (const entry of raw) {
|
|
46
|
-
if (typeof entry === "string") {
|
|
40
|
+
if (typeof entry === "string") {
|
|
41
|
+
titles.push(entry);
|
|
42
|
+
descriptions.push("");
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
47
45
|
if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).title === "string") {
|
|
48
46
|
const record = entry as Record<string, unknown>;
|
|
49
47
|
titles.push(record.title as string);
|
|
@@ -59,9 +57,21 @@ function normalizeDiscussOptions(params: Record<string, unknown>): void {
|
|
|
59
57
|
if (anyDescription) params.option_descriptions = descriptions;
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
async function liveAnswer(
|
|
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> {
|
|
63
67
|
if (!ctx.hasUI) return undefined;
|
|
64
|
-
const pending = (() => {
|
|
68
|
+
const pending = (() => {
|
|
69
|
+
try {
|
|
70
|
+
return readDiscussionExtra(discussion.extra);
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
65
75
|
// The just-recorded round's own content IS the real question -- a generic "Reply to <title>:"
|
|
66
76
|
// wrapper as the primary question, with the real content demoted to "Context:", left a human
|
|
67
77
|
// staring at a labeled-backwards prompt (live-observed). The wrapper is now only a fallback for
|
|
@@ -72,7 +82,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact, latestCon
|
|
|
72
82
|
return askQuestion(ctx, {
|
|
73
83
|
question,
|
|
74
84
|
subtitle,
|
|
75
|
-
options: pending.pendingOptions.map((title, index) => ({
|
|
85
|
+
options: pending.pendingOptions.map((title, index) => ({
|
|
86
|
+
title,
|
|
87
|
+
description: pending.pendingOptionDescriptions?.[index] || undefined,
|
|
88
|
+
})),
|
|
76
89
|
allowMultiple: pending.pendingOptionsMode === "multi",
|
|
77
90
|
onUpdate,
|
|
78
91
|
signal,
|
|
@@ -96,7 +109,9 @@ export function artifactLine(artifact: Artifact): string {
|
|
|
96
109
|
export function artifactLines(artifacts: Artifact[]): string[] {
|
|
97
110
|
const titleCounts = new Map<string, number>();
|
|
98
111
|
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
99
|
-
return artifacts.map((artifact) =>
|
|
112
|
+
return artifacts.map((artifact) =>
|
|
113
|
+
titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact),
|
|
114
|
+
);
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
/**
|
|
@@ -110,7 +125,9 @@ export function matchArtifactByName(candidates: Artifact[], name: string): strin
|
|
|
110
125
|
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
111
126
|
if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
|
|
112
127
|
if (matches.length > 1) {
|
|
113
|
-
throw new Error(
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${matches.length} artifacts are named "${name}": ${matches.map((artifact) => `${artifact.title} (${artifact.id})`).join(", ")} -- use id to disambiguate`,
|
|
130
|
+
);
|
|
114
131
|
}
|
|
115
132
|
return matches[0]!.id;
|
|
116
133
|
}
|
|
@@ -126,9 +143,7 @@ const SCOPE_AWARE_LIST_OPERATIONS = new Set<OperationName>(["tasks.list"]);
|
|
|
126
143
|
|
|
127
144
|
/** The widened-scope request tried once when a name isn't found under the caller's current scope. */
|
|
128
145
|
function widenedRequest(listOperation: OperationName, baseRequest: Record<string, unknown>): Record<string, unknown> {
|
|
129
|
-
return SCOPE_AWARE_LIST_OPERATIONS.has(listOperation)
|
|
130
|
-
? { ...baseRequest, scope: "all" }
|
|
131
|
-
: { ...baseRequest, project_root: undefined };
|
|
146
|
+
return SCOPE_AWARE_LIST_OPERATIONS.has(listOperation) ? { ...baseRequest, scope: "all" } : { ...baseRequest, project_root: undefined };
|
|
132
147
|
}
|
|
133
148
|
|
|
134
149
|
/**
|
|
@@ -147,13 +162,21 @@ function widenedRequest(listOperation: OperationName, baseRequest: Record<string
|
|
|
147
162
|
* widening, so the caller can surface that a search went wider than the caller's default scope
|
|
148
163
|
* rather than resolving silently.
|
|
149
164
|
*/
|
|
150
|
-
async function resolveArtifactIdByName(
|
|
165
|
+
async function resolveArtifactIdByName(
|
|
166
|
+
listOperation: OperationName,
|
|
167
|
+
baseRequest: Record<string, unknown>,
|
|
168
|
+
name: string,
|
|
169
|
+
notes?: string[],
|
|
170
|
+
): Promise<string> {
|
|
151
171
|
const candidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, { ...baseRequest, text: name });
|
|
152
172
|
try {
|
|
153
173
|
return matchArtifactByName(candidates, name);
|
|
154
174
|
} catch (error) {
|
|
155
|
-
if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || baseRequest
|
|
156
|
-
const widenedCandidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, {
|
|
175
|
+
if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || baseRequest.scope !== undefined) throw error;
|
|
176
|
+
const widenedCandidates = await callService<Record<string, unknown>, Artifact[]>(listOperation, {
|
|
177
|
+
...widenedRequest(listOperation, baseRequest),
|
|
178
|
+
text: name,
|
|
179
|
+
});
|
|
157
180
|
const id = matchArtifactByName(widenedCandidates, name);
|
|
158
181
|
notes?.push(`"${name}" was not found in the current project scope; resolved across all projects instead.`);
|
|
159
182
|
return id;
|
|
@@ -201,13 +224,13 @@ async function resolveNameArrayField(
|
|
|
201
224
|
* over the same two operations rather than reinventing trash semantics four times.
|
|
202
225
|
* Returns null when action is neither, so callers fall through to their own dispatch.
|
|
203
226
|
*/
|
|
204
|
-
async function
|
|
227
|
+
async function _handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
|
|
205
228
|
// Trashed/restored artifacts stay directly showable, so known identities render by title on
|
|
206
229
|
// either side of the action. An unresolved explicit id stays in structured/error channels;
|
|
207
230
|
// normal model text does not turn that backend key into the artifact's public name.
|
|
208
231
|
const titleOf = async (): Promise<string> => {
|
|
209
232
|
try {
|
|
210
|
-
const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params
|
|
233
|
+
const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params.id });
|
|
211
234
|
return artifact ? `"${artifact.title}"` : "unknown artifact";
|
|
212
235
|
} catch {
|
|
213
236
|
return "unknown artifact";
|
|
@@ -215,7 +238,10 @@ async function handleArtifactRemoveRestore(action: unknown, params: Record<strin
|
|
|
215
238
|
};
|
|
216
239
|
if (action === "remove") {
|
|
217
240
|
const label = await titleOf();
|
|
218
|
-
const record = await callService<
|
|
241
|
+
const record = await callService<
|
|
242
|
+
Record<string, unknown>,
|
|
243
|
+
{ artifactId: string; trashedAt: string; purgeAfter: string; reason?: string }
|
|
244
|
+
>("artifact.remove", params);
|
|
219
245
|
const message = `Trashed ${label}, eligible for purge at ${record.purgeAfter}.`;
|
|
220
246
|
return text(message, createPreviewDetails("artifact.remove", "Trashed", record.artifactId));
|
|
221
247
|
}
|
|
@@ -242,7 +268,8 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
|
242
268
|
pi.registerTool({
|
|
243
269
|
name: "discuss",
|
|
244
270
|
label: "Discuss",
|
|
245
|
-
description:
|
|
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.",
|
|
246
273
|
parameters: Type.Object({
|
|
247
274
|
action: Type.String(),
|
|
248
275
|
id: Type.Optional(Type.String()),
|
|
@@ -261,7 +288,9 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
|
261
288
|
state: Type.Optional(Type.String()),
|
|
262
289
|
after_round: Type.Optional(Type.Number()),
|
|
263
290
|
limit: Type.Optional(Type.Number()),
|
|
264
|
-
options: Type.Optional(
|
|
291
|
+
options: Type.Optional(
|
|
292
|
+
Type.Array(Type.Union([Type.String(), Type.Object({ title: Type.String(), description: Type.Optional(Type.String()) })])),
|
|
293
|
+
),
|
|
265
294
|
options_mode: Type.Optional(Type.String()),
|
|
266
295
|
selected: Type.Optional(Type.Array(Type.String())),
|
|
267
296
|
live: Type.Optional(Type.Boolean()),
|
|
@@ -270,8 +299,12 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
|
270
299
|
// back, same reasoning as pi-ask-user's own tool: the model must not batch a live ask with
|
|
271
300
|
// bash/edit/write and let those run before the human sees the prompt.
|
|
272
301
|
executionMode: "sequential",
|
|
273
|
-
renderCall(args, theme) {
|
|
274
|
-
|
|
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
|
+
},
|
|
275
308
|
async execute(_id, rawParams, signal, onUpdate, ctx) {
|
|
276
309
|
try {
|
|
277
310
|
const params: Record<string, unknown> = { ...rawParams };
|
|
@@ -290,14 +323,22 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
|
290
323
|
if (typeof params.actor !== "string" || params.actor.length === 0) params.actor = "agent";
|
|
291
324
|
const operation = action === "open" ? "discuss.open" : "discuss.reply";
|
|
292
325
|
const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
|
|
293
|
-
const fallback =
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
);
|
|
296
333
|
if (params.live !== true) return fallback;
|
|
297
334
|
const answer = await liveAnswer(ctx, result.discussion, result.rounds[0]?.content, onUpdate, signal);
|
|
298
335
|
if (!answer) return fallback;
|
|
299
336
|
const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
|
|
300
|
-
id: result.discussion.id,
|
|
337
|
+
id: result.discussion.id,
|
|
338
|
+
actor: "human",
|
|
339
|
+
content: answer.content,
|
|
340
|
+
...(answer.selected ? { selected: answer.selected } : {}),
|
|
341
|
+
source: "discuss-live",
|
|
301
342
|
});
|
|
302
343
|
return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
|
|
303
344
|
}
|
|
@@ -309,9 +350,10 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
|
309
350
|
callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.task_id }),
|
|
310
351
|
]);
|
|
311
352
|
const discussion = discussionAndRounds.discussion;
|
|
312
|
-
const message =
|
|
313
|
-
|
|
314
|
-
|
|
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}"`;
|
|
315
357
|
return text(message, createPreviewDetails(operation, action === "block" ? "Blocked" : "Unblocked", message));
|
|
316
358
|
}
|
|
317
359
|
if (action === "show") {
|
|
@@ -326,7 +368,10 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
|
|
|
326
368
|
}
|
|
327
369
|
if (action === "list") {
|
|
328
370
|
const rows = await callService<Record<string, unknown>, Artifact[]>("discuss.list", params);
|
|
329
|
-
return text(
|
|
371
|
+
return text(
|
|
372
|
+
rows.length ? artifactLines(rows).join("\n") : "No discussions found.",
|
|
373
|
+
createArtifactListDetails("discuss.list", rows),
|
|
374
|
+
);
|
|
330
375
|
}
|
|
331
376
|
const operations = { defer: "discuss.defer", resume: "discuss.resume", settle: "discuss.settle" } as const;
|
|
332
377
|
const operation = operations[action as keyof typeof operations];
|