@mrclrchtr/supi-context 0.1.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/src/format.ts ADDED
@@ -0,0 +1,280 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { ContextAnalysis } from "./analysis.ts";
3
+ import { formatTokens, pluralize } from "./utils.ts";
4
+
5
+ const GRID_COLS = 20;
6
+ const GRID_ROWS = 5;
7
+ const GRID_BLOCKS = GRID_COLS * GRID_ROWS;
8
+
9
+ type CategoryKey =
10
+ | "systemPrompt"
11
+ | "userMessages"
12
+ | "assistantMessages"
13
+ | "toolCalls"
14
+ | "toolResults"
15
+ | "other";
16
+
17
+ const CATEGORY_ORDER: CategoryKey[] = [
18
+ "systemPrompt",
19
+ "userMessages",
20
+ "assistantMessages",
21
+ "toolCalls",
22
+ "toolResults",
23
+ "other",
24
+ ];
25
+
26
+ const CATEGORY_LABELS: Record<CategoryKey, string> = {
27
+ systemPrompt: "System prompt",
28
+ userMessages: "User messages",
29
+ assistantMessages: "Assistant messages",
30
+ toolCalls: "Tool calls",
31
+ toolResults: "Tool results",
32
+ other: "Other",
33
+ };
34
+
35
+ const CATEGORY_COLORS: Record<CategoryKey, Parameters<Theme["fg"]>["0"]> = {
36
+ systemPrompt: "accent",
37
+ userMessages: "success",
38
+ assistantMessages: "warning",
39
+ toolCalls: "error",
40
+ toolResults: "dim",
41
+ other: "muted",
42
+ };
43
+
44
+ function pct(value: number, total: number): string {
45
+ if (total <= 0) return "0.0%";
46
+ return `${((value / total) * 100).toFixed(1)}%`;
47
+ }
48
+
49
+ function padLeft(text: string, width: number): string {
50
+ return text.padStart(width, " ");
51
+ }
52
+
53
+ function padRight(text: string, width: number): string {
54
+ return text.padEnd(width, " ");
55
+ }
56
+
57
+ function allocateGridBlocks(segments: number[]): number[] {
58
+ const total = segments.reduce((sum, value) => sum + value, 0);
59
+ if (total <= 0) {
60
+ return segments.map(() => 0);
61
+ }
62
+
63
+ const exact = segments.map((value) => (value / total) * GRID_BLOCKS);
64
+ const counts = exact.map((value) => Math.floor(value));
65
+ const remaining = GRID_BLOCKS - counts.reduce((sum, value) => sum + value, 0);
66
+
67
+ const byRemainder = exact
68
+ .map((value, index) => ({ index, remainder: value - counts[index] }))
69
+ .sort((a, b) => b.remainder - a.remainder);
70
+
71
+ for (let i = 0; i < remaining; i++) {
72
+ counts[byRemainder[i]?.index ?? 0] += 1;
73
+ }
74
+
75
+ return counts;
76
+ }
77
+
78
+ function renderGrid(analysis: ContextAnalysis, theme: Theme): string[] {
79
+ const { contextWindow, categories } = analysis;
80
+ if (contextWindow <= 0) {
81
+ return [theme.fg("dim", "No model selected — grid unavailable")];
82
+ }
83
+
84
+ const segments = [
85
+ ...CATEGORY_ORDER.map((key) => ({
86
+ color: CATEGORY_COLORS[key],
87
+ tokens: categories[key],
88
+ block: "█",
89
+ })),
90
+ { color: "dim" as Parameters<Theme["fg"]>["0"], tokens: categories.freeSpace, block: "░" },
91
+ {
92
+ color: "warning" as Parameters<Theme["fg"]>["0"],
93
+ tokens: categories.autocompactBuffer,
94
+ block: "░",
95
+ },
96
+ ];
97
+
98
+ const counts = allocateGridBlocks(segments.map((segment) => segment.tokens));
99
+ const blocks: string[] = [];
100
+ for (const [index, segment] of segments.entries()) {
101
+ for (let i = 0; i < (counts[index] ?? 0); i++) {
102
+ blocks.push(theme.fg(segment.color, segment.block));
103
+ }
104
+ }
105
+
106
+ const gridLines: string[] = [];
107
+ for (let row = 0; row < GRID_ROWS; row++) {
108
+ const start = row * GRID_COLS;
109
+ const line = blocks.slice(start, start + GRID_COLS).join("");
110
+ gridLines.push(line);
111
+ }
112
+
113
+ // Model info on the right
114
+ const infoLines = [
115
+ theme.fg("text", analysis.modelName),
116
+ theme.fg("dim", `${formatTokens(contextWindow)} context window`),
117
+ theme.fg(
118
+ "text",
119
+ `${formatTokens(analysis.totalTokens ?? 0)} used (${pct(analysis.totalTokens ?? 0, contextWindow)})`,
120
+ ),
121
+ ];
122
+
123
+ if (analysis.approximationNote) {
124
+ infoLines.push(theme.fg("warning", analysis.approximationNote));
125
+ }
126
+
127
+ const combined: string[] = [];
128
+ for (let i = 0; i < GRID_ROWS; i++) {
129
+ const left = gridLines[i] ?? "";
130
+ const right = infoLines[i] ?? "";
131
+ combined.push(`${left} ${right}`);
132
+ }
133
+ // Append any remaining info lines if grid is shorter (shouldn't happen with 5 rows)
134
+ for (let i = GRID_ROWS; i < infoLines.length; i++) {
135
+ combined.push(`${" ".repeat(GRID_COLS + 2)}${infoLines[i]}`);
136
+ }
137
+
138
+ return combined;
139
+ }
140
+
141
+ function renderCategoryBreakdown(analysis: ContextAnalysis, theme: Theme): string[] {
142
+ const lines: string[] = [];
143
+ lines.push(theme.fg("accent", "Usage by category"));
144
+
145
+ const { contextWindow, categories } = analysis;
146
+ const allCategories: Array<{
147
+ key: CategoryKey | "autocompactBuffer" | "freeSpace";
148
+ label: string;
149
+ color: Parameters<Theme["fg"]>["0"];
150
+ tokens: number;
151
+ }> = [
152
+ ...CATEGORY_ORDER.map((key) => ({
153
+ key,
154
+ label: CATEGORY_LABELS[key],
155
+ color: CATEGORY_COLORS[key],
156
+ tokens: categories[key],
157
+ })),
158
+ {
159
+ key: "autocompactBuffer",
160
+ label: "Autocompact buffer",
161
+ color: "warning" as Parameters<Theme["fg"]>["0"],
162
+ tokens: categories.autocompactBuffer,
163
+ },
164
+ {
165
+ key: "freeSpace",
166
+ label: "Free space",
167
+ color: "dim" as Parameters<Theme["fg"]>["0"],
168
+ tokens: categories.freeSpace,
169
+ },
170
+ ];
171
+
172
+ for (const cat of allCategories) {
173
+ if (cat.tokens <= 0 && cat.key !== "freeSpace") continue;
174
+ const label = padRight(cat.label, 20);
175
+ const tokens = padLeft(formatTokens(cat.tokens), 8);
176
+ const percentage = padLeft(pct(cat.tokens, contextWindow), 7);
177
+ lines.push(`${theme.fg(cat.color, "●")} ${label} ${tokens} ${percentage}`);
178
+ }
179
+
180
+ return lines;
181
+ }
182
+
183
+ function renderContextFilesSection(analysis: ContextAnalysis, theme: Theme): string[] {
184
+ const files = analysis.systemPromptBreakdown.contextFiles;
185
+ if (files.length === 0) return [];
186
+
187
+ const lines: string[] = [];
188
+ lines.push("");
189
+ lines.push(theme.fg("accent", "Context Files (system prompt)"));
190
+ for (const f of files) {
191
+ lines.push(` ${theme.fg("text", f.path)} ${theme.fg("dim", formatTokens(f.tokens))}`);
192
+ }
193
+ return lines;
194
+ }
195
+
196
+ function renderInjectedFilesSection(analysis: ContextAnalysis, theme: Theme): string[] {
197
+ const files = analysis.injectedFiles;
198
+ if (files.length === 0) return [];
199
+
200
+ const lines: string[] = [];
201
+ lines.push("");
202
+ lines.push(theme.fg("accent", "Context Files (injected · supi-claude-md)"));
203
+ for (const f of files) {
204
+ lines.push(
205
+ ` ${theme.fg("text", f.file)} ${theme.fg("dim", formatTokens(f.tokens))} ${theme.fg("dim", `turn ${f.turn}`)}`,
206
+ );
207
+ }
208
+ return lines;
209
+ }
210
+
211
+ function renderSkillsSection(analysis: ContextAnalysis, theme: Theme): string[] {
212
+ const lines: string[] = [];
213
+ lines.push("");
214
+ lines.push(theme.fg("accent", `Skills (${analysis.skills.length})`));
215
+
216
+ if (analysis.skills.length === 0) {
217
+ lines.push(theme.fg("dim", " Send a message to see skill details"));
218
+ return lines;
219
+ }
220
+
221
+ for (const s of analysis.skills) {
222
+ lines.push(` ${theme.fg("text", s.name)} ${theme.fg("dim", formatTokens(s.tokens))}`);
223
+ }
224
+ return lines;
225
+ }
226
+
227
+ function renderGuidelinesAndTools(analysis: ContextAnalysis, theme: Theme): string[] {
228
+ const lines: string[] = [];
229
+ lines.push("");
230
+ lines.push(
231
+ `${theme.fg("text", "Guidelines")} ${theme.fg("dim", formatTokens(analysis.guidelines))}`,
232
+ );
233
+ lines.push(
234
+ `${theme.fg("text", `Tool Definitions (${analysis.toolDefinitions.count} active)`)} ${theme.fg("dim", formatTokens(analysis.toolDefinitions.tokens))}`,
235
+ );
236
+ return lines;
237
+ }
238
+
239
+ function renderCompactionNote(analysis: ContextAnalysis, theme: Theme): string[] {
240
+ if (!analysis.compaction) return [];
241
+ return [
242
+ "",
243
+ theme.fg(
244
+ "dim",
245
+ `↳ ${pluralize(analysis.compaction.summarizedTurns, "older turn", "older turns")} summarized (compaction)`,
246
+ ),
247
+ ];
248
+ }
249
+
250
+ function renderProviderSections(analysis: ContextAnalysis, theme: Theme): string[] {
251
+ if (analysis.providerSections.length === 0) return [];
252
+
253
+ const lines: string[] = [];
254
+ for (const section of analysis.providerSections) {
255
+ lines.push("");
256
+ lines.push(theme.fg("accent", section.label));
257
+ for (const [key, value] of Object.entries(section.data)) {
258
+ lines.push(` ${theme.fg("text", key)}: ${theme.fg("dim", String(value))}`);
259
+ }
260
+ }
261
+ return lines;
262
+ }
263
+
264
+ export function formatContextReport(analysis: ContextAnalysis, theme: Theme): string[] {
265
+ const lines: string[] = [];
266
+
267
+ lines.push(theme.fg("accent", "◆ Context Usage"));
268
+ lines.push("");
269
+ lines.push(...renderGrid(analysis, theme));
270
+ lines.push("");
271
+ lines.push(...renderCategoryBreakdown(analysis, theme));
272
+ lines.push(...renderContextFilesSection(analysis, theme));
273
+ lines.push(...renderInjectedFilesSection(analysis, theme));
274
+ lines.push(...renderSkillsSection(analysis, theme));
275
+ lines.push(...renderGuidelinesAndTools(analysis, theme));
276
+ lines.push(...renderCompactionNote(analysis, theme));
277
+ lines.push(...renderProviderSections(analysis, theme));
278
+
279
+ return lines;
280
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./context.ts";
@@ -0,0 +1,131 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type {
3
+ BuildSystemPromptOptions,
4
+ ExtensionCommandContext,
5
+ Skill,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ function unescapeXml(text: string): string {
9
+ return text
10
+ .replace(/&lt;/g, "<")
11
+ .replace(/&gt;/g, ">")
12
+ .replace(/&quot;/g, '"')
13
+ .replace(/&apos;/g, "'")
14
+ .replace(/&amp;/g, "&");
15
+ }
16
+
17
+ function isLikelyContextFileHeading(value: string): boolean {
18
+ return (
19
+ value.includes("/") ||
20
+ value.includes("\\") ||
21
+ value.startsWith("~") ||
22
+ /(?:^|\b)(?:AGENTS|CLAUDE|SYSTEM|APPEND_SYSTEM)\.md$/i.test(value)
23
+ );
24
+ }
25
+
26
+ function deriveSkills(systemPrompt: string): Skill[] {
27
+ const skills: Skill[] = [];
28
+ const skillRegex =
29
+ /<skill>\s*<name>([\s\S]*?)<\/name>\s*<description>([\s\S]*?)<\/description>\s*<location>([\s\S]*?)<\/location>\s*<\/skill>/g;
30
+
31
+ for (const match of systemPrompt.matchAll(skillRegex)) {
32
+ skills.push({
33
+ name: unescapeXml(match[1].trim()),
34
+ description: unescapeXml(match[2].trim()),
35
+ filePath: unescapeXml(match[3].trim()),
36
+ } as Skill);
37
+ }
38
+
39
+ return skills;
40
+ }
41
+
42
+ function sliceProjectContextSection(systemPrompt: string): string | null {
43
+ const sectionMarker = "\n\n# Project Context\n\n";
44
+ const projectContextStart = systemPrompt.indexOf(sectionMarker);
45
+ if (projectContextStart < 0) {
46
+ return null;
47
+ }
48
+
49
+ let projectContext = systemPrompt.slice(projectContextStart + sectionMarker.length);
50
+ const intro = "Project-specific instructions and guidelines:\n\n";
51
+ if (projectContext.startsWith(intro)) {
52
+ projectContext = projectContext.slice(intro.length);
53
+ }
54
+
55
+ const skillsMarker =
56
+ "\n\nThe following skills provide specialized instructions for specific tasks.";
57
+ const skillsIndex = projectContext.indexOf(skillsMarker);
58
+ if (skillsIndex >= 0) {
59
+ projectContext = projectContext.slice(0, skillsIndex);
60
+ }
61
+
62
+ const dateIndex = projectContext.indexOf("\nCurrent date: ");
63
+ if (dateIndex >= 0) {
64
+ projectContext = projectContext.slice(0, dateIndex);
65
+ }
66
+
67
+ return projectContext;
68
+ }
69
+
70
+ function deriveContextFiles(systemPrompt: string): Array<{ path: string; content: string }> {
71
+ const projectContext = sliceProjectContextSection(systemPrompt);
72
+ if (!projectContext) {
73
+ return [];
74
+ }
75
+
76
+ const contextFiles: Array<{ path: string; content: string }> = [];
77
+ const headingRegex = /^##\s+(.+)$/gm;
78
+ for (const match of projectContext.matchAll(headingRegex)) {
79
+ const filePath = match[1].trim();
80
+ if (!isLikelyContextFileHeading(filePath) || !existsSync(filePath)) {
81
+ continue;
82
+ }
83
+ contextFiles.push({ path: filePath, content: readFileSync(filePath, "utf-8") });
84
+ }
85
+
86
+ return contextFiles;
87
+ }
88
+
89
+ export function extractGuidelinesSection(systemPrompt: string): string | null {
90
+ const marker = "\nGuidelines:\n";
91
+ const start = systemPrompt.indexOf(marker);
92
+ if (start < 0) {
93
+ return null;
94
+ }
95
+
96
+ const afterStart = start + marker.length;
97
+ const endMarkers = ["\n\nPi documentation", "\n\n# Project Context", "\nCurrent date: "];
98
+ let end = systemPrompt.length;
99
+ for (const endMarker of endMarkers) {
100
+ const index = systemPrompt.indexOf(endMarker, afterStart);
101
+ if (index >= 0) {
102
+ end = Math.min(end, index);
103
+ }
104
+ }
105
+
106
+ const section = systemPrompt.slice(afterStart, end).trim();
107
+ return section.length > 0 ? section : null;
108
+ }
109
+
110
+ export function deriveOptionsFromSystemPrompt(
111
+ ctx: ExtensionCommandContext,
112
+ cachedOptions: BuildSystemPromptOptions | undefined,
113
+ ): BuildSystemPromptOptions | undefined {
114
+ if (cachedOptions) {
115
+ return cachedOptions;
116
+ }
117
+
118
+ const systemPrompt = ctx.getSystemPrompt();
119
+ const contextFiles = deriveContextFiles(systemPrompt);
120
+ const skills = deriveSkills(systemPrompt);
121
+
122
+ if (contextFiles.length === 0 && skills.length === 0) {
123
+ return undefined;
124
+ }
125
+
126
+ return {
127
+ cwd: ctx.cwd,
128
+ contextFiles,
129
+ skills,
130
+ };
131
+ }
@@ -0,0 +1,26 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
+ import type { ContextAnalysis } from "./analysis.ts";
4
+ import { formatContextReport } from "./format.ts";
5
+
6
+ export function registerContextRenderer(pi: ExtensionAPI): void {
7
+ pi.registerMessageRenderer("supi-context", (message, _renderOptions, theme) => {
8
+ const analysis = (message.details as { analysis?: ContextAnalysis } | undefined)?.analysis;
9
+ if (!analysis) {
10
+ return new Text(theme.fg("dim", "No context analysis data"), 1, 0);
11
+ }
12
+
13
+ const lines = formatContextReport(analysis, theme);
14
+ const container = new Container();
15
+
16
+ for (const line of lines) {
17
+ if (line === "") {
18
+ container.addChild(new Spacer(1));
19
+ } else {
20
+ container.addChild(new Text(line, 0, 0));
21
+ }
22
+ }
23
+
24
+ return container;
25
+ });
26
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Format a token count for display (e.g. 45231 → "45.2k").
3
+ */
4
+ export function formatTokens(tokens: number): string {
5
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
6
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
7
+ return `${tokens}`;
8
+ }
9
+
10
+ /**
11
+ * Simple pluralize helper.
12
+ */
13
+ export function pluralize(count: number, singular: string, plural: string): string {
14
+ return count === 1 ? `${count} ${singular}` : `${count} ${plural}`;
15
+ }