@mrclrchtr/supi-context 1.3.0 → 1.4.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/analysis.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // biome-ignore lint/nursery/noExcessiveLinesPerFile: analysis file is inherently large
2
+ import { dirname, resolve } from "node:path";
2
3
  import {
3
4
  type BuildSystemPromptOptions,
4
5
  buildSessionContext,
@@ -10,6 +11,7 @@ import {
10
11
  SettingsManager,
11
12
  } from "@earendil-works/pi-coding-agent";
12
13
  import { getRegisteredContextProviders } from "@mrclrchtr/supi-core/api";
14
+
13
15
  import { deriveOptionsFromSystemPrompt, extractGuidelinesSection } from "./prompt-inference.ts";
14
16
 
15
17
  type AgentMessage = Parameters<typeof estimateTokens>[0];
@@ -26,12 +28,15 @@ export interface CategoryTokens {
26
28
  export interface ContextFileInfo {
27
29
  path: string;
28
30
  tokens: number;
31
+ lines: number;
32
+ origin: "global" | "project";
29
33
  }
30
34
 
31
35
  export interface InjectedFileInfo {
32
36
  file: string;
33
37
  turn: number;
34
38
  tokens: number;
39
+ lines: number;
35
40
  }
36
41
 
37
42
  export interface SkillInfo {
@@ -39,6 +44,25 @@ export interface SkillInfo {
39
44
  tokens: number;
40
45
  }
41
46
 
47
+ export interface ToolInfo {
48
+ name: string;
49
+ description: string;
50
+ tokens: number;
51
+ }
52
+
53
+ /** Per-tool breakdown of the one-line tool snippet shown in "Available tools". */
54
+ export interface ToolSnippetInfo {
55
+ name: string;
56
+ tokens: number;
57
+ }
58
+
59
+ /** Source-attributed guideline info. */
60
+ export interface GuidelineSourceInfo {
61
+ source: string; // "default" | tool name | "other"
62
+ tokens: number;
63
+ bulletCount: number;
64
+ }
65
+
42
66
  export interface ContextProviderSection {
43
67
  id: string;
44
68
  label: string;
@@ -51,22 +75,29 @@ export interface ContextAnalysis {
51
75
  totalTokens: number | null;
52
76
  scaled: boolean;
53
77
  approximationNote: string | null;
78
+ full: boolean;
54
79
  categories: CategoryTokens & {
55
80
  autocompactBuffer: number;
56
81
  freeSpace: number;
57
82
  };
58
83
  systemPromptBreakdown: {
59
84
  base: number;
85
+ instructionFiles: ContextFileInfo[];
60
86
  contextFiles: ContextFileInfo[];
61
87
  skills: SkillInfo[];
62
88
  guidelines: number;
63
89
  toolSnippets: number;
90
+ toolSnippetDetails: ToolSnippetInfo[];
91
+ guidelineSources: GuidelineSourceInfo[];
64
92
  appendText: number;
65
93
  };
66
94
  injectedFiles: InjectedFileInfo[];
67
95
  skills: SkillInfo[];
68
96
  guidelines: number;
69
- toolDefinitions: { count: number; tokens: number };
97
+ guidelineBullets: string[];
98
+ guidelineSources: GuidelineSourceInfo[];
99
+ toolSnippetDetails: ToolSnippetInfo[];
100
+ toolDefinitions: { count: number; tokens: number; tools: ToolInfo[] };
70
101
  compaction: { summarizedTurns: number } | null;
71
102
  providerSections: ContextProviderSection[];
72
103
  }
@@ -239,16 +270,50 @@ function collectProviderData(): ContextProviderSection[] {
239
270
  return sections;
240
271
  }
241
272
 
273
+ const INSTRUCTION_FILE_PATTERN = /^(AGENTS|CLAUDE|\.claude\.local)\.md$/i;
274
+
275
+ function isInstructionFile(path: string): boolean {
276
+ const basename = path.replace(/\\/g, "/").split("/").pop() ?? "";
277
+ return INSTRUCTION_FILE_PATTERN.test(basename);
278
+ }
279
+
280
+ function determineOrigin(filePath: string, cwd: string): "global" | "project" {
281
+ const resolvedPath = resolve(cwd, filePath);
282
+ const fileDir = dirname(resolvedPath);
283
+ let current = cwd;
284
+ const root = resolve("/");
285
+ while (true) {
286
+ if (fileDir === current) return "project";
287
+ if (current === root) break;
288
+ const parent = resolve(current, "..");
289
+ if (parent === current) break;
290
+ current = parent;
291
+ }
292
+ return "global";
293
+ }
294
+
242
295
  function computeContextFiles(
243
296
  promptOptions: BuildSystemPromptOptions | undefined,
244
- ): ContextFileInfo[] {
245
- const files: ContextFileInfo[] = [];
297
+ cwd: string,
298
+ ): { contextFiles: ContextFileInfo[]; instructionFiles: ContextFileInfo[] } {
299
+ const contextFiles: ContextFileInfo[] = [];
300
+ const instructionFiles: ContextFileInfo[] = [];
246
301
  if (promptOptions?.contextFiles) {
247
302
  for (const cf of promptOptions.contextFiles) {
248
- files.push({ path: cf.path, tokens: estimateTextTokens(cf.content) });
303
+ const info: ContextFileInfo = {
304
+ path: cf.path,
305
+ tokens: estimateTextTokens(cf.content),
306
+ lines: cf.content.split("\n").length,
307
+ origin: determineOrigin(cf.path, cwd),
308
+ };
309
+ if (isInstructionFile(cf.path)) {
310
+ instructionFiles.push(info);
311
+ } else {
312
+ contextFiles.push(info);
313
+ }
249
314
  }
250
315
  }
251
- return files;
316
+ return { contextFiles, instructionFiles };
252
317
  }
253
318
 
254
319
  function computeSkills(promptOptions: BuildSystemPromptOptions | undefined): SkillInfo[] {
@@ -262,12 +327,118 @@ function computeSkills(promptOptions: BuildSystemPromptOptions | undefined): Ski
262
327
  return skills;
263
328
  }
264
329
 
330
+ function extractGuidelineBullets(guidelinesText: string | null): string[] {
331
+ if (!guidelinesText) return [];
332
+ return guidelinesText
333
+ .split("\n")
334
+ .map((line) => line.trim())
335
+ .filter((line) => line.startsWith("- "))
336
+ .map((line) => line.slice(2).trim());
337
+ }
338
+
339
+ /**
340
+ * Known texts of PI built-in default guidelines.
341
+ * These are generated by buildSystemPrompt() in the system prompt builder.
342
+ */
343
+ const DEFAULT_GUIDELINE_TEXTS = new Set([
344
+ "Use bash for file operations like ls, rg, find",
345
+ "Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
346
+ "Be concise in your responses",
347
+ "Show file paths clearly when working with files",
348
+ ]);
349
+
350
+ /**
351
+ * Known promptGuidelines from PI's built-in tools.
352
+ * These are hardcoded in the tool definition modules (read, write, edit).
353
+ */
354
+ const BUILTIN_TOOL_GUIDELINES: Record<string, string[]> = {
355
+ read: ["Use read to examine files instead of cat or sed."],
356
+ write: ["Use write only for new files or complete rewrites."],
357
+ edit: [
358
+ "Use edit for precise changes (edits[].oldText must match exactly)",
359
+ "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
360
+ "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
361
+ "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
362
+ ],
363
+ };
364
+
365
+ /**
366
+ * Build a reverse map from guideline text → tool name so we can look up
367
+ * which built-in tool (if any) contributed each guideline bullet.
368
+ */
369
+ function buildGuidelineToToolMap(): Map<string, string> {
370
+ const map = new Map<string, string>();
371
+ for (const [tool, guidelines] of Object.entries(BUILTIN_TOOL_GUIDELINES)) {
372
+ for (const guideline of guidelines) {
373
+ map.set(guideline, tool);
374
+ }
375
+ }
376
+ return map;
377
+ }
378
+
379
+ const GUIDELINE_TO_TOOL = buildGuidelineToToolMap();
380
+
381
+ /**
382
+ * Given guideline bullets extracted from the system prompt, classify each bullet
383
+ * by source and compute per-source token counts.
384
+ */
385
+ function classifyGuidelines(bullets: string[], _activeToolNames: string[]): GuidelineSourceInfo[] {
386
+ const sources = new Map<string, { chars: number; count: number }>();
387
+
388
+ for (const bullet of bullets) {
389
+ let source: string;
390
+ if (DEFAULT_GUIDELINE_TEXTS.has(bullet)) {
391
+ source = "default";
392
+ } else {
393
+ const toolName = GUIDELINE_TO_TOOL.get(bullet);
394
+ source = toolName ?? "other";
395
+ }
396
+
397
+ const entry = sources.get(source) ?? { chars: 0, count: 0 };
398
+ entry.chars += bullet.length;
399
+ entry.count += 1;
400
+ sources.set(source, entry);
401
+ }
402
+
403
+ return Array.from(sources.entries())
404
+ .map(([source, { chars, count }]) => ({
405
+ source,
406
+ tokens: Math.ceil(chars / 4),
407
+ bulletCount: count,
408
+ }))
409
+ .sort((a, b) => {
410
+ // Default first, then tool sources (alphabetical), then "other" last
411
+ if (a.source === "default") return -1;
412
+ if (b.source === "default") return 1;
413
+ if (a.source === "other") return 1;
414
+ if (b.source === "other") return -1;
415
+ return a.source.localeCompare(b.source);
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Build per-tool snippet breakdown from the toolSnippets record.
421
+ */
422
+ function buildToolSnippetDetails(
423
+ toolSnippets: Record<string, string> | undefined,
424
+ ): ToolSnippetInfo[] {
425
+ if (!toolSnippets) return [];
426
+
427
+ return Object.entries(toolSnippets)
428
+ .map(([name, snippet]) => ({
429
+ name,
430
+ tokens: estimateTextTokens(snippet),
431
+ }))
432
+ .sort((a, b) => b.tokens - a.tokens);
433
+ }
434
+
265
435
  function computeSystemPromptBreakdown(
266
436
  promptOptions: BuildSystemPromptOptions | undefined,
267
437
  systemPromptText: string,
268
438
  systemPromptTokens: number,
439
+ cwd: string,
269
440
  ): ContextAnalysis["systemPromptBreakdown"] {
270
- const contextFiles = computeContextFiles(promptOptions);
441
+ const { contextFiles, instructionFiles } = computeContextFiles(promptOptions, cwd);
271
442
  const skills = computeSkills(promptOptions);
272
443
 
273
444
  const skillsTotal = skills.reduce((s, c) => s + c.tokens, 0);
@@ -277,9 +448,13 @@ function computeSystemPromptBreakdown(
277
448
  : promptOptions?.promptGuidelines
278
449
  ? estimateTextTokens(promptOptions.promptGuidelines.join("\n"))
279
450
  : 0;
280
- const toolSnippets = promptOptions?.toolSnippets
451
+ const toolSnippetsTotal = promptOptions?.toolSnippets
281
452
  ? estimateTextTokens(Object.values(promptOptions.toolSnippets).join("\n"))
282
453
  : 0;
454
+ const toolSnippetDetails = buildToolSnippetDetails(promptOptions?.toolSnippets);
455
+ const guidelineBullets = extractGuidelineBullets(inferredGuidelines);
456
+ const activeToolNames = promptOptions?.selectedTools ?? [];
457
+ const guidelineSources = classifyGuidelines(guidelineBullets, activeToolNames);
283
458
  const appendText = promptOptions?.appendSystemPrompt
284
459
  ? estimateTextTokens(promptOptions.appendSystemPrompt)
285
460
  : 0;
@@ -289,31 +464,47 @@ function computeSystemPromptBreakdown(
289
464
 
290
465
  const knownSubtotal =
291
466
  contextFiles.reduce((s, c) => s + c.tokens, 0) +
467
+ instructionFiles.reduce((s, c) => s + c.tokens, 0) +
292
468
  skillsTotal +
293
469
  guidelines +
294
- toolSnippets +
470
+ toolSnippetsTotal +
295
471
  appendText +
296
472
  customTokens;
297
473
 
298
474
  const base = Math.max(0, systemPromptTokens - knownSubtotal);
299
475
 
300
- return { base, contextFiles, skills, guidelines, toolSnippets, appendText };
476
+ return {
477
+ base,
478
+ instructionFiles,
479
+ contextFiles,
480
+ skills,
481
+ guidelines,
482
+ toolSnippets: toolSnippetsTotal,
483
+ toolSnippetDetails,
484
+ guidelineSources,
485
+ appendText,
486
+ };
301
487
  }
302
488
 
303
- function computeToolDefinitions(pi: ExtensionAPI): { count: number; tokens: number } {
489
+ function computeToolDefinitions(pi: ExtensionAPI): {
490
+ count: number;
491
+ tokens: number;
492
+ tools: ToolInfo[];
493
+ } {
304
494
  const activeToolNames = new Set(pi.getActiveTools());
305
495
  const allTools = pi.getAllTools();
306
496
  const activeTools = allTools.filter((t) => activeToolNames.has(t.name));
497
+ const tools = activeTools.map((t) => ({
498
+ name: t.name,
499
+ description: t.description,
500
+ tokens: estimateTextTokens(
501
+ JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters }),
502
+ ),
503
+ }));
307
504
  return {
308
505
  count: activeTools.length,
309
- tokens: activeTools.reduce(
310
- (sum, t) =>
311
- sum +
312
- estimateTextTokens(
313
- JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters }),
314
- ),
315
- 0,
316
- ),
506
+ tokens: tools.reduce((sum, t) => sum + t.tokens, 0),
507
+ tools,
317
508
  };
318
509
  }
319
510
 
@@ -353,7 +544,12 @@ export function extractInjectedContextFiles(messages: AgentMessage[]): InjectedF
353
544
  const innerContent = match[3];
354
545
  const key = `${file}::${turn}`;
355
546
  if (!seen.has(key)) {
356
- seen.set(key, { file, turn, tokens: estimateTextTokens(innerContent) });
547
+ seen.set(key, {
548
+ file,
549
+ turn,
550
+ tokens: estimateTextTokens(innerContent),
551
+ lines: innerContent.split("\n").length,
552
+ });
357
553
  }
358
554
  match = regex.exec(content);
359
555
  }
@@ -366,6 +562,7 @@ export function analyzeContext(
366
562
  ctx: ExtensionCommandContext,
367
563
  pi: ExtensionAPI,
368
564
  cachedOptions: BuildSystemPromptOptions | undefined,
565
+ full = false,
369
566
  ): ContextAnalysis {
370
567
  const branch = ctx.sessionManager.getBranch();
371
568
  const apiView = buildSessionContext(branch);
@@ -403,17 +600,21 @@ export function analyzeContext(
403
600
  promptOptions,
404
601
  systemPromptText,
405
602
  scaling.categories.systemPrompt,
603
+ ctx.cwd,
406
604
  );
407
605
  const injectedFiles = extractInjectedContextFiles(apiView.messages);
408
606
  const toolDefinitions = computeToolDefinitions(pi);
409
607
  const compaction = detectCompaction(branch);
410
608
 
609
+ const guidelineBullets = extractGuidelineBullets(extractGuidelinesSection(systemPromptText));
610
+
411
611
  return {
412
612
  modelName: ctx.model?.name ?? ctx.model?.id ?? "No model selected",
413
613
  contextWindow,
414
614
  totalTokens: scaling.totalTokens,
415
615
  scaled: scaling.scaled,
416
616
  approximationNote: scaling.approximationNote,
617
+ full,
417
618
  categories: {
418
619
  ...scaling.categories,
419
620
  autocompactBuffer,
@@ -423,6 +624,9 @@ export function analyzeContext(
423
624
  injectedFiles,
424
625
  skills: breakdown.skills,
425
626
  guidelines: breakdown.guidelines,
627
+ guidelineBullets,
628
+ guidelineSources: breakdown.guidelineSources,
629
+ toolSnippetDetails: breakdown.toolSnippetDetails,
426
630
  toolDefinitions,
427
631
  compaction,
428
632
  providerSections: collectProviderData(),
package/src/context.ts CHANGED
@@ -15,9 +15,10 @@ export default function contextExtension(pi: ExtensionAPI) {
15
15
  });
16
16
 
17
17
  pi.registerCommand("supi-context", {
18
- description: "Show detailed context usage",
19
- handler: async (_args, ctx) => {
20
- const analysis = analyzeContext(ctx, pi, cachedOptions);
18
+ description: "Show detailed context usage. Pass 'full' to show all guideline bullets.",
19
+ handler: async (args, ctx) => {
20
+ const full = args.trim() === "full";
21
+ const analysis = analyzeContext(ctx, pi, cachedOptions, full);
21
22
  const shortContent = `${formatTokens(analysis.totalTokens ?? 0)} / ${formatTokens(analysis.contextWindow)} tokens`;
22
23
 
23
24
  pi.sendMessage({