@danypops/papyrus 0.10.1 → 0.11.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 CHANGED
@@ -116,7 +116,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
116
116
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
117
117
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
118
118
 
119
- All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable text.
119
+ All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
120
120
 
121
121
  ## Notes
122
122
 
@@ -1,16 +1,31 @@
1
1
  import type { Artifact } from "../../src/domain/artifact.ts";
2
2
  import { formatMetadata } from "./artifact-format.ts";
3
3
 
4
+ export interface ArtifactDetailContent {
5
+ title: string;
6
+ identity: string;
7
+ body: string;
8
+ labels: string[];
9
+ metadata: string[];
10
+ relationships: string[];
11
+ }
12
+
13
+ export function artifactDetailContent(artifact: Artifact): ArtifactDetailContent {
14
+ return {
15
+ title: artifact.title,
16
+ identity: `${artifact.id} [${artifact.kind}|${artifact.status}]${artifact.subtype ? ` · ${artifact.subtype}` : ""}`,
17
+ body: artifact.body || "(no body)",
18
+ labels: [...artifact.labels],
19
+ metadata: Object.keys(artifact.extra).length > 0 ? formatMetadata(artifact.extra) : [],
20
+ relationships: (artifact.edges ?? []).map((edge) => `${edge.from} --${edge.relation}--> ${edge.to}`),
21
+ };
22
+ }
23
+
4
24
  export function artifactDetailsText(artifact: Artifact): string {
5
- let output = `${artifact.title}\n${artifact.id} [${artifact.kind}|${artifact.status}]`;
6
- if (artifact.subtype) output += ` · ${artifact.subtype}`;
7
- output += `\n\n${artifact.body || "(no body)"}`;
8
- if (artifact.labels.length > 0) output += `\n\nLabels: ${artifact.labels.join(", ")}`;
9
- if (Object.keys(artifact.extra).length > 0) {
10
- output += `\n\nMetadata:\n${formatMetadata(artifact.extra).map((line) => ` ${line}`).join("\n")}`;
11
- }
12
- if (artifact.edges?.length) {
13
- output += `\n\nRelationships:\n${artifact.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
14
- }
25
+ const content = artifactDetailContent(artifact);
26
+ let output = `${content.title}\n${content.identity}\n\n${content.body}`;
27
+ if (content.labels.length > 0) output += `\n\nLabels: ${content.labels.join(", ")}`;
28
+ if (content.metadata.length > 0) output += `\n\nMetadata:\n${content.metadata.map((line) => ` ${line}`).join("\n")}`;
29
+ if (content.relationships.length > 0) output += `\n\nRelationships:\n${content.relationships.map((line) => ` ${line}`).join("\n")}`;
15
30
  return output;
16
31
  }
@@ -1,4 +1,4 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
3
3
  import {
4
4
  ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS,
@@ -7,7 +7,8 @@ import {
7
7
  ARTIFACT_DETAIL_RESERVED_ROWS,
8
8
  } from "../../src/constants.ts";
9
9
  import type { Artifact } from "../../src/domain/artifact.ts";
10
- import { artifactDetailsText } from "./artifact-detail-format.ts";
10
+ import { artifactDetailContent, artifactDetailsText, type ArtifactDetailContent } from "./artifact-detail-format.ts";
11
+ import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
11
12
 
12
13
  interface ArtifactDetailLine {
13
14
  text: string;
@@ -20,12 +21,11 @@ class ArtifactDetailViewport {
20
21
  private renderedWidth = 0;
21
22
  private lines: ArtifactDetailLine[] = [];
22
23
  private readonly visibleLines: number;
23
- private readonly narrative: string;
24
- private readonly relationships: string[];
24
+ private readonly content: ArtifactDetailContent;
25
25
 
26
26
  constructor(
27
27
  private readonly tui: TUI,
28
- private readonly theme: Theme,
28
+ private readonly activeTheme: ActiveTheme,
29
29
  artifact: Artifact,
30
30
  private readonly close: () => void,
31
31
  ) {
@@ -33,8 +33,7 @@ class ArtifactDetailViewport {
33
33
  ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
34
34
  Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
35
35
  );
36
- this.narrative = artifactDetailsText({ ...artifact, edges: undefined });
37
- this.relationships = (artifact.edges ?? []).map((edge) => `${edge.from} --${edge.relation}--> ${edge.to}`);
36
+ this.content = artifactDetailContent(artifact);
38
37
  }
39
38
 
40
39
  invalidate(): void { this.renderedWidth = 0; }
@@ -42,11 +41,12 @@ class ArtifactDetailViewport {
42
41
  render(width: number): string[] {
43
42
  const contentWidth = Math.max(1, width - 2);
44
43
  this.buildLines(contentWidth);
45
- const wideWidth = this.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
44
+ const wideWidth = this.content.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
46
45
  this.offsetX = Math.min(this.offsetX, Math.max(0, wideWidth - contentWidth));
47
46
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
48
47
  const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
49
- const border = this.theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
48
+ const theme = this.activeTheme();
49
+ const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
50
50
  const footer = [
51
51
  wideWidth > contentWidth ? `←/→ relationships · column ${this.offsetX + 1}/${wideWidth}` : "",
52
52
  this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
@@ -54,12 +54,12 @@ class ArtifactDetailViewport {
54
54
  ].filter(Boolean).join(" · ");
55
55
  return [
56
56
  border,
57
- truncateToWidth(this.theme.bold("Artifact details"), width, ""),
57
+ truncateToWidth(theme.fg("accent", theme.bold("Artifact details")), width, ""),
58
58
  border,
59
59
  ...this.lines.slice(this.offsetY, end).map((line) => line.wide
60
60
  ? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
61
61
  : truncateToWidth(` ${line.text}`, width, "")),
62
- truncateToWidth(this.theme.fg("dim", footer), width, ""),
62
+ truncateToWidth(theme.fg("dim", footer), width, ""),
63
63
  border,
64
64
  ];
65
65
  }
@@ -77,12 +77,29 @@ class ArtifactDetailViewport {
77
77
  private buildLines(width: number): void {
78
78
  if (this.renderedWidth === width) return;
79
79
  this.renderedWidth = width;
80
- const narrative = this.narrative.split("\n").flatMap((line) =>
81
- (line.length === 0 ? [""] : wrapTextWithAnsi(line, width)).map((text) => ({ text, wide: false })));
82
- const relationshipSection = this.relationships.length > 0
83
- ? [{ text: "", wide: false }, { text: "Relationships:", wide: false }, ...this.relationships.map((text) => ({ text, wide: true }))]
80
+ const theme = this.activeTheme();
81
+ const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): ArtifactDetailLine[] =>
82
+ (text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, wide: false }));
83
+ const identity = [
84
+ ...wrap(theme.bold(this.content.title)),
85
+ ...wrap(this.content.identity, "muted"),
86
+ { text: "", wide: false },
87
+ ];
88
+ const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, wide: false }));
89
+ const labels = this.content.labels.length > 0
90
+ ? [{ text: "", wide: false }, ...wrap("Labels:", "muted"), ...wrap(this.content.labels.join(", "))]
91
+ : [];
92
+ const metadata = this.content.metadata.length > 0
93
+ ? [{ text: "", wide: false }, ...wrap("Metadata:", "muted"), ...this.content.metadata.flatMap((line) => wrap(` ${line}`, "dim"))]
94
+ : [];
95
+ const relationships = this.content.relationships.length > 0
96
+ ? [
97
+ { text: "", wide: false },
98
+ ...wrap("Relationships:", "muted"),
99
+ ...this.content.relationships.map((text) => ({ text: theme.fg("text", text), wide: true })),
100
+ ]
84
101
  : [];
85
- this.lines = [...narrative, ...relationshipSection];
102
+ this.lines = [...identity, ...body, ...labels, ...metadata, ...relationships];
86
103
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
87
104
  }
88
105
  }
@@ -91,5 +108,5 @@ export async function showArtifactDetailView(ctx: ExtensionCommandContext, artif
91
108
  const output = artifactDetailsText(artifact);
92
109
  if (ctx.mode !== "tui") { ctx.ui.notify(output, "info"); return; }
93
110
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
94
- new ArtifactDetailViewport(tui, theme, artifact, done));
111
+ new ArtifactDetailViewport(tui, () => ctx.ui.theme ?? theme, artifact, done));
95
112
  }
@@ -0,0 +1,60 @@
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
+ }
@@ -70,19 +70,38 @@ function historyLines(history: TaskEvent[]): string[] {
70
70
  return lines;
71
71
  }
72
72
 
73
- export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
74
- let output = `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}\n${task.id} [task|${task.status}]`;
75
- if (task.labels.length > 0) output += `\nLabels: ${task.labels.join(", ")}`;
76
- output += `\n\n${task.body || "(no body)"}`;
73
+ export interface TaskDetailContent {
74
+ headline: string;
75
+ identity: string;
76
+ labels: string[];
77
+ body: string;
78
+ sections: string[][];
79
+ }
80
+
81
+ export function taskDetailContent(task: Artifact, history: TaskEvent[] = []): TaskDetailContent {
82
+ const sections: string[][] = [];
77
83
  const checklist = checklistLines(task.extra["checklist"]);
78
- if (checklist.length > 0) output += `\n\n${checklist.join("\n")}`;
84
+ if (checklist.length > 0) sections.push(checklist);
79
85
  const gates = gateLines(task.extra["gates"]);
80
- if (gates.length > 0) output += `\n\n${gates.join("\n")}`;
86
+ if (gates.length > 0) sections.push(gates);
81
87
  const metadata = Object.fromEntries(Object.entries(task.extra).filter(([key]) => key !== "checklist" && key !== "gates"));
82
- if (Object.keys(metadata).length > 0) {
83
- output += `\n\nMetadata:\n${formatMetadata(metadata).map((line) => ` ${line}`).join("\n")}`;
84
- }
85
- output += `\n\n${historyLines(history).join("\n")}`;
88
+ if (Object.keys(metadata).length > 0) sections.push(["Metadata:", ...formatMetadata(metadata).map((line) => ` ${line}`)]);
89
+ sections.push(historyLines(history));
90
+ return {
91
+ headline: `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}`,
92
+ identity: `${task.id} [task|${task.status}]`,
93
+ labels: [...task.labels],
94
+ body: task.body || "(no body)",
95
+ sections,
96
+ };
97
+ }
98
+
99
+ export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
100
+ const content = taskDetailContent(task, history);
101
+ let output = `${content.headline}\n${content.identity}`;
102
+ if (content.labels.length > 0) output += `\nLabels: ${content.labels.join(", ")}`;
103
+ output += `\n\n${content.body}`;
104
+ for (const section of content.sections) output += `\n\n${section.join("\n")}`;
86
105
  if (task.edges?.length) {
87
106
  const graph = relationshipGraphLines.length > 0 ? relationshipGraphLines.join("\n") : " (graph unavailable)";
88
107
  output += `\n\nRelationships:\n Dependencies point prerequisite → dependent.\n${graph}`;
@@ -1,4 +1,4 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
3
3
  import {
4
4
  TASK_DETAIL_HORIZONTAL_PAN_COLUMNS,
@@ -12,7 +12,9 @@ import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
12
12
  import { projectTaskRelationships } from "../../src/task-relationship-view.ts";
13
13
  import type { TaskGraph } from "../../src/task-service.ts";
14
14
  import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
15
- import { taskDetailsText } from "./task-detail-format.ts";
15
+ import { taskDetailContent, taskDetailsText, type TaskDetailContent } from "./task-detail-format.ts";
16
+ import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
17
+ import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
16
18
 
17
19
  interface DetailLine {
18
20
  text: string;
@@ -25,11 +27,12 @@ class TaskDetailViewport {
25
27
  private renderedWidth = 0;
26
28
  private detailLines: DetailLine[] = [];
27
29
  private readonly visibleLines: number;
28
- private readonly narrative: string;
30
+ private readonly content: TaskDetailContent;
31
+ private readonly status: Artifact["status"];
29
32
 
30
33
  constructor(
31
34
  private readonly tui: TUI,
32
- private readonly theme: Theme,
35
+ private readonly activeTheme: ActiveTheme,
33
36
  task: Artifact,
34
37
  private readonly graphLines: string[],
35
38
  history: TaskEvent[],
@@ -39,7 +42,8 @@ class TaskDetailViewport {
39
42
  TASK_DETAIL_MIN_VISIBLE_LINES,
40
43
  Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
41
44
  );
42
- this.narrative = taskDetailsText({ ...task, edges: undefined }, [], history);
45
+ this.content = taskDetailContent(task, history);
46
+ this.status = task.status;
43
47
  }
44
48
 
45
49
  invalidate(): void { this.renderedWidth = 0; }
@@ -51,7 +55,8 @@ class TaskDetailViewport {
51
55
  this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
52
56
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
53
57
  const end = Math.min(this.detailLines.length, this.offsetY + this.visibleLines);
54
- const border = this.theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
58
+ const theme = this.activeTheme();
59
+ const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
55
60
  const footer = [
56
61
  graphWidth > contentWidth ? `←/→ graph · column ${this.offsetX + 1}/${graphWidth}` : "",
57
62
  this.detailLines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.detailLines.length}` : "",
@@ -59,12 +64,12 @@ class TaskDetailViewport {
59
64
  ].filter(Boolean).join(" · ");
60
65
  return [
61
66
  border,
62
- truncateToWidth(this.theme.bold("Task details"), width, ""),
67
+ truncateToWidth(theme.fg("accent", theme.bold("Task details")), width, ""),
63
68
  border,
64
69
  ...this.detailLines.slice(this.offsetY, end).map((line) => line.graph
65
70
  ? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
66
71
  : truncateToWidth(` ${line.text}`, width, "")),
67
- truncateToWidth(this.theme.fg("dim", footer), width, ""),
72
+ truncateToWidth(theme.fg("dim", footer), width, ""),
68
73
  border,
69
74
  ];
70
75
  }
@@ -82,16 +87,36 @@ class TaskDetailViewport {
82
87
  private buildLines(width: number): void {
83
88
  if (this.renderedWidth === width) return;
84
89
  this.renderedWidth = width;
85
- const narrative = this.narrative.split("\n").flatMap((line) =>
86
- (line.length === 0 ? [""] : wrapTextWithAnsi(line, width)).map((text) => ({ text, graph: false })));
90
+ const theme = this.activeTheme();
91
+ const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): DetailLine[] =>
92
+ (text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, graph: false }));
93
+ const status = TASK_STATUS_PRESENTATION[this.status as keyof typeof TASK_STATUS_PRESENTATION];
94
+ const headline = status ? theme.fg(status.color, theme.bold(this.content.headline)) : theme.bold(this.content.headline);
95
+ const identity = [
96
+ ...wrapTextWithAnsi(headline, width).map((text) => ({ text, graph: false })),
97
+ ...wrap(this.content.identity, "muted"),
98
+ ...(this.content.labels.length > 0 ? wrap(`Labels: ${this.content.labels.join(", ")}`, "muted") : []),
99
+ { text: "", graph: false },
100
+ ];
101
+ const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, graph: false }));
102
+ const sections = this.content.sections.flatMap((section) => [
103
+ { text: "", graph: false },
104
+ ...section.flatMap((line, index) => wrap(line, index === 0 ? "muted" : "dim")),
105
+ ]);
87
106
  const relationshipHeader = this.graphLines.length > 0
88
107
  ? [
89
108
  { text: "", graph: false },
90
- { text: "Relationships:", graph: false },
91
- { text: " Dependencies point prerequisite → dependent.", graph: false },
109
+ ...wrap("Relationships:", "muted"),
110
+ ...wrap(" Dependencies point prerequisite → dependent.", "dim"),
92
111
  ]
93
112
  : [];
94
- this.detailLines = [...narrative, ...relationshipHeader, ...this.graphLines.map((text) => ({ text, graph: true }))];
113
+ this.detailLines = [
114
+ ...identity,
115
+ ...body,
116
+ ...sections,
117
+ ...relationshipHeader,
118
+ ...this.graphLines.map((text) => ({ text: theme.fg("text", text), graph: true })),
119
+ ];
95
120
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
96
121
  }
97
122
  }
@@ -110,5 +135,5 @@ export async function showTaskDetails(
110
135
  return;
111
136
  }
112
137
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
113
- new TaskDetailViewport(tui, theme, task, relationshipGraph, history, done));
138
+ new TaskDetailViewport(tui, () => ctx.ui.theme ?? theme, task, relationshipGraph, history, done));
114
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],