@danypops/papyrus 0.34.1 → 0.34.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,19 +10,26 @@ export interface ArtifactDetailContent {
10
10
  relationships: string[];
11
11
  }
12
12
 
13
- export function artifactDetailContent(artifact: Artifact): ArtifactDetailContent {
13
+ export function artifactDetailContent(artifact: Artifact, relationshipLines: string[] = []): ArtifactDetailContent {
14
14
  return {
15
15
  title: artifact.title,
16
16
  identity: `${artifact.id} [${artifact.kind}|${artifact.status}]${artifact.subtype ? ` · ${artifact.subtype}` : ""}`,
17
17
  body: artifact.body || "(no body)",
18
18
  labels: [...artifact.labels],
19
19
  metadata: Object.keys(artifact.extra).length > 0 ? formatMetadata(artifact.extra) : [],
20
- relationships: (artifact.edges ?? []).map((edge) => `${edge.from} --${edge.relation}--> ${edge.to}`),
20
+ relationships: relationshipLines,
21
21
  };
22
22
  }
23
23
 
24
- export function artifactDetailsText(artifact: Artifact): string {
25
- const content = artifactDetailContent(artifact);
24
+ /**
25
+ * Deliberately plain, unlike ArtifactDetailViewport's TUI body (renderMarkdownBody): this path
26
+ * feeds ctx.ui.notify(), used outside interactive mode (RPC, piped/non-terminal callers) where
27
+ * ANSI escape codes are noise or corruption, not formatting. Raw Markdown source stays readable
28
+ * as plain text either way. relationshipLines is still upgraded (a real graph or a resolved
29
+ * arrow list, never raw ids) since that needs no color to read.
30
+ */
31
+ export function artifactDetailsText(artifact: Artifact, relationshipLines: string[] = []): string {
32
+ const content = artifactDetailContent(artifact, relationshipLines);
26
33
  let output = `${content.title}\n${content.identity}\n\n${content.body}`;
27
34
  if (content.labels.length > 0) output += `\n\nLabels: ${content.labels.join(", ")}`;
28
35
  if (content.metadata.length > 0) output += `\n\nMetadata:\n${content.metadata.map((line) => ` ${line}`).join("\n")}`;
@@ -7,7 +7,10 @@ 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 type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
10
11
  import { artifactDetailContent, artifactDetailsText, type ArtifactDetailContent } from "./artifact-detail-format.ts";
12
+ import { buildArtifactRelationshipLines } from "./artifact-relationship-lines.ts";
13
+ import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
11
14
  import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
12
15
 
13
16
  interface ArtifactDetailLine {
@@ -27,13 +30,14 @@ class ArtifactDetailViewport {
27
30
  private readonly tui: TUI,
28
31
  private readonly activeTheme: ActiveTheme,
29
32
  artifact: Artifact,
33
+ relationshipLines: string[],
30
34
  private readonly close: () => void,
31
35
  ) {
32
36
  this.visibleLines = Math.max(
33
37
  ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
34
38
  Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
35
39
  );
36
- this.content = artifactDetailContent(artifact);
40
+ this.content = artifactDetailContent(artifact, relationshipLines);
37
41
  }
38
42
 
39
43
  invalidate(): void { this.renderedWidth = 0; }
@@ -104,9 +108,14 @@ class ArtifactDetailViewport {
104
108
  }
105
109
  }
106
110
 
107
- export async function showArtifactDetailView(ctx: ExtensionCommandContext, artifact: Artifact): Promise<void> {
108
- const output = artifactDetailsText(artifact);
111
+ export async function showArtifactDetailView(
112
+ ctx: ExtensionCommandContext,
113
+ artifact: Artifact,
114
+ renderer: GraphRenderer = new BeautifulMermaidRenderer(),
115
+ ): Promise<void> {
116
+ const relationshipLines = buildArtifactRelationshipLines(artifact, renderer);
117
+ const output = artifactDetailsText(artifact, relationshipLines);
109
118
  if (ctx.mode !== "tui") { ctx.ui.notify(output, "info"); return; }
110
119
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
111
- new ArtifactDetailViewport(tui, () => ctx.ui.theme ?? theme, artifact, done));
120
+ new ArtifactDetailViewport(tui, () => ctx.ui.theme ?? theme, artifact, relationshipLines, done));
112
121
  }
@@ -0,0 +1,24 @@
1
+ import { GRAPH_RENDER_MAX_ROUTED_EDGES, GRAPH_RENDER_MAX_ROUTED_NODES } from "../../src/constants.ts";
2
+ import { projectArtifactRelationships } from "../../src/artifact-relationship-view.ts";
3
+ import type { Artifact } from "../../src/domain/artifact.ts";
4
+ import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
5
+
6
+ /**
7
+ * Renders an artifact's direct relationships as a small graph when the neighbor set is real
8
+ * (more than one node) and within the same bound Task graph rendering already enforces;
9
+ * otherwise falls back to a plain, still name-resolved arrow list -- never the renderer's own
10
+ * separate box-style fallback, so this view has exactly two shapes, not three.
11
+ */
12
+ export function buildArtifactRelationshipLines(artifact: Artifact, renderer: GraphRenderer): string[] {
13
+ const graph = projectArtifactRelationships(artifact);
14
+ if (graph.edges.length === 0) return [];
15
+ const withinBounds = graph.nodes.length > 1
16
+ && graph.nodes.length <= GRAPH_RENDER_MAX_ROUTED_NODES
17
+ && graph.edges.length <= GRAPH_RENDER_MAX_ROUTED_EDGES;
18
+ if (withinBounds) {
19
+ const rendered = renderer.render(graph);
20
+ if (rendered.lines.length > 0) return rendered.lines;
21
+ }
22
+ const labelById = new Map(graph.nodes.map((node) => [node.id, node.label]));
23
+ return graph.edges.map((edge) => `${labelById.get(edge.from) ?? edge.from} --${edge.label}--> ${labelById.get(edge.to) ?? edge.to}`);
24
+ }
@@ -77,7 +77,7 @@ export interface ContextSegmentItem {
77
77
  }
78
78
 
79
79
  export interface ContextSegment {
80
- key: "rules" | "tasks" | "skills" | "basePrompt" | "messageHistory" | "other";
80
+ key: "rules" | "tasks" | "skills" | "basePrompt" | "messageHistory" | "toolDefinitions" | "other";
81
81
  label: string;
82
82
  estimatedTokens: number;
83
83
  /** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
@@ -273,15 +273,15 @@ export interface ContextBreakdown {
273
273
  /** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
274
274
  effectiveBudget: number | null;
275
275
  /**
276
- * How much the known/estimated segments (rules+tasks+skills+basePrompt+messageHistory)
277
- * exceed the real total, when they do. Zero means no overshoot. This must stay visible
278
- * rather than only being absorbed into "unaccounted" clamping to zero -- a clamped-to-zero
279
- * unaccounted segment does NOT mean tool definitions and framework overhead are actually
280
- * free; it means this estimate's other segments already consumed the entire real budget on
281
- * paper. Hiding that distinction would make a genuinely nonzero cost look like zero.
276
+ * How much the known/estimated segments (rules+tasks+skills+basePrompt+messageHistory+
277
+ * toolDefinitions) exceed the real total, when they do. Zero means no overshoot. This must
278
+ * stay visible rather than only being absorbed into "unaccounted" clamping to zero -- a
279
+ * clamped-to-zero unaccounted segment does NOT mean wire-protocol overhead is actually free;
280
+ * it means this estimate's other segments already consumed the entire real budget on paper.
281
+ * Hiding that distinction would make a genuinely nonzero cost look like zero.
282
282
  */
283
283
  overshootTokens: number;
284
- /** rules, tasks, skills, basePrompt, messageHistory, then "other" absorbing whatever real usage the rest don't account for. */
284
+ /** rules, tasks, skills, basePrompt, messageHistory, toolDefinitions, then "other" absorbing whatever real usage the rest don't account for. */
285
285
  segments: ContextSegment[];
286
286
  }
287
287
 
@@ -301,6 +301,8 @@ export interface BuildContextBreakdownInput {
301
301
  messageHistoryItems: ContextSegmentItem[];
302
302
  /** buildMessageHistoryTree()'s activeTokens -- only entries on the current active path count toward the segment total; an abandoned /tree branch still appears in messageHistoryItems but contributes zero here. */
303
303
  messageHistoryActiveTokens: number;
304
+ /** From buildToolDefinitionItems() against pi.getAllTools() filtered to pi.getActiveTools(). Defaults to empty when omitted. */
305
+ toolDefinitionItems?: ContextSegmentItem[];
304
306
  }
305
307
 
306
308
  /** Sums a possibly-nested item tree's tokens recursively -- every node's own contribution, not just top-level items. */
@@ -308,6 +310,46 @@ function sumItemTree(items: ContextSegmentItem[]): number {
308
310
  return items.reduce((sum, item) => sum + item.estimatedTokens + sumItemTree(item.children ?? []), 0);
309
311
  }
310
312
 
313
+ /** The subset of pi.getAllTools()'s ToolInfo this estimate actually reads -- kept minimal so this stays testable with plain object literals instead of importing Pi's own extension types. */
314
+ export interface ActiveToolDefinitionLike {
315
+ name: string;
316
+ description: string;
317
+ parameters: unknown;
318
+ sourceInfo: { source: string };
319
+ }
320
+
321
+ /**
322
+ * Tool definitions (name + description + JSON schema) are actually measurable, unlike genuine
323
+ * wire-protocol framework overhead (message envelope/role wrapping, cache-control markers) which
324
+ * really is invisible to any extension -- this is what lets "other" stop absorbing them as an
325
+ * unmeasured guess. Grouped by extension/package source with each tool as a drill-down child
326
+ * (mirrors the Tasks segment's own parent/child shape) rather than one flat list, since a real
327
+ * session can have dozens of active tools spread across many extensions.
328
+ */
329
+ export function buildToolDefinitionItems(tools: ReadonlyArray<ActiveToolDefinitionLike>): ContextSegmentItem[] {
330
+ const bySource = new Map<string, ActiveToolDefinitionLike[]>();
331
+ for (const tool of tools) {
332
+ const list = bySource.get(tool.sourceInfo.source) ?? [];
333
+ list.push(tool);
334
+ bySource.set(tool.sourceInfo.source, list);
335
+ }
336
+ const items: ContextSegmentItem[] = [];
337
+ for (const [source, toolsForSource] of bySource) {
338
+ const children = toolsForSource
339
+ .map((tool) => {
340
+ const characters = tool.name.length + tool.description.length + JSON.stringify(tool.parameters ?? {}).length;
341
+ return { label: tool.name, estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) };
342
+ })
343
+ .sort((a, b) => b.estimatedTokens - a.estimatedTokens);
344
+ items.push({
345
+ label: `${source} (${toolsForSource.length} tool${toolsForSource.length === 1 ? "" : "s"})`,
346
+ estimatedTokens: children.reduce((sum, child) => sum + child.estimatedTokens, 0),
347
+ children,
348
+ });
349
+ }
350
+ return items.sort((a, b) => b.estimatedTokens - a.estimatedTokens);
351
+ }
352
+
311
353
  /**
312
354
  * Builds the Tasks segment's items from Papyrus's own real containment tree (parentIds/
313
355
  * childIds), not a flat list -- Tasks are a genuine DAG (a task may have more than one
@@ -382,9 +424,10 @@ export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
382
424
 
383
425
  /**
384
426
  * Composes every segment Papyrus can actually measure or estimate (rules, tasks, skills
385
- * catalog, cached base-prompt size, and the live session's own message history) against the
386
- * real total Pi reports, deriving "unaccounted" (tool definitions and framework overhead --
387
- * genuinely invisible to any extension) as the remainder. The remainder is clamped to zero
427
+ * catalog, cached base-prompt size, active tool definitions, and the live session's own
428
+ * message history) against the real total Pi reports, deriving "unaccounted" (genuine
429
+ * wire-protocol overhead -- message envelope/role wrapping, cache-control markers -- which
430
+ * really is invisible to any extension) as the remainder. The remainder is clamped to zero
388
431
  * rather than shown negative -- char/4 token estimation is approximate, and a small overshoot
389
432
  * in the known segments must not display as a nonsensical negative bucket -- but the clamp
390
433
  * amount itself is preserved as overshootTokens rather than silently discarded, so a
@@ -426,13 +469,26 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
426
469
  estimatedTokens: input.messageHistoryActiveTokens,
427
470
  items: input.messageHistoryItems,
428
471
  };
429
- const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens;
472
+ const toolDefinitionItems = input.toolDefinitionItems ?? [];
473
+ const toolCount = toolDefinitionItems.reduce((sum, item) => sum + (item.children?.length ?? 1), 0);
474
+ const toolDefinitions: ContextSegment = {
475
+ key: "toolDefinitions",
476
+ label: `Active tool definitions (${toolCount} tool${toolCount === 1 ? "" : "s"})`,
477
+ // Top-level sum only, NOT sumItemTree: unlike Tasks/message-history, whose parent nodes
478
+ // carry their own independent content genuinely additive with their children, a
479
+ // buildToolDefinitionItems() group node's own estimatedTokens IS the sum of its children
480
+ // (by construction, for a meaningful collapsed-row total) -- summing the tree here would
481
+ // double-count every tool once as itself and once inside its group's total.
482
+ estimatedTokens: toolDefinitionItems.reduce((sum, item) => sum + item.estimatedTokens, 0),
483
+ ...(toolDefinitionItems.length > 0 ? { items: toolDefinitionItems } : {}),
484
+ };
485
+ const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens + basePrompt.estimatedTokens + messageHistory.estimatedTokens + toolDefinitions.estimatedTokens;
430
486
  const overshootTokens = input.totalTokens === null ? 0 : Math.max(0, knownTokens - input.totalTokens);
431
487
  const other: ContextSegment = {
432
488
  key: "other",
433
489
  label: overshootTokens > 0
434
- ? `Unaccounted (tool definitions, framework overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
435
- : "Unaccounted (tool definitions, framework overhead)",
490
+ ? `Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead) -- estimate overshoot: other segments' estimates already exceed the real total by ~${overshootTokens} tokens, so this is a floor, not a real zero`
491
+ : "Unaccounted (message envelope, cache-control markers, and other wire-protocol overhead)",
436
492
  estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
437
493
  };
438
494
  return {
@@ -440,7 +496,7 @@ export function buildContextBreakdown(input: BuildContextBreakdownInput): Contex
440
496
  contextWindow: input.contextWindow,
441
497
  effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
442
498
  overshootTokens,
443
- segments: [rules, tasks, skills, basePrompt, messageHistory, other],
499
+ segments: [rules, tasks, skills, basePrompt, messageHistory, toolDefinitions, other],
444
500
  };
445
501
  }
446
502
 
@@ -10,6 +10,7 @@ const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
10
10
  skills: "mdLink",
11
11
  basePrompt: "warning",
12
12
  messageHistory: "syntaxFunction",
13
+ toolDefinitions: "syntaxKeyword",
13
14
  other: "muted",
14
15
  };
15
16
 
@@ -34,7 +34,7 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
34
34
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
35
35
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
36
36
  import { buildContextInjection } from "./context-injection-telemetry.ts";
37
- import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type ContextSegmentItem, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
37
+ import { buildContextBreakdown, buildMessageHistoryTree, buildTaskItemTree, buildToolDefinitionItems, computeContextBudget, computeRuleBudget, DEFAULT_RESERVE_TOKENS, type ContextSegmentItem, type SessionEntryLike, type SessionTreeNodeLike } from "./context-budget.ts";
38
38
  import { buildBasePromptItems } from "./base-prompt-breakdown.ts";
39
39
  import { showContextView } from "./context-view.ts";
40
40
  import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
@@ -635,6 +635,8 @@ export default async function (pi: ExtensionAPI) {
635
635
  const activeEntryIds = new Set((ctx.sessionManager.buildContextEntries() as SessionEntryLike[]).map((entry) => entry.id));
636
636
  const branchEntryIds = new Set((ctx.sessionManager.getBranch() as SessionEntryLike[]).map((entry) => entry.id));
637
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)));
638
640
  const breakdown = buildContextBreakdown({
639
641
  totalTokens: usage?.tokens ?? null,
640
642
  contextWindow: ctx.model?.contextWindow ?? null,
@@ -643,6 +645,7 @@ export default async function (pi: ExtensionAPI) {
643
645
  skills,
644
646
  basePromptEstimatedTokens: lastObservedBasePromptTokens,
645
647
  basePromptItems: lastObservedBasePromptItems,
648
+ toolDefinitionItems,
646
649
  messageHistoryItems: messageHistory.items,
647
650
  messageHistoryActiveTokens: messageHistory.activeTokens,
648
651
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.34.1",
3
+ "version": "0.34.3",
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"],
@@ -0,0 +1,23 @@
1
+ import type { Artifact } from "./domain/artifact.ts";
2
+ import type { DisplayGraph, DisplayGraphEdge, DisplayGraphNode } from "./domain/display-graph.ts";
3
+ import { fallbackLabel } from "./task-relationship-view.ts";
4
+
5
+ /**
6
+ * The generic-artifact counterpart to projectTaskRelationships: unlike a Task, which already
7
+ * has its containing TaskGraph's real titles in memory, a generic Doc/Rule/Skill/Playbook only
8
+ * has raw edge id pairs (artifact.show's tree fetch never resolves neighbor titles -- see
9
+ * ops.ts getArtifact). Reuses the same fallbackLabel heuristic rather than adding a network
10
+ * round-trip per neighbor, which would turn a rendering concern into a new daemon-adjacent one.
11
+ */
12
+ export function projectArtifactRelationships(artifact: Artifact): DisplayGraph {
13
+ const edges: DisplayGraphEdge[] = (artifact.edges ?? []).map((edge) => ({ from: edge.from, to: edge.to, label: edge.relation }));
14
+ const nodeIds = new Set<string>();
15
+ for (const edge of edges) {
16
+ nodeIds.add(edge.from);
17
+ nodeIds.add(edge.to);
18
+ }
19
+ const nodes: DisplayGraphNode[] = [...nodeIds].map((id) => id === artifact.id
20
+ ? { id, label: artifact.title, status: artifact.status }
21
+ : { id, label: fallbackLabel(id) });
22
+ return { direction: "LR", nodes, edges };
23
+ }
@@ -9,7 +9,8 @@ function normalizeEdge(edge: ArtifactEdge): DisplayGraphEdge {
9
9
  return { from: edge.from, to: edge.to, label: edge.relation };
10
10
  }
11
11
 
12
- function fallbackLabel(id: string): string {
12
+ /** Best-effort readable label from a bare id when no real title is known -- shared with artifact-relationship-view.ts's generic version of this same problem. */
13
+ export function fallbackLabel(id: string): string {
13
14
  return id.replace(/-[a-z0-9]{4}$/i, "").replaceAll("-", " ");
14
15
  }
15
16