@danypops/papyrus 0.11.4 → 0.13.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 +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- package/src/task-service.ts +70 -38
package/README.md
CHANGED
|
@@ -56,6 +56,20 @@ It blocks every Papyrus push whose destination is not `DanyPops/papyrus`, includ
|
|
|
56
56
|
|
|
57
57
|
The daemon uses WAL, foreign keys, a bounded busy timeout, versioned migrations, periodic passive checkpoints, and periodic `PRAGMA optimize`. Keep the database on a local filesystem; SQLite WAL does not support network filesystems.
|
|
58
58
|
|
|
59
|
+
### Context Mesh persistence model
|
|
60
|
+
|
|
61
|
+
`artifacts` is the shared graph-identity supertype, not a second copy of every application's database. `edges` references that single identity table at both endpoints, preserving foreign-key integrity for cross-domain links. Domain extension tables exist only where application invariants require indexed relational state: Task chronology/focus/scope and Discourse posts/events/session cursors/projection checkpoints. This is a class-table/table-per-type variant with explicit child-to-parent foreign keys; Papyrus does not use SQLite table inheritance or orphan-prone `(target_type, target_id)` links.
|
|
62
|
+
|
|
63
|
+
The owning application remains the mutation authority. Discourse commits its extension rows and `context-thread`/`context-message` Doc projections atomically through `discourse.store`; generic artifact, document, Skill-template, lifecycle, and graph-link operations reject those owned subtypes and the `reply_to`/`discusses` relations. SQLite triggers additionally verify that each extension row references the expected Doc subtype. Domain tables are canonical for domain invariants; graph bodies and metadata are read-oriented projections committed in the same transaction.
|
|
64
|
+
|
|
65
|
+
The authenticated CLI exposes the same operation for diagnostics and adapter parity:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
papyrus discourse store read_thread --store-id team-forum \
|
|
69
|
+
--input-json '{"forumId":"engineering","topicId":"reviews","threadId":"mesh","limit":25}' \
|
|
70
|
+
--json
|
|
71
|
+
```
|
|
72
|
+
|
|
59
73
|
## Schema protocol (enforceable)
|
|
60
74
|
|
|
61
75
|
Papyrus enforces four artifact kinds:
|
|
@@ -217,10 +231,10 @@ packed install npm:@danypops/papyrus
|
|
|
217
231
|
~/.pi/agent/npm/node_modules/.bin/papyrus service install
|
|
218
232
|
```
|
|
219
233
|
|
|
220
|
-
Existing databases are never migrated on daemon boot. After upgrading to
|
|
234
|
+
Existing databases are never migrated on daemon boot. After upgrading to a newer schema, run the authenticated CLI migration explicitly. Older databases receive prerequisite schemas—including Task continuation and Context Mesh extensions—in one transaction. Existing Tasks are deliberately marked **unscoped**: Papyrus does not guess ownership from titles, labels, historical cwd, or repository names. They remain visible in **All projects** until explicitly assigned with `papyrus tasks assign-project <task-id> [project-root]`:
|
|
221
235
|
|
|
222
236
|
```bash
|
|
223
|
-
~/.pi/agent/npm/node_modules/.bin/papyrus migrate
|
|
237
|
+
~/.pi/agent/npm/node_modules/.bin/papyrus migrate schema
|
|
224
238
|
```
|
|
225
239
|
|
|
226
240
|
Until that command succeeds, health reports `migrationRequired` and normal domain operations are rejected with actionable guidance. Migration is not exposed as a Pi tool or MCP action. New empty databases bootstrap directly at the current schema.
|
|
@@ -41,6 +41,8 @@ function continuationPrompt(task: ActiveTaskMarker): string {
|
|
|
41
41
|
return [
|
|
42
42
|
"Continue the active Papyrus Task now; do not hand off merely because the previous Pi run settled.",
|
|
43
43
|
"Reconcile its lifecycle, take the next concrete action, use tools, submit it for review when implementation effort is ready, and run gates plus checklist review before completion.",
|
|
44
|
+
"Do not shrink the task's scope to whatever fits in this turn, and do not treat a status update or summary as a substitute for doing the work or as proof of completion.",
|
|
45
|
+
"If something blocks progress, do not reject or pause on the first obstacle -- only after it genuinely recurs, and only when the task truly cannot proceed without external input.",
|
|
44
46
|
`Active task: ${task.id}: ${task.title.slice(0, TITLE_LIMIT)}`,
|
|
45
47
|
].join("\n");
|
|
46
48
|
}
|
|
@@ -97,6 +99,10 @@ export class ActiveTaskContinuation {
|
|
|
97
99
|
this.queued = false;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
onCompaction(): void {
|
|
103
|
+
this.queued = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
100
106
|
onHumanInput(): void {
|
|
101
107
|
this.resetProgress();
|
|
102
108
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { SEED_RELATIONS } from "../../src/constants.ts";
|
|
5
5
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
6
6
|
import type { OperationName } from "../../src/service.ts";
|
|
7
|
+
import type { StatusPresentation } from "./artifact-status-presentation.ts";
|
|
7
8
|
import { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
8
9
|
import { showArtifactDetailView } from "./artifact-detail-view.ts";
|
|
9
10
|
import { callService } from "./service-client.ts";
|
|
@@ -19,10 +20,10 @@ export interface ArtifactBrowserConfig {
|
|
|
19
20
|
kind: string;
|
|
20
21
|
title: string;
|
|
21
22
|
statusOrder: string[];
|
|
22
|
-
|
|
23
|
+
presentation: Record<string, StatusPresentation>;
|
|
23
24
|
listOperation?: OperationName;
|
|
24
25
|
listInput?: Record<string, unknown>;
|
|
25
|
-
rowMeta(row: Artifact): string;
|
|
26
|
+
rowMeta(row: Artifact, theme: Theme): string;
|
|
26
27
|
actions(row: Artifact): string[];
|
|
27
28
|
handleAction(choice: string, row: Artifact, ctx: ExtensionCommandContext): Promise<void>;
|
|
28
29
|
}
|
|
@@ -156,7 +157,11 @@ function renderPanel(
|
|
|
156
157
|
.join(theme.fg("muted", " · "));
|
|
157
158
|
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
158
159
|
const summary = statusSummary(rows, config.statusOrder)
|
|
159
|
-
.map(({ status, count }) =>
|
|
160
|
+
.map(({ status, count }) => {
|
|
161
|
+
const presentation = config.presentation[status];
|
|
162
|
+
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : status;
|
|
163
|
+
return `${glyph} ${count} ${status}`;
|
|
164
|
+
})
|
|
160
165
|
.join(", ");
|
|
161
166
|
return [
|
|
162
167
|
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
|
|
@@ -176,10 +181,11 @@ function renderPanel(
|
|
|
176
181
|
const row = filtered[index]!;
|
|
177
182
|
const selected = index === selectedIndex;
|
|
178
183
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
179
|
-
const
|
|
184
|
+
const presentation = config.presentation[row.status];
|
|
185
|
+
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : "?";
|
|
180
186
|
const title = selected ? theme.bold(row.title) : row.title;
|
|
181
|
-
const meta = config.rowMeta(row);
|
|
182
|
-
lines.push(truncateToWidth(`${cursor} ${glyph} ${title}${meta ? theme.fg("dim",
|
|
187
|
+
const meta = config.rowMeta(row, theme);
|
|
188
|
+
lines.push(truncateToWidth(`${cursor} ${glyph} ${title}${meta ? `${theme.fg("dim", " · ")}${meta}` : ""}`, width, ""));
|
|
183
189
|
}
|
|
184
190
|
lines.push(theme.fg("muted", ` ${selectedIndex + 1}/${filtered.length} ${config.kind}`));
|
|
185
191
|
return lines;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared {label, glyph, color} shape, mirroring task-presentation.ts's TASK_STATUS_PRESENTATION
|
|
5
|
+
* for every other artifact kind's status. Centralizing this closes a real gap: every artifact
|
|
6
|
+
* browser (Rules, Docs, Notes, Skills) previously rendered status as a bare glyph with no color at
|
|
7
|
+
* all, which is exactly why "hard to understand which rules are active" was a real complaint --
|
|
8
|
+
* an active rule's "●" and a deprecated rule's "○" differ only by one filled-vs-hollow pixel shape,
|
|
9
|
+
* easy to miss at a glance across a scrolling list.
|
|
10
|
+
*/
|
|
11
|
+
export interface StatusPresentation {
|
|
12
|
+
label: string;
|
|
13
|
+
glyph: string;
|
|
14
|
+
color: ThemeColor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RULE_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
18
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
19
|
+
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const DOC_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
23
|
+
draft: { label: "draft", glyph: "○", color: "muted" },
|
|
24
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
25
|
+
archived: { label: "archived", glyph: "■", color: "dim" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const NOTE_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
29
|
+
draft: { label: "draft", glyph: "○", color: "muted" },
|
|
30
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
31
|
+
archived: { label: "archived", glyph: "■", color: "dim" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
35
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
36
|
+
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Rule severity gets its own color independent of status -- block is the loudest, info the quietest. */
|
|
40
|
+
export const RULE_SEVERITY_PRESENTATION: Record<string, ThemeColor> = {
|
|
41
|
+
block: "error",
|
|
42
|
+
warn: "warning",
|
|
43
|
+
info: "accent",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function severityColor(severity: string): ThemeColor {
|
|
47
|
+
return RULE_SEVERITY_PRESENTATION[severity.toLowerCase()] ?? "muted";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Plain glyph lookup, for callers that build uncolored text first and colorize it later (e.g. task-graph's colorizeTaskGraphLine pattern). */
|
|
51
|
+
export function glyphOf(presentation: Record<string, StatusPresentation>, status: string): string {
|
|
52
|
+
return presentation[status]?.glyph ?? "?";
|
|
53
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../../src/constants.ts";
|
|
4
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
5
|
+
import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
|
|
6
|
+
import { ruleInjectionPreview } from "./rules.ts";
|
|
7
|
+
|
|
8
|
+
const REPORT_MAX_ROWS = 5;
|
|
9
|
+
|
|
10
|
+
export interface RuleBudgetEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
characters: number;
|
|
14
|
+
estimatedTokens: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContextBudget {
|
|
18
|
+
rules: {
|
|
19
|
+
entries: RuleBudgetEntry[]; // sorted descending by characters
|
|
20
|
+
totalCharacters: number;
|
|
21
|
+
totalEstimatedTokens: number;
|
|
22
|
+
};
|
|
23
|
+
skills: SkillCatalogFootprint;
|
|
24
|
+
totalEstimatedTokens: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Active Rules are injected into every relevant turn -- the same permanent tax role as a Pi-native skill's catalog entry. */
|
|
28
|
+
export function computeRuleBudget(rules: ReadonlyArray<Pick<Artifact, "id" | "title" | "body" | "extra">>): ContextBudget["rules"] {
|
|
29
|
+
const entries = rules
|
|
30
|
+
.map((rule) => {
|
|
31
|
+
const characters = ruleInjectionPreview(rule).length;
|
|
32
|
+
return { id: rule.id, title: rule.title, characters, estimatedTokens: Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN) };
|
|
33
|
+
})
|
|
34
|
+
.sort((a, b) => b.characters - a.characters);
|
|
35
|
+
return {
|
|
36
|
+
entries,
|
|
37
|
+
totalCharacters: entries.reduce((sum, entry) => sum + entry.characters, 0),
|
|
38
|
+
totalEstimatedTokens: entries.reduce((sum, entry) => sum + entry.estimatedTokens, 0),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Best-effort: a missing, unreadable, or malformed settings.json contributes no extra skill directories rather than failing the whole report. */
|
|
43
|
+
function readSettingsSkillPaths(settingsPath: string): string[] {
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { skills?: unknown };
|
|
46
|
+
if (!Array.isArray(raw.skills)) return [];
|
|
47
|
+
return raw.skills.filter((entry): entry is string => typeof entry === "string");
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function computeContextBudget(
|
|
54
|
+
rules: ReadonlyArray<Pick<Artifact, "id" | "title" | "body" | "extra">>,
|
|
55
|
+
cwd: string,
|
|
56
|
+
homeDirectory: string = homedir(),
|
|
57
|
+
): ContextBudget {
|
|
58
|
+
const settingsSkills = readSettingsSkillPaths(`${homeDirectory}/.pi/agent/settings.json`);
|
|
59
|
+
const directories = discoverSkillDirectories(homeDirectory, cwd, settingsSkills);
|
|
60
|
+
const skills = scanSkillCatalogFootprint(directories);
|
|
61
|
+
const ruleBudget = computeRuleBudget(rules);
|
|
62
|
+
return { rules: ruleBudget, skills, totalEstimatedTokens: ruleBudget.totalEstimatedTokens + skills.totalEstimatedTokens };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pi's own documented compaction-reserve default (docs/compaction.md): headroom kept free for the model's response. */
|
|
66
|
+
export const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
67
|
+
|
|
68
|
+
export interface ContextSegmentItem {
|
|
69
|
+
label: string;
|
|
70
|
+
estimatedTokens: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ContextSegment {
|
|
74
|
+
key: "rules" | "tasks" | "skills" | "other";
|
|
75
|
+
label: string;
|
|
76
|
+
estimatedTokens: number;
|
|
77
|
+
/** Drill-down items, when this segment can be broken down further. Absent for "other" -- an opaque remainder, not a real category. */
|
|
78
|
+
items?: ContextSegmentItem[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ContextBreakdown {
|
|
82
|
+
/** Real usage from ctx.getContextUsage() -- ground truth, not estimated. Null only when Pi has no usage yet (e.g. before the first turn). */
|
|
83
|
+
totalTokens: number | null;
|
|
84
|
+
/** From ctx.model.contextWindow. Null when the active model's context window is unknown. */
|
|
85
|
+
contextWindow: number | null;
|
|
86
|
+
/** contextWindow - reserveTokens, mirroring Pi's own compaction-trigger formula. Null when contextWindow is unknown. */
|
|
87
|
+
effectiveBudget: number | null;
|
|
88
|
+
/** rules, tasks, skills, then "other" absorbing whatever real usage the first three don't account for. */
|
|
89
|
+
segments: ContextSegment[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface BuildContextBreakdownInput {
|
|
93
|
+
totalTokens: number | null;
|
|
94
|
+
contextWindow: number | null;
|
|
95
|
+
reserveTokens?: number;
|
|
96
|
+
ruleBudget: ContextBudget["rules"];
|
|
97
|
+
taskEstimatedTokens: number;
|
|
98
|
+
skills: SkillCatalogFootprint;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Composes Papyrus's own estimated segments (rules/tasks/skills) against the real total Pi
|
|
103
|
+
* reports, deriving "everything else" (base system prompt, message history, tool definitions
|
|
104
|
+
* -- none of which Papyrus can see or estimate) as the remainder. The remainder is clamped to
|
|
105
|
+
* zero rather than shown negative: char/4 token estimation is approximate, and a small
|
|
106
|
+
* overshoot in the known segments must not display as a nonsensical negative "other" bucket.
|
|
107
|
+
* When the real total is unavailable, "other" is reported as zero and totalTokens surfaces as
|
|
108
|
+
* null so callers can label the whole breakdown as estimate-only rather than silently treating
|
|
109
|
+
* a partial sum as ground truth.
|
|
110
|
+
*/
|
|
111
|
+
export function buildContextBreakdown(input: BuildContextBreakdownInput): ContextBreakdown {
|
|
112
|
+
const reserveTokens = input.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
113
|
+
const rules: ContextSegment = {
|
|
114
|
+
key: "rules",
|
|
115
|
+
label: "Papyrus Rules",
|
|
116
|
+
estimatedTokens: input.ruleBudget.totalEstimatedTokens,
|
|
117
|
+
items: input.ruleBudget.entries.map((entry) => ({ label: entry.title, estimatedTokens: entry.estimatedTokens })),
|
|
118
|
+
};
|
|
119
|
+
const tasks: ContextSegment = { key: "tasks", label: "Papyrus Tasks", estimatedTokens: input.taskEstimatedTokens };
|
|
120
|
+
const skills: ContextSegment = {
|
|
121
|
+
key: "skills",
|
|
122
|
+
label: "Pi Skills catalog",
|
|
123
|
+
estimatedTokens: input.skills.totalEstimatedTokens,
|
|
124
|
+
items: input.skills.entries.map((entry) => ({ label: entry.name, estimatedTokens: entry.estimatedTokens })),
|
|
125
|
+
};
|
|
126
|
+
const knownTokens = rules.estimatedTokens + tasks.estimatedTokens + skills.estimatedTokens;
|
|
127
|
+
const other: ContextSegment = {
|
|
128
|
+
key: "other",
|
|
129
|
+
label: "Everything else (base prompt, message history, tool definitions)",
|
|
130
|
+
estimatedTokens: input.totalTokens === null ? 0 : Math.max(0, input.totalTokens - knownTokens),
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
totalTokens: input.totalTokens,
|
|
134
|
+
contextWindow: input.contextWindow,
|
|
135
|
+
effectiveBudget: input.contextWindow === null ? null : Math.max(0, input.contextWindow - reserveTokens),
|
|
136
|
+
segments: [rules, tasks, skills, other],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function truncate(text: string, max: number): string {
|
|
141
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Pure text formatter, independent of live daemon/filesystem state, for direct unit testing. */
|
|
145
|
+
export function formatContextBudgetReport(budget: ContextBudget): string {
|
|
146
|
+
const lines: string[] = ["Papyrus passive context budget", ""];
|
|
147
|
+
|
|
148
|
+
lines.push(`Rules (active, injected every relevant turn): ${budget.rules.entries.length} rules · ${budget.rules.totalCharacters} chars · ~${budget.rules.totalEstimatedTokens} tokens`);
|
|
149
|
+
if (budget.rules.entries.length > 0) {
|
|
150
|
+
lines.push(" Largest:");
|
|
151
|
+
for (const entry of budget.rules.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
152
|
+
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.title, 60)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
|
|
157
|
+
lines.push(`Skills (Pi-native catalog, injected at startup): ${budget.skills.entries.length} skills · ${budget.skills.totalCharacters} chars · ~${budget.skills.totalEstimatedTokens} tokens`);
|
|
158
|
+
if (budget.skills.entries.length > 0) {
|
|
159
|
+
lines.push(" Largest:");
|
|
160
|
+
for (const entry of budget.skills.entries.slice(0, REPORT_MAX_ROWS)) {
|
|
161
|
+
lines.push(` ${entry.characters.toString().padStart(5)} chars (~${entry.estimatedTokens} tok) ${truncate(entry.name, 40)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (budget.skills.scannedDirectories.length > 0) {
|
|
165
|
+
lines.push(` Scanned: ${budget.skills.scannedDirectories.join(", ")}`);
|
|
166
|
+
} else {
|
|
167
|
+
lines.push(" No skill directories found (checked Pi's documented global/project locations and settings.json's skills array).");
|
|
168
|
+
}
|
|
169
|
+
lines.push("");
|
|
170
|
+
|
|
171
|
+
lines.push(`Total passive tax: ~${budget.totalEstimatedTokens} tokens across ${budget.rules.entries.length + budget.skills.entries.length} items, before a single user message or tool call.`);
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ContextBreakdown, ContextSegment, ContextSegmentItem } from "./context-budget.ts";
|
|
4
|
+
import { formatContextBudgetReport, type ContextBudget } from "./context-budget.ts";
|
|
5
|
+
|
|
6
|
+
const DRILLDOWN_VISIBLE_ROWS = 15;
|
|
7
|
+
|
|
8
|
+
const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
|
|
9
|
+
rules: "accent",
|
|
10
|
+
tasks: "success",
|
|
11
|
+
skills: "mdLink",
|
|
12
|
+
other: "muted",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function formatTokenCount(tokens: number): string {
|
|
16
|
+
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function percentOf(part: number, whole: number): string {
|
|
20
|
+
return whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "—";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Renders the context window as one proportional stacked bar, one colored run of block
|
|
25
|
+
* characters per segment, matching each row's own swatch color below it. A zero-token
|
|
26
|
+
* breakdown (nothing observed yet) renders an empty dim track rather than a divide-by-zero.
|
|
27
|
+
*/
|
|
28
|
+
export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number): string {
|
|
29
|
+
const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
30
|
+
if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
|
|
31
|
+
let used = 0;
|
|
32
|
+
let output = "";
|
|
33
|
+
segments.forEach((segment, index) => {
|
|
34
|
+
const isLast = index === segments.length - 1;
|
|
35
|
+
const cells = isLast ? width - used : Math.round((segment.estimatedTokens / total) * width);
|
|
36
|
+
used += cells;
|
|
37
|
+
if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
|
|
38
|
+
});
|
|
39
|
+
return output;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class ContextViewport {
|
|
43
|
+
private selectedIndex = 0;
|
|
44
|
+
private drillDown: ContextSegment | null = null;
|
|
45
|
+
private drillIndex = 0;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly tui: TUI,
|
|
49
|
+
private readonly theme: Theme,
|
|
50
|
+
private readonly breakdown: ContextBreakdown,
|
|
51
|
+
private readonly close: () => void,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
invalidate(): void {}
|
|
55
|
+
|
|
56
|
+
render(width: number): string[] {
|
|
57
|
+
const theme = this.theme;
|
|
58
|
+
const contentWidth = Math.max(1, width);
|
|
59
|
+
const border = theme.fg("borderMuted", "─".repeat(contentWidth));
|
|
60
|
+
const lines: string[] = [border, truncateToWidth(theme.fg("accent", theme.bold("Context")), contentWidth, "")];
|
|
61
|
+
|
|
62
|
+
if (this.breakdown.totalTokens !== null && this.breakdown.effectiveBudget !== null) {
|
|
63
|
+
const percent = percentOf(this.breakdown.totalTokens, this.breakdown.effectiveBudget);
|
|
64
|
+
lines.push(truncateToWidth(
|
|
65
|
+
`${formatTokenCount(this.breakdown.totalTokens)} / ${formatTokenCount(this.breakdown.effectiveBudget)} tokens (${percent} of usable budget)`,
|
|
66
|
+
contentWidth,
|
|
67
|
+
"",
|
|
68
|
+
));
|
|
69
|
+
} else if (this.breakdown.totalTokens !== null) {
|
|
70
|
+
lines.push(truncateToWidth(`${formatTokenCount(this.breakdown.totalTokens)} tokens (model context window unknown)`, contentWidth, ""));
|
|
71
|
+
} else {
|
|
72
|
+
lines.push(theme.fg("dim", "No real usage reported yet — segment sizes below are Papyrus's own estimates only"));
|
|
73
|
+
}
|
|
74
|
+
lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth));
|
|
75
|
+
lines.push("");
|
|
76
|
+
|
|
77
|
+
if (this.drillDown) {
|
|
78
|
+
lines.push(...this.renderDrillDown(contentWidth));
|
|
79
|
+
} else {
|
|
80
|
+
lines.push(...this.renderSegments(contentWidth));
|
|
81
|
+
}
|
|
82
|
+
lines.push(border);
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private renderSegments(width: number): string[] {
|
|
87
|
+
const theme = this.theme;
|
|
88
|
+
const lines: string[] = [];
|
|
89
|
+
const denominator = this.breakdown.totalTokens ?? this.breakdown.segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
|
|
90
|
+
this.breakdown.segments.forEach((segment, index) => {
|
|
91
|
+
const selected = index === this.selectedIndex;
|
|
92
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
93
|
+
const swatch = theme.fg(SEGMENT_COLORS[segment.key], "██");
|
|
94
|
+
const title = selected ? theme.bold(segment.label) : segment.label;
|
|
95
|
+
const percent = percentOf(segment.estimatedTokens, denominator);
|
|
96
|
+
const drillHint = segment.items && segment.items.length > 0 ? theme.fg("dim", ` (${segment.items.length} items — enter to expand)`) : "";
|
|
97
|
+
lines.push(truncateToWidth(`${cursor} ${swatch} ${segment.estimatedTokens.toString().padStart(6)} tok ${percent.padStart(5)} ${title}${drillHint}`, width, ""));
|
|
98
|
+
});
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push(theme.fg("dim", "↑↓ select · enter expand · esc close"));
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private renderDrillDown(width: number): string[] {
|
|
105
|
+
const theme = this.theme;
|
|
106
|
+
const segment = this.drillDown!;
|
|
107
|
+
const items: ContextSegmentItem[] = segment.items ?? [];
|
|
108
|
+
const lines: string[] = [truncateToWidth(theme.fg("muted", `${segment.label} — largest first`), width, "")];
|
|
109
|
+
if (items.length === 0) {
|
|
110
|
+
lines.push(theme.fg("dim", " (nothing to break down further)"));
|
|
111
|
+
} else {
|
|
112
|
+
const start = Math.max(0, Math.min(this.drillIndex - Math.floor(DRILLDOWN_VISIBLE_ROWS / 2), items.length - DRILLDOWN_VISIBLE_ROWS));
|
|
113
|
+
const end = Math.min(start + DRILLDOWN_VISIBLE_ROWS, items.length);
|
|
114
|
+
for (let index = start; index < end; index++) {
|
|
115
|
+
const item = items[index]!;
|
|
116
|
+
const selected = index === this.drillIndex;
|
|
117
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
118
|
+
const title = selected ? theme.bold(item.label) : item.label;
|
|
119
|
+
lines.push(truncateToWidth(`${cursor} ${item.estimatedTokens.toString().padStart(6)} tok ${title}`, width, ""));
|
|
120
|
+
}
|
|
121
|
+
lines.push(theme.fg("muted", ` ${this.drillIndex + 1}/${items.length}`));
|
|
122
|
+
}
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(theme.fg("dim", "↑↓ scroll · esc back"));
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
handleInput(data: string): void {
|
|
129
|
+
if (this.drillDown) {
|
|
130
|
+
const items = this.drillDown.items ?? [];
|
|
131
|
+
if (matchesKey(data, "escape")) this.drillDown = null;
|
|
132
|
+
else if (matchesKey(data, "up")) this.drillIndex = Math.max(0, this.drillIndex - 1);
|
|
133
|
+
else if (matchesKey(data, "down")) this.drillIndex = Math.min(Math.max(0, items.length - 1), this.drillIndex + 1);
|
|
134
|
+
else return;
|
|
135
|
+
} else {
|
|
136
|
+
if (matchesKey(data, "escape")) { this.close(); return; }
|
|
137
|
+
if (matchesKey(data, "up")) this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
138
|
+
else if (matchesKey(data, "down")) this.selectedIndex = Math.min(this.breakdown.segments.length - 1, this.selectedIndex + 1);
|
|
139
|
+
else if (matchesKey(data, "enter")) {
|
|
140
|
+
const segment = this.breakdown.segments[this.selectedIndex];
|
|
141
|
+
if (segment?.items && segment.items.length > 0) {
|
|
142
|
+
this.drillDown = segment;
|
|
143
|
+
this.drillIndex = 0;
|
|
144
|
+
}
|
|
145
|
+
} else return;
|
|
146
|
+
}
|
|
147
|
+
this.tui.requestRender();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Non-interactive fallback (print mode, RPC, etc.) reuses the existing plain-text report shape. */
|
|
152
|
+
function fallbackReport(breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): string {
|
|
153
|
+
const skillsSegment = breakdown.segments.find((segment) => segment.key === "skills");
|
|
154
|
+
const other = breakdown.segments.find((segment) => segment.key === "other")!;
|
|
155
|
+
const totalLine = breakdown.totalTokens !== null
|
|
156
|
+
? `Real usage: ${breakdown.totalTokens} tokens${breakdown.effectiveBudget !== null ? ` / ${breakdown.effectiveBudget} usable budget (${percentOf(breakdown.totalTokens, breakdown.effectiveBudget)})` : ""}`
|
|
157
|
+
: "Real usage: not yet reported";
|
|
158
|
+
return [
|
|
159
|
+
totalLine,
|
|
160
|
+
`Everything else (base prompt, message history, tool defs): ~${other.estimatedTokens} tokens`,
|
|
161
|
+
"",
|
|
162
|
+
formatContextBudgetReport({ rules: ruleBudget, skills: { entries: [], totalCharacters: 0, totalEstimatedTokens: skillsSegment?.estimatedTokens ?? 0, scannedDirectories: [] }, totalEstimatedTokens: ruleBudget.totalEstimatedTokens }),
|
|
163
|
+
].join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function showContextView(ctx: ExtensionCommandContext, breakdown: ContextBreakdown, ruleBudget: ContextBudget["rules"]): Promise<void> {
|
|
167
|
+
if (ctx.mode !== "tui") {
|
|
168
|
+
ctx.ui.notify(fallbackReport(breakdown, ruleBudget), "info");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new ContextViewport(tui, theme, breakdown, done));
|
|
172
|
+
}
|
package/extension/src/docs.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { DOC_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
4
5
|
import { callService } from "./service-client.ts";
|
|
5
6
|
|
|
6
|
-
const DOC_GLYPHS: Record<string, string> = { draft: "○", active: "●", archived: "■" };
|
|
7
7
|
const DOC_ACTIONS: Record<string, string[]> = {
|
|
8
8
|
draft: ["Activate", "Archive"],
|
|
9
9
|
active: ["Archive"],
|
|
@@ -11,8 +11,9 @@ const DOC_ACTIONS: Record<string, string[]> = {
|
|
|
11
11
|
};
|
|
12
12
|
const DOC_RELATIONS = ["references", "documents", "supersedes", "relates_to", "contains", "part_of"];
|
|
13
13
|
|
|
14
|
-
export function documentRowMeta(document: Artifact): string {
|
|
15
|
-
|
|
14
|
+
export function documentRowMeta(document: Artifact, theme: Theme): string {
|
|
15
|
+
const subtype = document.subtype ? theme.fg("accent", document.subtype) : "";
|
|
16
|
+
return [subtype, document.labels.join(", ")].filter(Boolean).join(" · ");
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
@@ -21,7 +22,7 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
21
22
|
title: "Documents",
|
|
22
23
|
listOperation: "docs.list",
|
|
23
24
|
statusOrder: ["draft", "active", "archived"],
|
|
24
|
-
|
|
25
|
+
presentation: DOC_STATUS_PRESENTATION,
|
|
25
26
|
rowMeta: documentRowMeta,
|
|
26
27
|
actions: (document) => ["Show details", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
|
|
27
28
|
handleAction: async (choice, document, commandCtx) => {
|