@danypops/papyrus 0.34.2 → 0.35.0
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 +5 -189
- package/package.json +8 -16
- package/src/artifact-relationship-view.ts +23 -0
- package/src/cli.ts +0 -0
- package/src/index.ts +32 -0
- package/src/task-relationship-view.ts +2 -1
- package/extension/src/active-task-continuation.ts +0 -131
- package/extension/src/artifact-browser.ts +0 -229
- package/extension/src/artifact-detail-format.ts +0 -31
- package/extension/src/artifact-detail-view.ts +0 -112
- package/extension/src/artifact-format.ts +0 -84
- package/extension/src/artifact-status-presentation.ts +0 -71
- package/extension/src/base-prompt-breakdown.ts +0 -55
- package/extension/src/beautiful-mermaid-renderer.ts +0 -68
- package/extension/src/bounded-poll.ts +0 -20
- package/extension/src/context-budget.ts +0 -503
- package/extension/src/context-injection-telemetry.ts +0 -88
- package/extension/src/context-view.ts +0 -222
- package/extension/src/discuss-ask-layout.ts +0 -193
- package/extension/src/discuss-ask-view.ts +0 -1301
- package/extension/src/discuss.ts +0 -134
- package/extension/src/discussion-detail-view.ts +0 -136
- package/extension/src/docs.ts +0 -58
- package/extension/src/domain-tools.ts +0 -886
- package/extension/src/index.ts +0 -776
- package/extension/src/markdown.ts +0 -60
- package/extension/src/note-widget.ts +0 -8
- package/extension/src/notes.ts +0 -102
- package/extension/src/playbook-bridge.ts +0 -91
- package/extension/src/playbooks.ts +0 -97
- package/extension/src/rules.ts +0 -51
- package/extension/src/service-client.ts +0 -29
- package/extension/src/session-identity.ts +0 -22
- package/extension/src/skill-catalog-footprint.ts +0 -183
- package/extension/src/skills.ts +0 -127
- package/extension/src/task-context.ts +0 -1
- package/extension/src/task-detail-format.ts +0 -110
- package/extension/src/task-detail-view.ts +0 -139
- package/extension/src/task-focus-events.ts +0 -57
- package/extension/src/task-graph.ts +0 -116
- package/extension/src/task-presentation.ts +0 -26
- package/extension/src/task-widget.ts +0 -70
- package/extension/src/tasks.ts +0 -418
- package/extension/src/tool-rendering/artifact-card.ts +0 -117
- package/extension/src/tool-rendering/artifact-list.ts +0 -179
- package/extension/src/tool-rendering/index.ts +0 -109
- package/extension/src/tool-rendering/render-model.ts +0 -410
package/extension/src/index.ts
DELETED
|
@@ -1,776 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-papyrus — native Pi extension for the Papyrus graph store.
|
|
3
|
-
*
|
|
4
|
-
* Tools: papyrus_query/graph/show (low-level), plus one native tool per domain (docs/rules/skills/playbooks/tasks/discuss/notes).
|
|
5
|
-
* Command: /tasks (interactive task panel).
|
|
6
|
-
* Widget: persistent task status above editor (rpiv-todo pattern).
|
|
7
|
-
* Injection: active rules + open tasks appended to system prompt every turn.
|
|
8
|
-
* "Are we there yet?" — the agent sees its open work items.
|
|
9
|
-
*/
|
|
10
|
-
import { randomUUID } from "node:crypto";
|
|
11
|
-
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { Type } from "typebox";
|
|
13
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
|
-
import {
|
|
15
|
-
TASK_DRIVER_MAX_TURNS,
|
|
16
|
-
TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
17
|
-
PAPYRUS_CONTEXT_INJECTION_CHANNEL,
|
|
18
|
-
CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
19
|
-
TASK_WIDGET_POLL_INTERVAL_MS,
|
|
20
|
-
NOTE_WIDGET_POLL_INTERVAL_MS,
|
|
21
|
-
NOTE_LIST_MAX_LIMIT,
|
|
22
|
-
} from "../../src/constants.ts";
|
|
23
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
24
|
-
import type { GateResult } from "../../src/domain/gate.ts";
|
|
25
|
-
import { formatMetadata } from "./artifact-format.ts";
|
|
26
|
-
import { callService } from "./service-client.ts";
|
|
27
|
-
import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
|
|
28
|
-
import { BoundedPoll } from "./bounded-poll.ts";
|
|
29
|
-
import { renderNoteWidgetLines } from "./note-widget.ts";
|
|
30
|
-
import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
|
|
31
|
-
import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
|
|
32
|
-
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
33
|
-
import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
34
|
-
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
35
|
-
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
36
|
-
import { buildContextInjection } from "./context-injection-telemetry.ts";
|
|
37
|
-
import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, buildToolDefinitionItems, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type ContextSegmentItem, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
|
|
38
|
-
import { buildBasePromptItems } from "./base-prompt-breakdown.ts";
|
|
39
|
-
import { showContextView } from "./context-view.ts";
|
|
40
|
-
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
41
|
-
import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
|
|
42
|
-
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
43
|
-
import {
|
|
44
|
-
createArtifactDetails,
|
|
45
|
-
createArtifactListDetails,
|
|
46
|
-
createGraphDetails,
|
|
47
|
-
createModelContent,
|
|
48
|
-
createPreviewDetails,
|
|
49
|
-
} from "./tool-rendering/render-model.ts";
|
|
50
|
-
|
|
51
|
-
function text(value: string, details: unknown = {}) {
|
|
52
|
-
const modelContent = createModelContent(value);
|
|
53
|
-
return { content: [{ type: "text" as const, text: modelContent.text }], details };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function artifactTextLabel(artifact: Artifact): string {
|
|
57
|
-
return `[${artifact.kind}|${artifact.status}] ${artifact.title}`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function artifactTextLines(artifacts: readonly Artifact[]): string[] {
|
|
61
|
-
const titleCounts = new Map<string, number>();
|
|
62
|
-
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
63
|
-
return artifacts.map((artifact) => titleCounts.get(artifact.title)! > 1
|
|
64
|
-
? `${artifactTextLabel(artifact)} (${artifact.id})`
|
|
65
|
-
: artifactTextLabel(artifact));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Resolves graph protocol ids into model-facing names; equal titles retain ids only to disambiguate. */
|
|
69
|
-
async function artifactNamesById(ids: readonly string[]): Promise<Map<string, string>> {
|
|
70
|
-
const uniqueIds = [...new Set(ids)];
|
|
71
|
-
const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
|
|
72
|
-
const titleCounts = new Map<string, number>();
|
|
73
|
-
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
74
|
-
return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
// Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
const WIDGET_KEY = "pi-papyrus";
|
|
82
|
-
|
|
83
|
-
export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
|
|
84
|
-
if (projection.openTotal === 0) return [];
|
|
85
|
-
const lines: string[] = [theme.fg("muted", `Tasks · ${projection.scopeLabel}`)];
|
|
86
|
-
for (let index = 0; index < projection.rows.length; index++) {
|
|
87
|
-
const row = projection.rows[index]!;
|
|
88
|
-
const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
|
|
89
|
-
const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
|
|
90
|
-
const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
|
|
91
|
-
const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
|
|
92
|
-
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
|
|
93
|
-
// Task containment is a DAG: a task with more than one parent is only ever shown once in
|
|
94
|
-
// this bounded tree (under whichever parent this walk reached first). Flag it rather than
|
|
95
|
-
// silently hiding that it also lives elsewhere -- see /tasks graph's composition view for
|
|
96
|
-
// the full multi-parent picture.
|
|
97
|
-
const multiParent = row.parentCount > 1 ? theme.fg("dim", ` ⥂${row.parentCount}`) : "";
|
|
98
|
-
lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}${multiParent}`, width, "…"));
|
|
99
|
-
}
|
|
100
|
-
return lines;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export class TaskOverlay {
|
|
104
|
-
private uiCtx: ExtensionUIContext | undefined;
|
|
105
|
-
private registered = false;
|
|
106
|
-
private tui: any | undefined;
|
|
107
|
-
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
108
|
-
private projectRoot: string | undefined;
|
|
109
|
-
private sessionId: string | undefined;
|
|
110
|
-
private readonly poll = new BoundedPoll();
|
|
111
|
-
|
|
112
|
-
setUI(ctx: ExtensionUIContext): void {
|
|
113
|
-
if (ctx !== this.uiCtx) {
|
|
114
|
-
this.uiCtx = ctx;
|
|
115
|
-
this.registered = false;
|
|
116
|
-
this.tui = undefined;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
|
|
121
|
-
// Scopes the widget's "active" glyph to this Pi session's own Focus, so a second
|
|
122
|
-
// concurrent agent's focused task never shows as active in this session's widget.
|
|
123
|
-
setSessionId(sessionId: string): void { this.sessionId = sessionId; }
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Never throws: called from several pi.on(...) handlers, some of which (session_compact,
|
|
127
|
-
* session_tree, tool_execution_end) don't wrap it themselves -- Pi's event emitter does not
|
|
128
|
-
* guarantee catching a handler's rejection, so an unguarded throw here would become an
|
|
129
|
-
* unhandled rejection at the call site instead of a stability issue contained to this widget.
|
|
130
|
-
*/
|
|
131
|
-
async refresh(): Promise<void> {
|
|
132
|
-
if (!this.projectRoot) return;
|
|
133
|
-
try {
|
|
134
|
-
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot, session_id: this.sessionId });
|
|
135
|
-
} catch {
|
|
136
|
-
this.snapshot = { nodes: [], rootIds: [] };
|
|
137
|
-
}
|
|
138
|
-
try {
|
|
139
|
-
this.render();
|
|
140
|
-
} catch {
|
|
141
|
-
// A rendering bug must not crash the extension host over a best-effort status widget.
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
private render(): void {
|
|
146
|
-
if (!this.uiCtx) return;
|
|
147
|
-
|
|
148
|
-
// Hide widget when no tasks
|
|
149
|
-
if (this.snapshot.nodes.length === 0) {
|
|
150
|
-
if (this.registered) {
|
|
151
|
-
this.uiCtx.setWidget(WIDGET_KEY, undefined);
|
|
152
|
-
this.registered = false;
|
|
153
|
-
this.tui = undefined;
|
|
154
|
-
}
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (!this.registered) {
|
|
159
|
-
this.uiCtx.setWidget(
|
|
160
|
-
WIDGET_KEY,
|
|
161
|
-
(tui: any, theme: Theme) => {
|
|
162
|
-
this.tui = tui;
|
|
163
|
-
return {
|
|
164
|
-
render: (width: number) => this.renderLines(theme, width),
|
|
165
|
-
invalidate: () => {
|
|
166
|
-
// Theme changed — force re-registration
|
|
167
|
-
this.registered = false;
|
|
168
|
-
this.tui = undefined;
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
},
|
|
172
|
-
{ placement: "aboveEditor" },
|
|
173
|
-
);
|
|
174
|
-
this.registered = true;
|
|
175
|
-
} else {
|
|
176
|
-
this.tui?.requestRender?.();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
private renderLines(theme: Theme, width: number): string[] {
|
|
181
|
-
return renderTaskWidgetLines(theme, buildTaskWidgetProjection(this.snapshot), width);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Fallback for a Task mutation no event announces -- the CLI run directly from a shell, or
|
|
186
|
-
* a second concurrent Pi session against the same daemon.
|
|
187
|
-
*/
|
|
188
|
-
startPolling(intervalMs: number = TASK_WIDGET_POLL_INTERVAL_MS): void {
|
|
189
|
-
this.poll.start(intervalMs, () => { void this.refresh(); });
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
stopPolling(): void {
|
|
193
|
-
this.poll.stop();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
dispose(): void {
|
|
197
|
-
this.stopPolling();
|
|
198
|
-
this.uiCtx?.setWidget(WIDGET_KEY, undefined);
|
|
199
|
-
this.registered = false;
|
|
200
|
-
this.tui = undefined;
|
|
201
|
-
this.uiCtx = undefined;
|
|
202
|
-
this.projectRoot = undefined;
|
|
203
|
-
this.sessionId = undefined;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
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
|
-
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
// Entry point
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
|
|
303
|
-
export default async function (pi: ExtensionAPI) {
|
|
304
|
-
setTaskFocusEventBus(pi);
|
|
305
|
-
registerDomainTools(pi);
|
|
306
|
-
registerPlaybookBridge(pi);
|
|
307
|
-
let contextInjectionSequence = 0;
|
|
308
|
-
const contextInjectionProducerId = randomUUID();
|
|
309
|
-
let previousContextInjectionFingerprint: string | undefined;
|
|
310
|
-
let logTurnSequence = 0;
|
|
311
|
-
// Cached from the most recent before_agent_start observation: Pi's own base system prompt
|
|
312
|
-
// is only ever visible transiently inside that hook's event.systemPrompt, so /context
|
|
313
|
-
// reuses the size buildContextInjection already computes every turn rather than going
|
|
314
|
-
// without it entirely. basePromptItems is the structural sub-breakdown built from the same
|
|
315
|
-
// event's systemPromptOptions field ("Extensions can inspect this to understand what Pi
|
|
316
|
-
// loaded without re-discovering resources", per Pi's own doc comment) -- no new hook, no new
|
|
317
|
-
// risk, just reading a field before_agent_start already hands over.
|
|
318
|
-
let lastObservedBasePromptTokens: number | null = null;
|
|
319
|
-
let lastObservedBasePromptItems: ContextSegmentItem[] = [];
|
|
320
|
-
const taskContinuation = new ActiveTaskContinuation({
|
|
321
|
-
maxTurns: TASK_DRIVER_MAX_TURNS,
|
|
322
|
-
maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
326
|
-
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
327
|
-
// ctx.isIdle() means "not streaming a model response" -- it reads true while a live discuss
|
|
328
|
-
// ask is still genuinely pending, blocked on the human. Queuing a "continue the active task"
|
|
329
|
-
// nudge here would start a second, concurrent turn reasoning about the very Discussion this
|
|
330
|
-
// live ask is already resolving. See discuss-ask-view.ts's isLiveAskPending() doc comment.
|
|
331
|
-
if (isLiveAskPending()) return;
|
|
332
|
-
try {
|
|
333
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
334
|
-
const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd, session_id: sessionId });
|
|
335
|
-
const decision = taskContinuation.evaluate(active, {
|
|
336
|
-
idle: ctx.isIdle(),
|
|
337
|
-
pendingMessages: ctx.hasPendingMessages(),
|
|
338
|
-
});
|
|
339
|
-
if (decision.action === "continue" && decision.prompt) {
|
|
340
|
-
pi.sendMessage({
|
|
341
|
-
customType: "papyrus-task-continuation",
|
|
342
|
-
content: decision.prompt,
|
|
343
|
-
display: false,
|
|
344
|
-
}, { triggerTurn: true, deliverAs: "nextTurn" });
|
|
345
|
-
} else if (decision.action === "pause") {
|
|
346
|
-
const paused = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>("tasks.pause", {
|
|
347
|
-
actor: "system",
|
|
348
|
-
source: "task-continuation",
|
|
349
|
-
reason: automaticPauseReason(decision.reason),
|
|
350
|
-
session_id: sessionId,
|
|
351
|
-
...sessionSecretField(sessionId),
|
|
352
|
-
});
|
|
353
|
-
emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
|
|
354
|
-
if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
|
|
355
|
-
}
|
|
356
|
-
} catch {
|
|
357
|
-
// The daemon may be unavailable during startup, reload, or shutdown.
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
// ── Post-mortem AND live: every settled turn, log a real context-usage snapshot ────
|
|
362
|
-
// Deliberately lean (real usage + budget numbers only, no full segment breakdown --
|
|
363
|
-
// that stays /context's job on demand) so this stays cheap enough to run every turn:
|
|
364
|
-
// no extra daemon round-trips beyond the one logs.append call, no session tree walk.
|
|
365
|
-
const PI_SESSION_CONTEXT_LOG_SOURCE = "pi-session-context";
|
|
366
|
-
const logSessionContextSnapshot = async (ctx: ExtensionContext): Promise<void> => {
|
|
367
|
-
try {
|
|
368
|
-
const usage = ctx.getContextUsage?.();
|
|
369
|
-
if (!usage || usage.tokens === null) return; // nothing real to report yet (e.g. before the first assistant turn, or right after compaction)
|
|
370
|
-
const totalTokens = usage.tokens;
|
|
371
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
372
|
-
const effectiveBudget = Math.max(0, usage.contextWindow - DEFAULT_RESERVE_TOKENS);
|
|
373
|
-
const percentOfBudget = effectiveBudget > 0 ? Math.round((totalTokens / effectiveBudget) * 1000) / 10 : null;
|
|
374
|
-
await callService("logs.append", {
|
|
375
|
-
source_id: PI_SESSION_CONTEXT_LOG_SOURCE,
|
|
376
|
-
source_label: "Pi session context usage",
|
|
377
|
-
project_root: ctx.cwd,
|
|
378
|
-
level: "info",
|
|
379
|
-
message: `context usage: ${totalTokens} tok / ${effectiveBudget} tok budget (${percentOfBudget}%)`,
|
|
380
|
-
operation_id: `${sessionId}:${++logTurnSequence}`,
|
|
381
|
-
session_id: sessionId,
|
|
382
|
-
fields: { totalTokens, contextWindow: usage.contextWindow, effectiveBudget, percentOfBudget },
|
|
383
|
-
});
|
|
384
|
-
} catch {
|
|
385
|
-
// The daemon may be unavailable during startup, reload, or shutdown -- a missed
|
|
386
|
-
// snapshot is not worth surfacing to the user, matching every other best-effort
|
|
387
|
-
// per-turn daemon call in this extension.
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
// ── Low-level graph-store tools ────────────────────────────────────
|
|
392
|
-
|
|
393
|
-
pi.registerTool({
|
|
394
|
-
name: "papyrus_query",
|
|
395
|
-
label: "Papyrus Query",
|
|
396
|
-
description: "Query artifacts by kind, status, or full-text search.",
|
|
397
|
-
parameters: Type.Object({
|
|
398
|
-
kind: Type.Optional(Type.String()),
|
|
399
|
-
status: Type.Optional(Type.String()),
|
|
400
|
-
text: Type.Optional(Type.String({ description: "substring across title and body" })),
|
|
401
|
-
limit: Type.Optional(Type.Number()),
|
|
402
|
-
}),
|
|
403
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Query artifacts", args, theme); },
|
|
404
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
405
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
406
|
-
try {
|
|
407
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("artifact.query", { ...params, limit: params.limit ?? 50 });
|
|
408
|
-
if (rows.length === 0) return text("No artifacts found.", createArtifactListDetails("artifact.query", rows));
|
|
409
|
-
const lines = artifactTextLines(rows).map((line, index) => `${index + 1}. ${line}`);
|
|
410
|
-
return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, createArtifactListDetails("artifact.query", rows));
|
|
411
|
-
} catch (e) {
|
|
412
|
-
throw new Error(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
|
|
413
|
-
}
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
pi.registerTool({
|
|
418
|
-
name: "papyrus_graph",
|
|
419
|
-
label: "Papyrus Graph",
|
|
420
|
-
description:
|
|
421
|
-
"Link artifacts with typed edges (any kind → any kind), view subgraph, or read the mutation event log. " +
|
|
422
|
-
"RELATIONS: references, implements, follows, depends_on, documents, blocks, supersedes, relates_to, gates, triggers, contains, part_of. " +
|
|
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), " +
|
|
424
|
-
"tree (id → bounded BFS subgraph), " +
|
|
425
|
-
"history (who did what, when — requires id, actor, or session_id). " +
|
|
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.",
|
|
428
|
-
parameters: Type.Object({
|
|
429
|
-
action: Type.String({ description: "link | unlink | tree | status | history" }),
|
|
430
|
-
from: Type.Optional(Type.String()),
|
|
431
|
-
from_name: Type.Optional(Type.String()),
|
|
432
|
-
relation: Type.Optional(Type.String()),
|
|
433
|
-
to: Type.Optional(Type.String()),
|
|
434
|
-
to_name: Type.Optional(Type.String()),
|
|
435
|
-
id: Type.Optional(Type.String()),
|
|
436
|
-
status: Type.Optional(Type.String()),
|
|
437
|
-
depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
|
|
438
|
-
max_nodes: Type.Optional(Type.Number({ description: "tree node cap; bounded by a hard ceiling" })),
|
|
439
|
-
actor: Type.Optional(Type.String({ description: "history: filter by actor" })),
|
|
440
|
-
session_id: Type.Optional(Type.String({ description: "history: filter by session" })),
|
|
441
|
-
since: Type.Optional(Type.String({ description: "history: RFC3339 lower bound" })),
|
|
442
|
-
limit: Type.Optional(Type.Number({ description: "history: bounded page size" })),
|
|
443
|
-
}),
|
|
444
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Artifact graph", args, theme); },
|
|
445
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
446
|
-
async execute(_id, rawParams, _signal, _onUpdate, _ctx) {
|
|
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
|
-
}
|
|
456
|
-
if (params.action === "link") {
|
|
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"}"`;
|
|
460
|
-
return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
|
|
461
|
-
}
|
|
462
|
-
if (params.action === "unlink") {
|
|
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"}"`;
|
|
466
|
-
const output = result.removed ? `Unlinked ${relationship}` : `No such relationship: ${relationship}`;
|
|
467
|
-
return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
|
|
468
|
-
}
|
|
469
|
-
if (params.action === "tree") {
|
|
470
|
-
const root = params.id ?? params.from;
|
|
471
|
-
if (!root) throw new Error("missing id for tree");
|
|
472
|
-
const a = await callService<Record<string, unknown>, Artifact | null>("graph.tree", {
|
|
473
|
-
id: root,
|
|
474
|
-
depth: params.depth,
|
|
475
|
-
max_nodes: params.max_nodes,
|
|
476
|
-
});
|
|
477
|
-
if (!a) throw new Error(`artifact ${root} not found`);
|
|
478
|
-
const edges = a.edges ?? [];
|
|
479
|
-
if (edges.length === 0) return text(`${a.title} — no edges`, createGraphDetails("graph.tree", [a], []));
|
|
480
|
-
const names = await artifactNamesById(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
481
|
-
return text(
|
|
482
|
-
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((edge) => ` "${names.get(edge.from) ?? "unknown artifact"}" --${edge.relation}--> "${names.get(edge.to) ?? "unknown artifact"}"`).join("\n")}`,
|
|
483
|
-
createGraphDetails("graph.tree", [a], edges),
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
if (params.action === "status") {
|
|
487
|
-
const a = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id: params.id!, status: params.status! });
|
|
488
|
-
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
489
|
-
return text(`Updated "${a.title}" → [${a.status}]`, createArtifactDetails("graph.status", a));
|
|
490
|
-
}
|
|
491
|
-
if (params.action === "history") {
|
|
492
|
-
const page = await callService<Record<string, unknown>, { events: Array<Record<string, unknown>> }>("graph.history", {
|
|
493
|
-
id: params.id, actor: params.actor, session_id: params.session_id, since: params.since, limit: params.limit,
|
|
494
|
-
});
|
|
495
|
-
if (page.events.length === 0) return text("No recorded events.", createPreviewDetails("graph.history", "Mutation event log", "No recorded events."));
|
|
496
|
-
const eventIds = page.events.map((event) => event["artifactId"]).filter((id): id is string => typeof id === "string");
|
|
497
|
-
const names = await artifactNamesById(eventIds);
|
|
498
|
-
const output = page.events.map((event) => `${event["occurredAt"]} "${typeof event["artifactId"] === "string" ? names.get(event["artifactId"]) ?? "unknown artifact" : "unknown artifact"}" ${event["type"]} · ${event["actor"]}/${event["source"]}`).join("\n");
|
|
499
|
-
return text(output, createPreviewDetails("graph.history", "Mutation event log", output));
|
|
500
|
-
}
|
|
501
|
-
throw new Error(`unknown action: ${params.action}; use link, tree, status, or history`);
|
|
502
|
-
} catch (e) {
|
|
503
|
-
throw new Error(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
|
|
504
|
-
}
|
|
505
|
-
},
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
pi.registerTool({
|
|
509
|
-
name: "papyrus_show",
|
|
510
|
-
label: "Papyrus Show",
|
|
511
|
-
description: "Show one artifact with body, edges, and optionally run its gates.",
|
|
512
|
-
parameters: Type.Object({
|
|
513
|
-
id: Type.String(),
|
|
514
|
-
run_gates: Type.Optional(Type.Boolean()),
|
|
515
|
-
depth: Type.Optional(Type.Number({ description: "edge traversal depth" })),
|
|
516
|
-
max_nodes: Type.Optional(Type.Number({ description: "maximum traversed nodes" })),
|
|
517
|
-
}),
|
|
518
|
-
renderCall(args, theme) { return renderPapyrusToolCall("Show artifact", args, theme); },
|
|
519
|
-
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
520
|
-
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
521
|
-
try {
|
|
522
|
-
const a = await callService<Record<string, unknown>, Artifact | null>("artifact.show", {
|
|
523
|
-
id: params.id,
|
|
524
|
-
tree: true,
|
|
525
|
-
depth: params.depth,
|
|
526
|
-
max_nodes: params.max_nodes,
|
|
527
|
-
});
|
|
528
|
-
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
529
|
-
let out = `${artifactTextLabel(a)}\n\n${a.body}`;
|
|
530
|
-
if (Object.keys(a.extra).length > 0) {
|
|
531
|
-
out += `\n\nMetadata:\n${formatMetadata(a.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
532
|
-
}
|
|
533
|
-
if (a.edges?.length) {
|
|
534
|
-
const names = await artifactNamesById(a.edges.flatMap((edge) => [edge.from, edge.to]));
|
|
535
|
-
out += `\n\nEdges:\n${a.edges.map((edge) => ` "${names.get(edge.from) ?? "unknown artifact"}" --${edge.relation}--> "${names.get(edge.to) ?? "unknown artifact"}"`).join("\n")}`;
|
|
536
|
-
}
|
|
537
|
-
if (params.run_gates) {
|
|
538
|
-
const results = await callService<Record<string, unknown>, GateResult[]>("gates.run", { id: params.id });
|
|
539
|
-
out += `\n\nGates:\n${results.map((gate) => ` ${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`;
|
|
540
|
-
}
|
|
541
|
-
return text(out, createArtifactDetails("artifact.show", a));
|
|
542
|
-
} catch (e) {
|
|
543
|
-
throw new Error(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
// ── Interactive artifact browsers ──────────────────────────────────
|
|
549
|
-
|
|
550
|
-
// Lazy imports keep TUI components out of non-interactive startup paths.
|
|
551
|
-
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, playbooksModule, discussModule] = await Promise.all([
|
|
552
|
-
import("./tasks.ts"),
|
|
553
|
-
import("./docs.ts"),
|
|
554
|
-
import("./notes.ts"),
|
|
555
|
-
import("./rules.ts"),
|
|
556
|
-
import("./skills.ts"),
|
|
557
|
-
import("./playbooks.ts"),
|
|
558
|
-
import("./discuss.ts"),
|
|
559
|
-
]);
|
|
560
|
-
let overlay: TaskOverlay | undefined;
|
|
561
|
-
let noteOverlay: NoteOverlay | undefined;
|
|
562
|
-
|
|
563
|
-
pi.registerCommand("tasks", {
|
|
564
|
-
description: "Browse and manage Papyrus tasks (interactive)",
|
|
565
|
-
handler: async (_args, ctx) => {
|
|
566
|
-
overlay?.setProjectRoot(ctx.cwd);
|
|
567
|
-
overlay?.setSessionId(ctx.sessionManager.getSessionId());
|
|
568
|
-
await tasksModule.showTasks(ctx);
|
|
569
|
-
await overlay?.refresh();
|
|
570
|
-
},
|
|
571
|
-
});
|
|
572
|
-
pi.registerCommand("docs", {
|
|
573
|
-
description: "Browse and manage Papyrus documents (interactive)",
|
|
574
|
-
handler: async (_args, ctx) => { await docsModule.showDocs(ctx); },
|
|
575
|
-
});
|
|
576
|
-
pi.registerCommand("note", {
|
|
577
|
-
description: "Capture a deferred request directly in Papyrus",
|
|
578
|
-
handler: async (args, ctx) => {
|
|
579
|
-
await notesModule.captureNote(args, ctx);
|
|
580
|
-
await noteOverlay?.refresh();
|
|
581
|
-
},
|
|
582
|
-
});
|
|
583
|
-
pi.registerCommand("notes", {
|
|
584
|
-
description: "Browse and triage the project Notes inbox",
|
|
585
|
-
handler: async (_args, ctx) => {
|
|
586
|
-
noteOverlay?.setProjectRoot(ctx.cwd);
|
|
587
|
-
await notesModule.showNotes(ctx);
|
|
588
|
-
await noteOverlay?.refresh();
|
|
589
|
-
},
|
|
590
|
-
});
|
|
591
|
-
pi.registerCommand("rules", {
|
|
592
|
-
description: "Browse, preview, and toggle Papyrus rules (interactive)",
|
|
593
|
-
handler: async (_args, ctx) => { await rulesModule.showRules(ctx); },
|
|
594
|
-
});
|
|
595
|
-
pi.registerCommand("skills", {
|
|
596
|
-
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
597
|
-
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
598
|
-
});
|
|
599
|
-
pi.registerCommand("playbooks", {
|
|
600
|
-
description: "Browse, edit, and invoke Papyrus playbooks -- trigger/steps guidance an agent reads and follows (interactive)",
|
|
601
|
-
handler: async (_args, ctx) => { await playbooksModule.showPlaybooks(ctx); },
|
|
602
|
-
});
|
|
603
|
-
pi.registerCommand("playbook", {
|
|
604
|
-
description: "Open one Papyrus playbook directly by name (tab-completes active playbook titles) and place its invocation in the editor; no argument opens the full /playbooks browser instead",
|
|
605
|
-
getArgumentCompletions: (argumentPrefix) => playbooksModule.playbookArgumentCompletions(argumentPrefix),
|
|
606
|
-
handler: async (args, ctx) => { await playbooksModule.openPlaybookByName(args, ctx); },
|
|
607
|
-
});
|
|
608
|
-
pi.registerCommand("discuss", {
|
|
609
|
-
description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
|
|
610
|
-
handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
|
|
611
|
-
});
|
|
612
|
-
pi.registerCommand("context", {
|
|
613
|
-
description: "Structured, per-segment breakdown of the context window: real usage against the model's window, drilling into Papyrus Rules and the Pi-native skill catalog",
|
|
614
|
-
handler: async (_args, ctx) => {
|
|
615
|
-
try {
|
|
616
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
617
|
-
const [rules, taskGraph] = await Promise.all([
|
|
618
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
619
|
-
callService<Record<string, unknown>, TaskGraph>("tasks.graph", { project_root: ctx.cwd, session_id: sessionId }),
|
|
620
|
-
]);
|
|
621
|
-
const { skills } = computeContextBudget(rules, ctx.cwd);
|
|
622
|
-
const ruleBudget = computeRuleBudget(rules);
|
|
623
|
-
const usage = ctx.getContextUsage?.();
|
|
624
|
-
// Real tree (not just the linear current-branch path): surfaces content sitting in an
|
|
625
|
-
// abandoned /tree branch, which cost real tokens to generate but isn't in context now.
|
|
626
|
-
const tree = ctx.sessionManager.getTree() as SessionTreeNodeLike[];
|
|
627
|
-
// buildContextEntries(), NOT getBranch(): getBranch() returns every raw entry on the
|
|
628
|
-
// current path including everything a real compaction has already summarized away.
|
|
629
|
-
// A session with 3 real compactions confirmed this made "active" message-history
|
|
630
|
-
// tokens overcount the real total by over 13x -- getBranch()'s own docstring already
|
|
631
|
-
// says as much ("Use buildSessionContext() to get the resolved messages for the
|
|
632
|
-
// LLM"); buildContextEntries() is the compaction-aware entry list matching what the
|
|
633
|
-
// LLM actually sees (the latest compaction entry itself, plus kept entries from its
|
|
634
|
-
// firstKeptEntryId onward, plus everything after -- older summarized entries omitted).
|
|
635
|
-
const activeEntryIds = new Set((ctx.sessionManager.buildContextEntries() as SessionEntryLike[]).map((entry) => entry.id));
|
|
636
|
-
const branchEntryIds = new Set((ctx.sessionManager.getBranch() as SessionEntryLike[]).map((entry) => entry.id));
|
|
637
|
-
const messageHistory = buildMessageHistoryTree(tree, activeEntryIds, branchEntryIds);
|
|
638
|
-
const activeToolNames = new Set(pi.getActiveTools());
|
|
639
|
-
const toolDefinitionItems = buildToolDefinitionItems(pi.getAllTools().filter((tool) => activeToolNames.has(tool.name)));
|
|
640
|
-
const breakdown = buildContextBreakdown({
|
|
641
|
-
totalTokens: usage?.tokens ?? null,
|
|
642
|
-
contextWindow: ctx.model?.contextWindow ?? null,
|
|
643
|
-
ruleBudget,
|
|
644
|
-
taskItems: buildTaskItemTree(taskGraph),
|
|
645
|
-
skills,
|
|
646
|
-
basePromptEstimatedTokens: lastObservedBasePromptTokens,
|
|
647
|
-
basePromptItems: lastObservedBasePromptItems,
|
|
648
|
-
toolDefinitionItems,
|
|
649
|
-
messageHistoryItems: messageHistory.items,
|
|
650
|
-
messageHistoryActiveTokens: messageHistory.activeTokens,
|
|
651
|
-
});
|
|
652
|
-
await showContextView(ctx, breakdown);
|
|
653
|
-
} catch (error) {
|
|
654
|
-
ctx.ui.notify(`Context breakdown failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
655
|
-
}
|
|
656
|
-
},
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
// ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
|
|
660
|
-
|
|
661
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
662
|
-
// Registers this session's identity with the daemon as early as possible -- before any
|
|
663
|
-
// Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
|
|
664
|
-
// domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
|
|
665
|
-
// unavailable during startup, and every other Focus-mutating call already tolerates an
|
|
666
|
-
// unregistered/never-armored session_id (opt-in armor), so a missed registration here is
|
|
667
|
-
// not worth surfacing to the user.
|
|
668
|
-
try {
|
|
669
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
670
|
-
const { secret } = await callService<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", { session_id: sessionId });
|
|
671
|
-
cacheSessionSecret(sessionId, secret);
|
|
672
|
-
} catch {
|
|
673
|
-
// intentionally silent -- see comment above
|
|
674
|
-
}
|
|
675
|
-
if (!ctx.hasUI) return;
|
|
676
|
-
// Attached from session start, not lazily on first ask -- a per-ask listener would only see
|
|
677
|
-
// keystrokes from the moment that tool call happens to begin, missing typing already in
|
|
678
|
-
// progress when it started (the exact case Discuss's typing-courtesy wait protects against).
|
|
679
|
-
ensureTypingCourtesyTracking(ctx.ui);
|
|
680
|
-
overlay ??= new TaskOverlay();
|
|
681
|
-
overlay.setUI(ctx.ui);
|
|
682
|
-
overlay.setProjectRoot(ctx.cwd);
|
|
683
|
-
overlay.setSessionId(ctx.sessionManager.getSessionId());
|
|
684
|
-
await overlay.refresh();
|
|
685
|
-
overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
|
|
686
|
-
|
|
687
|
-
noteOverlay ??= new NoteOverlay();
|
|
688
|
-
noteOverlay.setUI(ctx.ui);
|
|
689
|
-
noteOverlay.setProjectRoot(ctx.cwd);
|
|
690
|
-
await noteOverlay.refresh();
|
|
691
|
-
noteOverlay.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
|
|
695
|
-
pi.on("session_compact", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
|
|
696
|
-
pi.on("session_tree", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
|
|
697
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
698
|
-
overlay?.dispose();
|
|
699
|
-
overlay = undefined;
|
|
700
|
-
noteOverlay?.dispose();
|
|
701
|
-
noteOverlay = undefined;
|
|
702
|
-
try {
|
|
703
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
704
|
-
await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
705
|
-
forgetSessionSecret(sessionId);
|
|
706
|
-
} catch {
|
|
707
|
-
// intentionally silent -- see session_start's comment above
|
|
708
|
-
}
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
// Update widgets after any papyrus tool call
|
|
712
|
-
pi.on("tool_execution_end", async (event) => {
|
|
713
|
-
if (event.toolName.startsWith("papyrus_") || event.toolName === "tasks") {
|
|
714
|
-
await overlay?.refresh();
|
|
715
|
-
}
|
|
716
|
-
if (event.toolName === "notes") {
|
|
717
|
-
await noteOverlay?.refresh();
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
// ── Keep driving active work after Pi has exhausted built-in continuations ──
|
|
722
|
-
// agent_settled is intentionally later than agent_end: Pi guarantees that
|
|
723
|
-
// retry, compaction retry, and queued follow-up processing have finished.
|
|
724
|
-
|
|
725
|
-
pi.on("input", async (event, ctx) => {
|
|
726
|
-
if (event.source === "extension") return;
|
|
727
|
-
taskContinuation.onHumanInput();
|
|
728
|
-
try {
|
|
729
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
730
|
-
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
|
|
731
|
-
if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
|
|
732
|
-
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
733
|
-
emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
|
|
734
|
-
}
|
|
735
|
-
} catch {
|
|
736
|
-
// The daemon may be unavailable during startup, reload, or shutdown.
|
|
737
|
-
}
|
|
738
|
-
});
|
|
739
|
-
pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
|
|
740
|
-
pi.on("agent_settled", async (_event, ctx) => {
|
|
741
|
-
await driveActiveTasks(ctx);
|
|
742
|
-
await logSessionContextSnapshot(ctx);
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
// ── "Are we there yet?" — inject active tasks into every turn ──────
|
|
746
|
-
// The agent sees its open work items every turn. If there are rejected
|
|
747
|
-
// tasks, they're explicitly called out — the agent should address them.
|
|
748
|
-
|
|
749
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
750
|
-
try {
|
|
751
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
752
|
-
const [rules, playbooks, summary] = await Promise.all([
|
|
753
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
754
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "extra">>>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS }),
|
|
755
|
-
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId, verbosity: "summary" }),
|
|
756
|
-
]);
|
|
757
|
-
const injection = buildContextInjection({
|
|
758
|
-
basePrompt: event.systemPrompt ?? "",
|
|
759
|
-
rules,
|
|
760
|
-
playbooks,
|
|
761
|
-
taskSummary: summary,
|
|
762
|
-
observedAt: Date.now(),
|
|
763
|
-
sequence: ++contextInjectionSequence,
|
|
764
|
-
producerId: contextInjectionProducerId,
|
|
765
|
-
previousFingerprint: previousContextInjectionFingerprint,
|
|
766
|
-
});
|
|
767
|
-
previousContextInjectionFingerprint = injection.observation.fingerprint;
|
|
768
|
-
lastObservedBasePromptTokens = Math.ceil(injection.observation.before.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
|
|
769
|
-
lastObservedBasePromptItems = buildBasePromptItems(event.systemPromptOptions, injection.observation.before.characters);
|
|
770
|
-
pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
|
|
771
|
-
if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
|
|
772
|
-
} catch {
|
|
773
|
-
// DB not ready
|
|
774
|
-
}
|
|
775
|
-
});
|
|
776
|
-
}
|