pi-soly 1.7.0 → 1.9.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/commands.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  // =============================================================================
4
4
  //
5
5
  // Registers slash commands (via pi.registerCommand):
6
- // - /rules manage soly rules (list/show/analytics/reload/enable/disable/add/new)
6
+ // - /rules manage soly rules (list/show/stats/analytics/reload/enable/disable/add/new)
7
+ // - /docs manage soly intent docs (stats — context breakdown)
7
8
  // - /soly project state inspection (position/plan/phases/tasks/...)
8
9
  // subcommands: position, state, plan, context, research, roadmap,
9
10
  // progress, phases, tasks, task <id>, features,
@@ -21,15 +22,24 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
22
  import {
22
23
  analyzeRules,
23
24
  buildProgressBar,
25
+ buildRulesContextStats,
24
26
  CONTEXT_WINDOW_TOKENS,
25
27
  extractFilePathsFromPrompt,
26
28
  formatAnalyticsFull,
29
+ formatRulesContextStats,
27
30
  formatTok,
28
31
  readIfExists,
29
32
  solyDirFor,
30
33
  type RuleFile,
31
34
  type SolyState,
32
35
  } from "./core.ts";
36
+ import {
37
+ buildIntentStats,
38
+ formatIntentStats,
39
+ loadInlineIntentBodies,
40
+ type IntentDoc,
41
+ type IntentInlineDoc,
42
+ } from "./intent.ts";
33
43
  import type { SolyConfig } from "./config.ts";
34
44
  import { migrateSolyDir } from "./migrate.js";
35
45
  import { initSolyProject } from "./init.js";
@@ -51,6 +61,7 @@ export interface CommandsDeps {
51
61
  refreshState: () => void;
52
62
  updateStatus: (ui: CommandUI) => void;
53
63
  getConfig: () => SolyConfig;
64
+ getIntentDocs: () => IntentDoc[];
54
65
  }
55
66
 
56
67
  export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
@@ -62,6 +73,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
62
73
  refreshState,
63
74
  updateStatus,
64
75
  getConfig,
76
+ getIntentDocs,
65
77
  } = deps;
66
78
 
67
79
  // ============================================================================
@@ -70,7 +82,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
70
82
 
71
83
  pi.registerCommand("rules", {
72
84
  description:
73
- "manage soly rules (list, show, analytics, reload, enable, disable)",
85
+ "manage soly rules (list, show, stats, analytics, reload, enable, disable)",
74
86
  handler: async (args, ctx) => {
75
87
  const ui: CommandUI = {
76
88
  notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
@@ -129,6 +141,16 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
129
141
  return;
130
142
  }
131
143
 
144
+ if (sub === "stats") {
145
+ // Claude-memory-style breakdown: shows which rules are always-on
146
+ // (every turn) vs glob-matched (only when prompt has file paths).
147
+ // Surfaces context bloat and verifies rules will actually fire.
148
+ const rules = getRules();
149
+ const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
150
+ ui.notify(formatRulesContextStats(stats), "info");
151
+ return;
152
+ }
153
+
132
154
  if (sub === "show") {
133
155
  if (!target) {
134
156
  ui.notify("Usage: /rules show <path>", "error");
@@ -335,6 +357,40 @@ What must the LLM do?
335
357
  },
336
358
  });
337
359
 
360
+ // ============================================================================
361
+ // /docs — manage soly intent docs (stats subcommand shows context usage)
362
+ // ============================================================================
363
+ pi.registerCommand("docs", {
364
+ description:
365
+ "manage soly intent docs (stats — show context breakdown)",
366
+ handler: async (args, ctx) => {
367
+ const ui: CommandUI = {
368
+ notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
369
+ select: async (label, options) => {
370
+ const result = await ctx.ui.select(label, options);
371
+ return result === undefined ? null : options.indexOf(result);
372
+ },
373
+ confirm: (title, message) => ctx.ui.confirm(title, message),
374
+ };
375
+ const parts = args.trim().split(/\s+/);
376
+ const sub = parts[0] ?? "stats";
377
+
378
+ if (sub === "stats") {
379
+ const docs = getIntentDocs();
380
+ const inlineBodies: IntentInlineDoc[] = loadInlineIntentBodies(docs);
381
+ const stats = buildIntentStats(docs, inlineBodies);
382
+ ui.notify(formatIntentStats(stats), "info");
383
+ return;
384
+ }
385
+
386
+ ui.notify(
387
+ `Usage: /docs stats — show context breakdown for intent docs\n` +
388
+ `Found ${getIntentDocs().length} doc(s) loaded.`,
389
+ "info",
390
+ );
391
+ },
392
+ });
393
+
338
394
  // ============================================================================
339
395
  // /soly migrate — move .soly/ → .agents/ atomically
340
396
  // ============================================================================
package/core.ts CHANGED
@@ -993,6 +993,127 @@ export function formatAnalyticsFull(analytics: RuleAnalytics): string {
993
993
  return lines.join("\n");
994
994
  }
995
995
 
996
+ // =============================================================================
997
+ // Rules context stats — Claude-memory-style breakdown
998
+ // =============================================================================
999
+ //
1000
+ // Shows which rules are "always-on" (loaded every turn) vs "glob-matched"
1001
+ // (loaded only when file paths in prompt match). Useful for spotting
1002
+ // context bloat and verifying rules will actually fire.
1003
+
1004
+ export interface RuleStat {
1005
+ relPath: string;
1006
+ tokens: number;
1007
+ sourceLabel: string;
1008
+ description?: string;
1009
+ always: boolean;
1010
+ globs: string[];
1011
+ loadedLastTurn: boolean;
1012
+ }
1013
+
1014
+ export interface RulesContextStats {
1015
+ totalLoaded: number;
1016
+ totalTokens: number;
1017
+ contextBudgetPct: number;
1018
+ alwaysOn: RuleStat[];
1019
+ globMatched: RuleStat[];
1020
+ disabled: RuleStat[];
1021
+ lastTurn: {
1022
+ promptFiles: string[];
1023
+ matchedRulePaths: string[];
1024
+ };
1025
+ }
1026
+
1027
+ export function buildRulesContextStats(
1028
+ rules: RuleFile[],
1029
+ contextWindowTokens: number,
1030
+ lastTurn?: { promptFiles: string[]; matchedRelPaths: string[] },
1031
+ ): RulesContextStats {
1032
+ const enabled = rules.filter((r) => r.enabled);
1033
+ const disabled = rules.filter((r) => !r.enabled);
1034
+ const lastTurnMatched = new Set(lastTurn?.matchedRelPaths ?? []);
1035
+ const stat = (r: RuleFile, loadedLastTurn: boolean): RuleStat => ({
1036
+ relPath: r.relPath,
1037
+ tokens: estimateTokens(r.body),
1038
+ sourceLabel: r.sourceLabel,
1039
+ description: r.meta.description,
1040
+ always: r.meta.always === true,
1041
+ globs: r.meta.globs ?? [],
1042
+ loadedLastTurn,
1043
+ });
1044
+ const alwaysOn: RuleStat[] = [];
1045
+ const globMatched: RuleStat[] = [];
1046
+ for (const r of enabled) {
1047
+ const isAlways = r.meta.always === true;
1048
+ const isLoadedLastTurn = lastTurnMatched.has(r.relPath) || isAlways;
1049
+ const s = stat(r, isLoadedLastTurn);
1050
+ if (isAlways) alwaysOn.push(s);
1051
+ else globMatched.push(s);
1052
+ }
1053
+ const totalTokens = [...alwaysOn, ...globMatched].reduce((a, b) => a + b.tokens, 0);
1054
+ return {
1055
+ totalLoaded: enabled.length,
1056
+ totalTokens,
1057
+ contextBudgetPct:
1058
+ contextWindowTokens > 0 ? (totalTokens / contextWindowTokens) * 100 : 0,
1059
+ alwaysOn,
1060
+ globMatched,
1061
+ disabled: disabled.map((r) => stat(r, false)),
1062
+ lastTurn: {
1063
+ promptFiles: lastTurn?.promptFiles ?? [],
1064
+ matchedRulePaths: lastTurn?.matchedRelPaths ?? [],
1065
+ },
1066
+ };
1067
+ }
1068
+
1069
+ export function formatRulesContextStats(stats: RulesContextStats): string {
1070
+ const lines: string[] = [];
1071
+ lines.push(`📊 Rules context stats`);
1072
+ lines.push(``);
1073
+ const ctxWindow = stats.contextBudgetPct > 0
1074
+ ? Math.round(stats.totalTokens / (stats.contextBudgetPct / 100))
1075
+ : 0;
1076
+ lines.push(
1077
+ `Loaded: ${stats.totalLoaded} rule(s) · ${formatTok(stats.totalTokens)} · ${stats.contextBudgetPct.toFixed(1)}% of ${formatTok(ctxWindow)} context`,
1078
+ );
1079
+ lines.push(``);
1080
+ if (stats.alwaysOn.length > 0) {
1081
+ lines.push(`ALWAYS-ON (loaded every turn):`);
1082
+ for (const r of stats.alwaysOn) {
1083
+ const last = r.loadedLastTurn ? " ✓" : "";
1084
+ const desc = r.description ? ` — "${r.description}"` : "";
1085
+ lines.push(` ● ${r.relPath} ${formatTok(r.tokens)}${desc}${last}`);
1086
+ }
1087
+ lines.push(``);
1088
+ }
1089
+ if (stats.globMatched.length > 0) {
1090
+ lines.push(`GLOB-MATCHED (loaded when prompt file matches):`);
1091
+ for (const r of stats.globMatched) {
1092
+ const last = r.loadedLastTurn ? " ✓" : "";
1093
+ const desc = r.description ? ` — "${r.description}"` : "";
1094
+ const globs = r.globs.length > 0 ? ` [globs: ${r.globs.join(", ")}]` : "";
1095
+ lines.push(` ◐ ${r.relPath} ${formatTok(r.tokens)}${desc}${globs}${last}`);
1096
+ }
1097
+ lines.push(``);
1098
+ }
1099
+ if (stats.disabled.length > 0) {
1100
+ lines.push(`DISABLED:`);
1101
+ for (const r of stats.disabled) {
1102
+ lines.push(` ○ ${r.relPath} ${formatTok(r.tokens)} — disabled`);
1103
+ }
1104
+ lines.push(``);
1105
+ }
1106
+ if (stats.lastTurn.promptFiles.length > 0) {
1107
+ lines.push(`Last turn: ${stats.lastTurn.promptFiles.length} file path(s) in prompt`);
1108
+ for (const f of stats.lastTurn.promptFiles) {
1109
+ lines.push(` → ${f}`);
1110
+ }
1111
+ } else {
1112
+ lines.push(`Last turn: no file paths in prompt (only always-on rules loaded)`);
1113
+ }
1114
+ return lines.join("\n");
1115
+ }
1116
+
996
1117
  // ============================================================================
997
1118
  // @import resolver (markdown only)
998
1119
  // ============================================================================
package/index.ts CHANGED
@@ -307,6 +307,7 @@ export default function solyExtension(pi: ExtensionAPI) {
307
307
  refreshState: () => refreshState(),
308
308
  updateStatus: (ui) => updateStatus(ui),
309
309
  getConfig: getActiveConfig,
310
+ getIntentDocs: () => intentDocs,
310
311
  });
311
312
 
312
313
  registerTools(pi, {
package/intent.ts CHANGED
@@ -301,3 +301,109 @@ export function loadInlineIntentBodies(intent: IntentDoc[]): IntentInlineDoc[] {
301
301
  }
302
302
  return out;
303
303
  }
304
+
305
+ // =============================================================================
306
+ // Intent stats — Claude-memory-style breakdown for docs
307
+ // =============================================================================
308
+ //
309
+ // Shows how docs consume context. Most docs are preview-only (cheap),
310
+ // only `inline: true` docs inject their full body (expensive).
311
+
312
+ export interface IntentDocStat {
313
+ relPath: string;
314
+ kind: "md" | "html";
315
+ title: string;
316
+ tokens: number; // full file tokens (what would be loaded if inline)
317
+ previewTokens: number; // preview tokens (what's actually loaded by default)
318
+ inline: boolean; // true if `inline: true` frontmatter — body is injected
319
+ oversized: boolean;
320
+ phaseNumber?: number;
321
+ }
322
+
323
+ export interface IntentStats {
324
+ totalDocs: number;
325
+ totalInlineTokens: number; // tokens from inline: true docs (full body)
326
+ totalPreviewTokens: number; // tokens from preview-only docs (always loaded)
327
+ totalPerTurnTokens: number; // sum of what's actually in system prompt
328
+ inlineDocs: IntentDocStat[];
329
+ previewDocs: IntentDocStat[];
330
+ phaseSpecificDocs: IntentDocStat[];
331
+ }
332
+
333
+ export function buildIntentStats(
334
+ docs: IntentDoc[],
335
+ inlineBodies: IntentInlineDoc[],
336
+ ): IntentStats {
337
+ const inlineRelPaths = new Set(inlineBodies.map((d) => d.relPath));
338
+ const inlineBodyTokens = new Map(inlineBodies.map((d) => [d.relPath, d.tokens]));
339
+ const stat = (d: IntentDoc): IntentDocStat => ({
340
+ relPath: d.relPath,
341
+ kind: d.kind,
342
+ title: d.title,
343
+ tokens: d.tokens,
344
+ previewTokens: Math.ceil(d.preview.length / 4),
345
+ inline: inlineRelPaths.has(d.relPath),
346
+ oversized: d.oversized,
347
+ phaseNumber: d.phaseNumber,
348
+ });
349
+ const all = docs.map(stat);
350
+ const inlineDocs = all.filter((d) => d.inline);
351
+ const previewDocs = all.filter((d) => !d.inline);
352
+ const phaseSpecificDocs = all.filter((d) => d.phaseNumber != null);
353
+ const totalInlineTokens = inlineDocs.reduce(
354
+ (a, b) => a + (inlineBodyTokens.get(b.relPath) ?? b.tokens),
355
+ 0,
356
+ );
357
+ const totalPreviewTokens = previewDocs.reduce((a, b) => a + b.previewTokens, 0);
358
+ return {
359
+ totalDocs: all.length,
360
+ totalInlineTokens,
361
+ totalPreviewTokens,
362
+ totalPerTurnTokens: totalInlineTokens + totalPreviewTokens,
363
+ inlineDocs,
364
+ previewDocs,
365
+ phaseSpecificDocs,
366
+ };
367
+ }
368
+
369
+ export function formatIntentStats(stats: IntentStats): string {
370
+ const lines: string[] = [];
371
+ lines.push(`📚 Docs context stats`);
372
+ lines.push(``);
373
+ lines.push(
374
+ `Loaded: ${stats.totalDocs} doc(s) · ${stats.totalPerTurnTokens} tok every turn`,
375
+ );
376
+ lines.push(
377
+ ` (${stats.totalInlineTokens} from inline bodies + ${stats.totalPreviewTokens} from previews)`,
378
+ );
379
+ lines.push(``);
380
+ if (stats.inlineDocs.length > 0) {
381
+ lines.push(`INLINE (full body loaded every turn):`);
382
+ for (const d of stats.inlineDocs) {
383
+ const title = d.title ? ` — "${d.title}"` : "";
384
+ lines.push(` ● ${d.relPath} ${d.tokens} tok${title}`);
385
+ }
386
+ lines.push(``);
387
+ }
388
+ if (stats.previewDocs.length > 0) {
389
+ lines.push(`PREVIEW-ONLY (only title + 180-char preview loaded):`);
390
+ for (const d of stats.previewDocs) {
391
+ const title = d.title ? ` — "${d.title}"` : "";
392
+ const size = d.oversized ? " (oversized)" : "";
393
+ lines.push(` ○ ${d.relPath} ${d.previewTokens} tok preview${title}${size}`);
394
+ }
395
+ lines.push(``);
396
+ }
397
+ if (stats.phaseSpecificDocs.length > 0) {
398
+ lines.push(`PHASE-SPECIFIC (only loaded for matching phase):`);
399
+ for (const d of stats.phaseSpecificDocs) {
400
+ const title = d.title ? ` — "${d.title}"` : "";
401
+ lines.push(` ◐ phase ${d.phaseNumber}: ${d.relPath}${title}`);
402
+ }
403
+ lines.push(``);
404
+ }
405
+ if (stats.totalDocs === 0) {
406
+ lines.push(`No intent docs found in .soly/docs/ or ~/.soly/docs/`);
407
+ }
408
+ return lines.join("\n");
409
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Project management framework for pi-coding-agent. Workflows, planning, multi-question picker, agent switcher, live task list — one npm install, zero config.",
5
5
  "type": "module",
6
6
  "main": "index.ts",