@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
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
3
|
-
|
|
4
|
-
export type ActiveTheme = () => Theme;
|
|
5
|
-
export type ActiveMarkdownTheme = () => Pick<MarkdownTheme, "highlightCode">;
|
|
6
|
-
|
|
7
|
-
function activePiMarkdownTheme(): Pick<MarkdownTheme, "highlightCode"> {
|
|
8
|
-
try {
|
|
9
|
-
return getMarkdownTheme();
|
|
10
|
-
} catch {
|
|
11
|
-
return {};
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function createPapyrusMarkdownTheme(
|
|
16
|
-
activeTheme: ActiveTheme,
|
|
17
|
-
activeMarkdownTheme: ActiveMarkdownTheme = activePiMarkdownTheme,
|
|
18
|
-
): MarkdownTheme {
|
|
19
|
-
return {
|
|
20
|
-
heading: (text) => activeTheme().fg("mdHeading", text),
|
|
21
|
-
link: (text) => activeTheme().fg("mdLink", text),
|
|
22
|
-
linkUrl: (text) => activeTheme().fg("mdLinkUrl", text),
|
|
23
|
-
code: (text) => activeTheme().fg("mdCode", text),
|
|
24
|
-
codeBlock: (text) => activeTheme().fg("mdCodeBlock", text),
|
|
25
|
-
codeBlockBorder: (text) => activeTheme().fg("mdCodeBlockBorder", text),
|
|
26
|
-
quote: (text) => activeTheme().fg("mdQuote", text),
|
|
27
|
-
quoteBorder: (text) => activeTheme().fg("mdQuoteBorder", text),
|
|
28
|
-
hr: (text) => activeTheme().fg("mdHr", text),
|
|
29
|
-
listBullet: (text) => activeTheme().fg("mdListBullet", text),
|
|
30
|
-
bold: (text) => activeTheme().bold(text),
|
|
31
|
-
italic: (text) => activeTheme().italic(text),
|
|
32
|
-
strikethrough: (text) => activeTheme().strikethrough(text),
|
|
33
|
-
underline: (text) => activeTheme().underline(text),
|
|
34
|
-
highlightCode: (code, language) => {
|
|
35
|
-
try {
|
|
36
|
-
const highlighted = activeMarkdownTheme().highlightCode?.(code, language);
|
|
37
|
-
if (highlighted) return highlighted;
|
|
38
|
-
} catch {
|
|
39
|
-
// The host theme may be unavailable in isolated rendering tests.
|
|
40
|
-
}
|
|
41
|
-
return code.split("\n").map((line) => activeTheme().fg("mdCodeBlock", line));
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function renderMarkdownBody(
|
|
47
|
-
body: string,
|
|
48
|
-
width: number,
|
|
49
|
-
activeTheme: ActiveTheme,
|
|
50
|
-
activeMarkdownTheme: ActiveMarkdownTheme = activePiMarkdownTheme,
|
|
51
|
-
): string[] {
|
|
52
|
-
const markdown = new Markdown(
|
|
53
|
-
body || "(no body)",
|
|
54
|
-
0,
|
|
55
|
-
0,
|
|
56
|
-
createPapyrusMarkdownTheme(activeTheme, activeMarkdownTheme),
|
|
57
|
-
{ color: (text) => activeTheme().fg("text", text) },
|
|
58
|
-
);
|
|
59
|
-
return markdown.render(Math.max(1, width));
|
|
60
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
|
|
4
|
-
/** Hidden at 0, matching TaskOverlay's own "nothing open" hiding rule. */
|
|
5
|
-
export function renderNoteWidgetLines(theme: Theme, openCount: number, width: number): string[] {
|
|
6
|
-
if (openCount === 0) return [];
|
|
7
|
-
return [truncateToWidth(`${theme.fg("muted", "Notes")} ${theme.fg("accent", String(openCount))}`, width, "…")];
|
|
8
|
-
}
|
package/extension/src/notes.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { NOTE_LIST_MAX_LIMIT } from "../../src/constants.ts";
|
|
3
|
-
import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
|
|
4
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
5
|
-
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
6
|
-
import { NOTE_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
7
|
-
import { callService } from "./service-client.ts";
|
|
8
|
-
|
|
9
|
-
export function noteRowMeta(note: Artifact): string {
|
|
10
|
-
const history = Array.isArray(note.extra["noteHistory"]) ? note.extra["noteHistory"].length : 0;
|
|
11
|
-
return `${history} event${history === 1 ? "" : "s"}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function noteCaptureInput(request: string, projectRoot: string): Record<string, unknown> | null {
|
|
15
|
-
const body = request.trim();
|
|
16
|
-
if (!body) return null;
|
|
17
|
-
return { body, project_root: projectRoot, actor: "human", source: "note-command" };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The generic artifact browser (extension/src/artifact-browser.ts) requests a fixed 500-row
|
|
22
|
-
* page by default, but notes.list enforces its own tighter NOTE_LIST_MAX_LIMIT (200) — an
|
|
23
|
-
* unqualified /notes call exceeded that bound and the browser surfaced the daemon's rejection
|
|
24
|
-
* as an opaque extension error instead of ever rendering. Passing an explicit limit here that
|
|
25
|
-
* respects the Notes-specific bound is the fix; the generic browser's default stays as-is
|
|
26
|
-
* since no other kind's list operation has a bound below 500.
|
|
27
|
-
*/
|
|
28
|
-
export function noteListInput(projectRoot: string): Record<string, unknown> {
|
|
29
|
-
return { project_root: projectRoot, limit: NOTE_LIST_MAX_LIMIT };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function captureNote(request: string, ctx: ExtensionCommandContext): Promise<Artifact | null> {
|
|
33
|
-
const input = noteCaptureInput(request, ctx.cwd);
|
|
34
|
-
if (!input) {
|
|
35
|
-
ctx.ui.notify("Usage: /note <request for later>", "warning");
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
const note = await callService<Record<string, unknown>, Artifact>("notes.capture", input);
|
|
40
|
-
ctx.ui.notify(`Captured note: ${note.title}`, "info");
|
|
41
|
-
return note;
|
|
42
|
-
} catch (error) {
|
|
43
|
-
ctx.ui.notify(`Note capture failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export async function showNotes(ctx: ExtensionCommandContext): Promise<void> {
|
|
49
|
-
await showArtifactBrowser(ctx, {
|
|
50
|
-
kind: "note",
|
|
51
|
-
title: "Notes inbox",
|
|
52
|
-
listOperation: "notes.list",
|
|
53
|
-
listInput: noteListInput(ctx.cwd),
|
|
54
|
-
statusOrder: ["draft", "active", "archived"],
|
|
55
|
-
presentation: NOTE_STATUS_PRESENTATION,
|
|
56
|
-
rowMeta: noteRowMeta,
|
|
57
|
-
actions: (note) => [
|
|
58
|
-
"Show details",
|
|
59
|
-
...(note.status === "draft" ? ["Consume"] : []),
|
|
60
|
-
"Promote",
|
|
61
|
-
"Archive",
|
|
62
|
-
],
|
|
63
|
-
handleAction: async (choice, note, commandCtx) => {
|
|
64
|
-
if (choice === "Show details") {
|
|
65
|
-
await showArtifactDetails(commandCtx, note.id, "notes.show", { project_root: commandCtx.cwd });
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (choice === "Consume") {
|
|
69
|
-
await callService("notes.consume", { id: note.id, project_root: commandCtx.cwd, actor: "human", source: "notes-tui" });
|
|
70
|
-
commandCtx.ui.notify(`Consumed ${note.title}`, "info");
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (choice === "Promote") {
|
|
74
|
-
const targetId = await commandCtx.ui.input("Resulting artifact id:", "");
|
|
75
|
-
if (!targetId) return;
|
|
76
|
-
const reason = await commandCtx.ui.input("Disposition note (optional):", "");
|
|
77
|
-
await callService("notes.promote", {
|
|
78
|
-
id: note.id,
|
|
79
|
-
target_id: targetId,
|
|
80
|
-
project_root: commandCtx.cwd,
|
|
81
|
-
actor: "human",
|
|
82
|
-
source: "notes-tui",
|
|
83
|
-
...(reason ? { reason } : {}),
|
|
84
|
-
});
|
|
85
|
-
commandCtx.ui.notify(`Promoted ${note.title} → ${targetId}`, "info");
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const disposition = await commandCtx.ui.select("Archive disposition", [...NOTE_DISPOSITIONS]);
|
|
89
|
-
if (!disposition) return;
|
|
90
|
-
const reason = await commandCtx.ui.input("Reason (optional):", "");
|
|
91
|
-
await callService("notes.archive", {
|
|
92
|
-
id: note.id,
|
|
93
|
-
disposition,
|
|
94
|
-
project_root: commandCtx.cwd,
|
|
95
|
-
actor: "human",
|
|
96
|
-
source: "notes-tui",
|
|
97
|
-
...(reason ? { reason } : {}),
|
|
98
|
-
});
|
|
99
|
-
commandCtx.ui.notify(`Archived ${note.title} · ${disposition}`, "info");
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* playbook-bridge.ts — materializes active Papyrus Playbooks as their own /playbook:name slash
|
|
3
|
-
* commands, one per playbook, the same one-entry-per-item autocomplete experience Pi's own
|
|
4
|
-
* /skill:name gives real skills.
|
|
5
|
-
*
|
|
6
|
-
* /skill:name itself is a hardcoded core mechanism (Pi's interactive mode builds it directly
|
|
7
|
-
* from its own skill loader) -- not something an extension can retarget to a different prefix.
|
|
8
|
-
* pi.registerCommand(name, ...) accepts any string, colons included, so "playbook:<slug>"
|
|
9
|
-
* registers and invokes as literally /playbook:<slug> -- a real, supported extension API, no
|
|
10
|
-
* core touched.
|
|
11
|
-
*
|
|
12
|
-
* Real limitation: ExtensionAPI has no unregisterCommand. A disabled or renamed playbook's old
|
|
13
|
-
* /playbook:<slug> command lingers until a full Pi restart -- registerCommand can only add or
|
|
14
|
-
* overwrite, never remove. Mitigated two ways: registrations refresh on every resources_discover
|
|
15
|
-
* (session start and /reload), so a renamed playbook's NEW slug appears promptly even though the
|
|
16
|
-
* old one lingers; and each command's handler re-fetches the live playbook by id at invocation
|
|
17
|
-
* time rather than baking in stale content, so even a lingering stale name fails cleanly with a
|
|
18
|
-
* real error instead of running deleted content.
|
|
19
|
-
*/
|
|
20
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
22
|
-
import { callService } from "./service-client.ts";
|
|
23
|
-
|
|
24
|
-
export const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
|
|
25
|
-
|
|
26
|
-
function slugify(title: string): string {
|
|
27
|
-
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
28
|
-
return slug.length > 0 ? slug : "playbook";
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function activePlaybooks(): Promise<Artifact[]> {
|
|
32
|
-
return callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Exported for direct testing without a real ExtensionAPI. */
|
|
36
|
-
export function playbookCommandName(title: string): string {
|
|
37
|
-
return `playbook:${slugify(title)}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* One line per active Playbook for context injection -- the passive, every-turn surfacing that
|
|
42
|
-
* gives the model the same "this exists and might match my task" awareness Pi's own Skill catalog
|
|
43
|
-
* gives real Skills, without a file-based bridge. See buildContextInjection (rules and open tasks
|
|
44
|
-
* already work this way).
|
|
45
|
-
*/
|
|
46
|
-
export function playbookInjectionPreview(playbook: Pick<Artifact, "title" | "extra">): string {
|
|
47
|
-
const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
|
|
48
|
-
return `• ${playbook.title} (when: ${trigger})`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Exported for direct testing without a real ExtensionAPI: what would be registered right now. */
|
|
52
|
-
export async function planPlaybookCommandRegistrations(): Promise<Array<{ name: string; id: string; title: string; trigger: string }>> {
|
|
53
|
-
const playbooks = await activePlaybooks();
|
|
54
|
-
const usedNames = new Set<string>();
|
|
55
|
-
return playbooks.map((playbook) => {
|
|
56
|
-
let name = playbookCommandName(playbook.title);
|
|
57
|
-
if (usedNames.has(name)) name = `${name}-${playbook.id.slice(0, 8)}`; // a real title collision, not the common case
|
|
58
|
-
usedNames.add(name);
|
|
59
|
-
const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
|
|
60
|
-
return { name, id: playbook.id, title: playbook.title, trigger };
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function registerPlaybookBridge(pi: ExtensionAPI): void {
|
|
65
|
-
const refresh = async () => {
|
|
66
|
-
try {
|
|
67
|
-
const registrations = await planPlaybookCommandRegistrations();
|
|
68
|
-
for (const { name, id, title, trigger } of registrations) {
|
|
69
|
-
pi.registerCommand(name, {
|
|
70
|
-
description: trigger,
|
|
71
|
-
handler: async (_args, ctx) => {
|
|
72
|
-
try {
|
|
73
|
-
// Re-fetched live, not captured at registration time: a lingering stale
|
|
74
|
-
// command (renamed or disabled since, since registerCommand can't be
|
|
75
|
-
// unregistered) must fail cleanly, never run deleted/stale content.
|
|
76
|
-
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id });
|
|
77
|
-
ctx.ui.setEditorText(invocation);
|
|
78
|
-
ctx.ui.notify(`"${title}" invocation placed in the editor`, "info");
|
|
79
|
-
} catch (error) {
|
|
80
|
-
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
} catch {
|
|
86
|
-
// A Papyrus daemon hiccup must never break Pi's own resource discovery -- degrades to
|
|
87
|
-
// "no new/updated playbook commands this cycle", not a broken session start.
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
pi.on("resources_discover", async () => { await refresh(); return {}; });
|
|
91
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
-
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
|
-
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
5
|
-
import { PLAYBOOK_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
6
|
-
import { matchArtifactByName } from "./domain-tools.ts";
|
|
7
|
-
import { callService } from "./service-client.ts";
|
|
8
|
-
|
|
9
|
-
const PLAYBOOK_COMPLETION_MAX_CANDIDATES = 100;
|
|
10
|
-
|
|
11
|
-
async function activePlaybooks(): Promise<Artifact[]> {
|
|
12
|
-
return callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_COMPLETION_MAX_CANDIDATES });
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** `/playbook <tab>` completions -- title-prefix match, since that's what a human actually types, not a full-text search of body content. */
|
|
16
|
-
export async function playbookArgumentCompletions(argumentPrefix: string): Promise<AutocompleteItem[] | null> {
|
|
17
|
-
try {
|
|
18
|
-
const needle = argumentPrefix.trim().toLowerCase();
|
|
19
|
-
const rows = await activePlaybooks();
|
|
20
|
-
return rows
|
|
21
|
-
.filter((row) => row.title.toLowerCase().startsWith(needle))
|
|
22
|
-
.sort((a, b) => a.title.localeCompare(b.title))
|
|
23
|
-
.map((row) => ({ value: row.title, label: row.title, description: typeof row.extra["trigger"] === "string" ? row.extra["trigger"] : undefined }));
|
|
24
|
-
} catch {
|
|
25
|
-
return null; // a Papyrus daemon hiccup degrades to "no suggestions", never breaks the command line
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** `/playbook <name>` (no args opens the full browser instead): resolves by exact title, then places its invocation directly in the editor -- one step, not browse-then-select-then-invoke. */
|
|
30
|
-
export async function openPlaybookByName(name: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
31
|
-
if (!name.trim()) { await showPlaybooks(ctx); return; }
|
|
32
|
-
try {
|
|
33
|
-
const id = matchArtifactByName(await activePlaybooks(), name);
|
|
34
|
-
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id });
|
|
35
|
-
ctx.ui.setEditorText(invocation);
|
|
36
|
-
ctx.ui.notify(`"${name.trim()}" invocation placed in the editor`, "info");
|
|
37
|
-
} catch (error) {
|
|
38
|
-
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const PLAYBOOK_RELATIONS = ["references", "documents", "relates_to", "contains", "part_of"];
|
|
43
|
-
|
|
44
|
-
function strings(value: unknown): string[] {
|
|
45
|
-
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function playbookRowMeta(playbook: Artifact): string {
|
|
49
|
-
const trigger = typeof playbook.extra["trigger"] === "string" ? `when ${playbook.extra["trigger"]}` : "manual invocation";
|
|
50
|
-
const tools = strings(playbook.extra["tools"]);
|
|
51
|
-
return [trigger, tools.join(", ")].filter(Boolean).join(" \u00b7 ");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function showPlaybooks(ctx: ExtensionCommandContext): Promise<void> {
|
|
55
|
-
await showArtifactBrowser(ctx, {
|
|
56
|
-
kind: "playbook",
|
|
57
|
-
title: "Playbooks",
|
|
58
|
-
listOperation: "playbooks.list",
|
|
59
|
-
statusOrder: ["active", "deprecated"],
|
|
60
|
-
presentation: PLAYBOOK_STATUS_PRESENTATION,
|
|
61
|
-
rowMeta: playbookRowMeta,
|
|
62
|
-
actions: (playbook) => ["Show details", "Edit", "Invoke", "Link artifact", playbook.status === "active" ? "Disable" : "Enable"],
|
|
63
|
-
handleAction: async (choice, playbook, commandCtx) => {
|
|
64
|
-
if (choice === "Show details") {
|
|
65
|
-
await showArtifactDetails(commandCtx, playbook.id, "playbooks.show");
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (choice === "Edit") {
|
|
69
|
-
const title = await commandCtx.ui.input("Title:", playbook.title);
|
|
70
|
-
if (title === undefined) return; // canceled
|
|
71
|
-
const body = await commandCtx.ui.input("Body:", playbook.body);
|
|
72
|
-
if (body === undefined) return; // canceled
|
|
73
|
-
const updated = await callService<Record<string, unknown>, Artifact>("playbooks.update", { id: playbook.id, title, body });
|
|
74
|
-
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (choice === "Invoke") {
|
|
78
|
-
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id: playbook.id });
|
|
79
|
-
commandCtx.ui.setEditorText(invocation);
|
|
80
|
-
commandCtx.ui.notify("Invocation placed in the editor", "info");
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (choice === "Link artifact") {
|
|
84
|
-
const targetId = await commandCtx.ui.input("Target artifact id:", "");
|
|
85
|
-
if (!targetId) return;
|
|
86
|
-
const relation = await commandCtx.ui.select("Relation", PLAYBOOK_RELATIONS);
|
|
87
|
-
if (!relation) return;
|
|
88
|
-
await callService("graph.link", { from: playbook.id, relation, to: targetId });
|
|
89
|
-
commandCtx.ui.notify(`Linked "${playbook.title}" via ${relation}`, "info");
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
const operation = choice === "Disable" ? "playbooks.disable" : "playbooks.enable";
|
|
93
|
-
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: playbook.id });
|
|
94
|
-
commandCtx.ui.notify(`${updated.title} \u2192 [${updated.status}]`, "info");
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
}
|
package/extension/src/rules.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
|
-
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
-
import { RULE_STATUS_PRESENTATION, severityColor } from "./artifact-status-presentation.ts";
|
|
5
|
-
import { callService } from "./service-client.ts";
|
|
6
|
-
|
|
7
|
-
export function ruleRowMeta(rule: Artifact, theme: Theme): string {
|
|
8
|
-
const severity = typeof rule.extra["severity"] === "string" ? rule.extra["severity"] : "info";
|
|
9
|
-
const severityText = theme.fg(severityColor(severity), severity.toUpperCase());
|
|
10
|
-
const condition = typeof rule.extra["condition"] === "string" ? `when ${rule.extra["condition"]}` : "always";
|
|
11
|
-
return `${severityText} · ${condition}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function ruleInjectionPreview(rule: Pick<Artifact, "title" | "body" | "extra">): string {
|
|
15
|
-
const condition = typeof rule.extra["condition"] === "string" ? ` (when: ${rule.extra["condition"]})` : "";
|
|
16
|
-
const action = rule.body || (typeof rule.extra["action"] === "string" ? rule.extra["action"] : "");
|
|
17
|
-
return `• ${rule.title}${condition}\n ${action}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
21
|
-
await showArtifactBrowser(ctx, {
|
|
22
|
-
kind: "rule",
|
|
23
|
-
title: "Rules",
|
|
24
|
-
listOperation: "rules.list",
|
|
25
|
-
statusOrder: ["active", "deprecated"],
|
|
26
|
-
presentation: RULE_STATUS_PRESENTATION,
|
|
27
|
-
rowMeta: ruleRowMeta,
|
|
28
|
-
actions: (rule) => ["Show details", "Edit", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
29
|
-
handleAction: async (choice, rule, commandCtx) => {
|
|
30
|
-
if (choice === "Show details") await showArtifactDetails(commandCtx, rule.id, "rules.show");
|
|
31
|
-
else if (choice === "Edit") {
|
|
32
|
-
const title = await commandCtx.ui.input("Title:", rule.title);
|
|
33
|
-
if (title === undefined) return; // canceled
|
|
34
|
-
const body = await commandCtx.ui.input("Body:", rule.body);
|
|
35
|
-
if (body === undefined) return; // canceled
|
|
36
|
-
const updated = await callService<Record<string, unknown>, Artifact>("rules.update", { id: rule.id, title, body });
|
|
37
|
-
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
38
|
-
} else if (choice === "Preview injection") {
|
|
39
|
-
const preview = await callService<Record<string, unknown>, string>("rules.preview", { id: rule.id });
|
|
40
|
-
commandCtx.ui.notify(preview, "info");
|
|
41
|
-
} else if (choice === "Link gated task") {
|
|
42
|
-
const taskId = await commandCtx.ui.input("Task artifact id:", "");
|
|
43
|
-
if (taskId) await callService("rules.gate", { id: rule.id, task_id: taskId });
|
|
44
|
-
} else {
|
|
45
|
-
const operation = choice === "Disable" ? "rules.disable" : "rules.enable";
|
|
46
|
-
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: rule.id });
|
|
47
|
-
commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { createRetryingClient, type RetryingClient } from "@danypops/daemon-kit/pi-client";
|
|
2
|
-
import { connectPapyrusClient, type PapyrusClient } from "../../src/client.ts";
|
|
3
|
-
import type { OperationName } from "../../src/service.ts";
|
|
4
|
-
|
|
5
|
-
type ClientConnector = () => Promise<PapyrusClient>;
|
|
6
|
-
|
|
7
|
-
let connector: ClientConnector = () => connectPapyrusClient();
|
|
8
|
-
const client: RetryingClient<PapyrusClient> = createRetryingClient<PapyrusClient>(() => connector(), { label: "Papyrus" });
|
|
9
|
-
|
|
10
|
-
export async function papyrusClient(): Promise<PapyrusClient> {
|
|
11
|
-
return client.call(async (resolved) => resolved);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function callService<Input extends Record<string, unknown>, Output>(
|
|
15
|
-
operation: OperationName,
|
|
16
|
-
input: Input,
|
|
17
|
-
): Promise<Output> {
|
|
18
|
-
return client.call((resolved) => resolved.call<Input, Output>(operation, input));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function setPapyrusClientConnectorForTests(value: ClientConnector): void {
|
|
22
|
-
connector = value;
|
|
23
|
-
client.reset();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function resetPapyrusClientForTests(): void {
|
|
27
|
-
connector = () => connectPapyrusClient();
|
|
28
|
-
client.reset();
|
|
29
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side cache for the extension's own session_secret, registered with the daemon at
|
|
3
|
-
* session_start and released at session_shutdown (see index.ts). Keyed by sessionId (not a
|
|
4
|
-
* single "current" variable) defensively -- multiple call sites reference an explicit
|
|
5
|
-
* sessionId already, and a Map costs nothing extra for real correctness. See
|
|
6
|
-
* src/domain/session-identity.ts (daemon side) for the full design rationale.
|
|
7
|
-
*/
|
|
8
|
-
const secretsBySessionId = new Map<string, string>();
|
|
9
|
-
|
|
10
|
-
export function cacheSessionSecret(sessionId: string, secret: string): void {
|
|
11
|
-
secretsBySessionId.set(sessionId, secret);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function forgetSessionSecret(sessionId: string): void {
|
|
15
|
-
secretsBySessionId.delete(sessionId);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Spread into any Focus-mutating request body alongside session_id -- empty object when no secret is cached for this sessionId (unregistered, or registration hasn't completed yet), matching the daemon's opt-in-armor default. */
|
|
19
|
-
export function sessionSecretField(sessionId: string | undefined): { session_secret?: string } {
|
|
20
|
-
const secret = sessionId ? secretsBySessionId.get(sessionId) : undefined;
|
|
21
|
-
return secret ? { session_secret: secret } : {};
|
|
22
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Pi-native skills (SKILL.md) carry a real, permanent context tax independent of Papyrus:
|
|
7
|
-
* per Pi's own docs, every discovered skill's name+description is injected into the system
|
|
8
|
-
* prompt unconditionally at startup (the Agent Skills spec's "catalog" tier, ~50-100 tokens
|
|
9
|
-
* per skill). This module measures that tax by replicating Pi's own documented discovery
|
|
10
|
-
* rules (docs/skills.md "Locations" section) directly against the filesystem, rather than
|
|
11
|
-
* trying to parse it back out of the assembled system prompt -- Pi does not document (and
|
|
12
|
-
* this repo must not depend on) the exact wire format it uses to inject the catalog, so
|
|
13
|
-
* re-deriving the same inputs Pi itself reads is the robust approach, not a fragile one.
|
|
14
|
-
* Package-declared skills (pi.skills in package.json / packages' own skills/ directories)
|
|
15
|
-
* are deliberately out of scope: enumerating every installed package for skill declarations
|
|
16
|
-
* is a materially larger, slower scan than reading a handful of known directories, and this
|
|
17
|
-
* tool is a budget estimate, not an exhaustive audit.
|
|
18
|
-
*/
|
|
19
|
-
export interface SkillCatalogEntry {
|
|
20
|
-
name: string;
|
|
21
|
-
description: string;
|
|
22
|
-
location: string;
|
|
23
|
-
characters: number;
|
|
24
|
-
estimatedTokens: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface SkillCatalogFootprint {
|
|
28
|
-
entries: SkillCatalogEntry[];
|
|
29
|
-
totalCharacters: number;
|
|
30
|
-
totalEstimatedTokens: number;
|
|
31
|
-
scannedDirectories: string[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export const SKILL_SCAN_MAX_DEPTH = 6;
|
|
35
|
-
export const SKILL_SCAN_MAX_DIRECTORIES = 2000;
|
|
36
|
-
export const SKILL_SCAN_MAX_SKILLS = 500;
|
|
37
|
-
|
|
38
|
-
function unquote(value: string): string {
|
|
39
|
-
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
40
|
-
return value.slice(1, -1);
|
|
41
|
-
}
|
|
42
|
-
return value;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Extracts `name` and `description` from a SKILL.md's YAML frontmatter, tolerating the
|
|
47
|
-
* folded (`>`) and literal (`|`) block-scalar forms real-world skills commonly use for
|
|
48
|
-
* multi-line descriptions. Deliberately not a general YAML parser -- only the two fields
|
|
49
|
-
* the Agent Skills spec requires are extracted; anything else in the frontmatter is ignored.
|
|
50
|
-
*/
|
|
51
|
-
export function parseSkillFrontmatter(content: string): { name: string; description: string } | null {
|
|
52
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
53
|
-
if (!match) return null;
|
|
54
|
-
const lines = match[1]!.split(/\r?\n/);
|
|
55
|
-
let name = "";
|
|
56
|
-
let description = "";
|
|
57
|
-
for (let index = 0; index < lines.length; index++) {
|
|
58
|
-
const line = lines[index]!;
|
|
59
|
-
const nameMatch = line.match(/^name:\s*(.*)$/);
|
|
60
|
-
if (nameMatch) {
|
|
61
|
-
name = unquote(nameMatch[1]!.trim());
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const descriptionMatch = line.match(/^description:\s*(.*)$/);
|
|
65
|
-
if (!descriptionMatch) continue;
|
|
66
|
-
const rest = descriptionMatch[1]!.trim();
|
|
67
|
-
if (rest === ">" || rest === ">-" || rest === "|" || rest === "|-") {
|
|
68
|
-
const collected: string[] = [];
|
|
69
|
-
let cursor = index + 1;
|
|
70
|
-
while (cursor < lines.length && (lines[cursor] === "" || /^\s+/.test(lines[cursor]!))) {
|
|
71
|
-
collected.push(lines[cursor]!.trim());
|
|
72
|
-
cursor++;
|
|
73
|
-
}
|
|
74
|
-
description = collected.join(rest.startsWith("|") ? "\n" : " ").trim();
|
|
75
|
-
index = cursor - 1;
|
|
76
|
-
} else {
|
|
77
|
-
description = unquote(rest);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (!name || !description) return null;
|
|
81
|
-
return { name, description };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** True at the filesystem root on POSIX (`/`) and Windows (`C:\`, `D:\`, ...). */
|
|
85
|
-
function isFilesystemRoot(path: string): boolean {
|
|
86
|
-
return dirname(path) === path;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Global and project skill directories per Pi's own documented discovery rules, plus any
|
|
91
|
-
* explicit paths configured in settings.json's `skills` array. Project directories are
|
|
92
|
-
* collected walking from `cwd` up to the git repository root (or filesystem root when not
|
|
93
|
-
* in a repo), matching "up to git repo root, or filesystem root when not in a repo" exactly.
|
|
94
|
-
*/
|
|
95
|
-
export function discoverSkillDirectories(homeDirectory: string, cwd: string, settingsSkills: readonly string[] = []): string[] {
|
|
96
|
-
const directories = [join(homeDirectory, ".pi", "agent", "skills"), join(homeDirectory, ".agents", "skills")];
|
|
97
|
-
let current = cwd;
|
|
98
|
-
for (let depth = 0; depth < SKILL_SCAN_MAX_DIRECTORIES; depth++) {
|
|
99
|
-
directories.push(join(current, ".pi", "skills"), join(current, ".agents", "skills"));
|
|
100
|
-
if (existsSync(join(current, ".git")) || isFilesystemRoot(current)) break;
|
|
101
|
-
current = dirname(current);
|
|
102
|
-
}
|
|
103
|
-
directories.push(...settingsSkills);
|
|
104
|
-
return [...new Set(directories)];
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
interface ScanContext {
|
|
108
|
-
entries: SkillCatalogEntry[];
|
|
109
|
-
seenLocations: Set<string>;
|
|
110
|
-
directoriesVisited: number;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Root-level .md files count as individual skills only in these two locations, per Pi's docs. */
|
|
114
|
-
function allowsRootMarkdownFiles(directory: string): boolean {
|
|
115
|
-
return directory.endsWith(join(".pi", "agent", "skills")) || directory.endsWith(join(".pi", "skills"));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function recordSkillFile(context: ScanContext, path: string): void {
|
|
119
|
-
if (context.seenLocations.has(path) || context.entries.length >= SKILL_SCAN_MAX_SKILLS) return;
|
|
120
|
-
let content: string;
|
|
121
|
-
try {
|
|
122
|
-
content = readFileSync(path, "utf8");
|
|
123
|
-
} catch {
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const parsed = parseSkillFrontmatter(content);
|
|
127
|
-
if (!parsed) return;
|
|
128
|
-
context.seenLocations.add(path);
|
|
129
|
-
const characters = parsed.name.length + parsed.description.length;
|
|
130
|
-
context.entries.push({
|
|
131
|
-
name: parsed.name,
|
|
132
|
-
description: parsed.description,
|
|
133
|
-
location: path,
|
|
134
|
-
characters,
|
|
135
|
-
estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function walk(context: ScanContext, directory: string, depth: number, allowRootMarkdown: boolean): void {
|
|
140
|
-
if (depth > SKILL_SCAN_MAX_DEPTH || context.directoriesVisited >= SKILL_SCAN_MAX_DIRECTORIES) return;
|
|
141
|
-
context.directoriesVisited++;
|
|
142
|
-
let names: string[];
|
|
143
|
-
try {
|
|
144
|
-
names = readdirSync(directory);
|
|
145
|
-
} catch {
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
for (const name of names) {
|
|
149
|
-
if (name === "node_modules" || name === ".git") continue;
|
|
150
|
-
const path = join(directory, name);
|
|
151
|
-
let stat: ReturnType<typeof statSync>;
|
|
152
|
-
try {
|
|
153
|
-
stat = statSync(path);
|
|
154
|
-
} catch {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (stat.isDirectory()) {
|
|
158
|
-
const skillFile = join(path, "SKILL.md");
|
|
159
|
-
if (existsSync(skillFile)) recordSkillFile(context, skillFile);
|
|
160
|
-
else walk(context, path, depth + 1, false);
|
|
161
|
-
} else if (allowRootMarkdown && depth === 0 && name.toLowerCase().endsWith(".md")) {
|
|
162
|
-
recordSkillFile(context, path);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Bounded, best-effort scan: a missing or unreadable directory is silently skipped, not an error. */
|
|
168
|
-
export function scanSkillCatalogFootprint(directories: readonly string[]): SkillCatalogFootprint {
|
|
169
|
-
const context: ScanContext = { entries: [], seenLocations: new Set(), directoriesVisited: 0 };
|
|
170
|
-
const scanned: string[] = [];
|
|
171
|
-
for (const directory of directories) {
|
|
172
|
-
if (!existsSync(directory) || !statSync(directory).isDirectory()) continue;
|
|
173
|
-
scanned.push(directory);
|
|
174
|
-
walk(context, directory, 0, allowsRootMarkdownFiles(directory));
|
|
175
|
-
}
|
|
176
|
-
const entries = context.entries.sort((a, b) => b.characters - a.characters);
|
|
177
|
-
return {
|
|
178
|
-
entries,
|
|
179
|
-
totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
|
|
180
|
-
totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
|
|
181
|
-
scannedDirectories: scanned,
|
|
182
|
-
};
|
|
183
|
-
}
|