supipowers 1.2.5 → 1.3.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.
@@ -111,3 +111,90 @@ export function createModelBridge(platform: Platform): ModelPlatformBridge {
111
111
  },
112
112
  };
113
113
  }
114
+
115
+ /**
116
+ * Apply a resolved model override to the current session.
117
+ *
118
+ * Resolves the string model ID to an OMP Model object via the context's
119
+ * modelRegistry, then calls platform.setModel() with it. Also applies
120
+ * thinking level if specified.
121
+ *
122
+ * @param platform - The platform adapter
123
+ * @param ctx - Command handler context (must have modelRegistry.getAvailable())
124
+ * @param actionId - The action being configured (e.g. "plan", "review") — used in notification
125
+ * @param resolved - The resolved model from resolveModelForAction()
126
+ * @returns true if model was applied, false if skipped or failed
127
+ */
128
+ export async function applyModelOverride(
129
+ platform: Platform,
130
+ ctx: any,
131
+ actionId: string,
132
+ resolved: ResolvedModel,
133
+ ): Promise<boolean> {
134
+ // Skip if resolution fell through to the main session model (nothing to change)
135
+ if (resolved.source === "main") return false;
136
+
137
+ const modelId = resolved.model;
138
+ if (!modelId) return false;
139
+
140
+ // Apply thinking level (independent of model switch success)
141
+ if (resolved.thinkingLevel && platform.setThinkingLevel) {
142
+ platform.setThinkingLevel(resolved.thinkingLevel);
143
+ }
144
+
145
+ if (!platform.setModel) return false;
146
+
147
+ // Resolve string model ID to full OMP Model object via the context's model registry.
148
+ // OMP's setModel expects a Model object (with provider, id, api, etc.), not a string.
149
+ const available = ctx.modelRegistry?.getAvailable?.() as any[] | undefined;
150
+ if (!available) return false;
151
+
152
+ const modelObj = available.find((m: any) => {
153
+ if (!m?.id) return false;
154
+ if (modelId === m.id) return true;
155
+ if (modelId === `${m.provider}/${m.id}`) return true;
156
+ return modelId.includes("/") ? false : m.id === modelId;
157
+ });
158
+
159
+ if (!modelObj) return false;
160
+
161
+ // Save current model so we can restore after the agent turn completes.
162
+ // OMP's extension API setModel() persists to settings (calls session.setModel,
163
+ // not session.setModelTemporary). We must restore to avoid permanently
164
+ // overriding the user's default model.
165
+ const originalModel = ctx.model;
166
+
167
+ const applied = await platform.setModel(modelObj);
168
+ if (!applied) return false;
169
+
170
+ // Show persistent model override info in the footer status bar.
171
+ // ctx.ui.notify() is transient and gets immediately replaced by progress widgets;
172
+ // setStatus persists alongside them.
173
+ const STATUS_KEY = "supi-model";
174
+ const displayName = modelObj.name ?? modelObj.id ?? modelId;
175
+ const sourceLabel =
176
+ resolved.source === "action" ? `configured for ${actionId}` :
177
+ resolved.source === "default" ? "supipowers default" :
178
+ "harness role";
179
+ let detail = sourceLabel;
180
+ if (resolved.thinkingLevel) {
181
+ detail += ` \u00b7 ${resolved.thinkingLevel} thinking`;
182
+ }
183
+ ctx.ui?.setStatus?.(STATUS_KEY, `Model: ${displayName} (${detail})`);
184
+
185
+ // Register a one-shot agent_end hook to restore the original model
186
+ // and clear the status bar entry.
187
+ {
188
+ let restored = false;
189
+ platform.on("agent_end", async () => {
190
+ if (restored) return;
191
+ restored = true;
192
+ ctx.ui?.setStatus?.(STATUS_KEY, undefined);
193
+ if (originalModel) {
194
+ await platform.setModel!(originalModel);
195
+ }
196
+ });
197
+ }
198
+
199
+ return true;
200
+ }
@@ -43,6 +43,63 @@ export function parseSystemPrompt(text: string): PromptSection[] {
43
43
  return sections;
44
44
  }
45
45
 
46
+ // ── Per-Skill Parser ──────────────────────────────────────
47
+
48
+ /** A single skill extracted from the system prompt */
49
+ export interface ParsedSkill {
50
+ name: string;
51
+ bytes: number;
52
+ tokens: number;
53
+ content: string;
54
+ }
55
+
56
+ /** Extract individual skills from system prompt text */
57
+ export function parseIndividualSkills(systemPrompt: string): ParsedSkill[] {
58
+ if (!systemPrompt) return [];
59
+
60
+ // OMP renders skills as markdown headings under "# Skills":
61
+ // ## skill-name
62
+ // Description text...
63
+ // Find the Skills section bounded by the next h1 heading or end of text.
64
+ const skillsSectionMatch = systemPrompt.match(
65
+ /^# Skills\n[\s\S]*?\n(?=##\s)/m,
66
+ );
67
+ if (!skillsSectionMatch) return [];
68
+
69
+ // Extract the region from first ## to the next # (h1) or end of text
70
+ const sectionStart = skillsSectionMatch.index! + skillsSectionMatch[0].length;
71
+ const afterSection = systemPrompt.slice(sectionStart);
72
+ const nextH1 = afterSection.search(/^# [^#]/m);
73
+ const skillsBody =
74
+ skillsSectionMatch[0].slice(skillsSectionMatch[0].indexOf("\n## ") + 1) +
75
+ (nextH1 === -1 ? afterSection : afterSection.slice(0, nextH1));
76
+
77
+ // Split on ## headings
78
+ const skills: ParsedSkill[] = [];
79
+ const headingRegex = /^## (.+)$/gm;
80
+ const headings: { name: string; index: number }[] = [];
81
+ let match;
82
+
83
+ while ((match = headingRegex.exec(skillsBody)) !== null) {
84
+ headings.push({ name: match[1].trim(), index: match.index });
85
+ }
86
+
87
+ for (let i = 0; i < headings.length; i++) {
88
+ const start = headings[i].index;
89
+ const end = i + 1 < headings.length ? headings[i + 1].index : skillsBody.length;
90
+ const content = skillsBody.slice(start, end).trimEnd();
91
+ const bytes = byteLength(content);
92
+ skills.push({
93
+ name: headings[i].name,
94
+ bytes,
95
+ tokens: estimateTokens(content),
96
+ content,
97
+ });
98
+ }
99
+
100
+ return skills;
101
+ }
102
+
46
103
  // ── Breakdown Builder ─────────────────────────────────────
47
104
 
48
105
  /** Context usage data from OMP runtime */
@@ -0,0 +1,199 @@
1
+ import type { Platform } from "../platform/types.js";
2
+ import type { ParsedSkill, PromptSection } from "./analyzer.js";
3
+ import { estimateTokens } from "./analyzer.js";
4
+
5
+ // ── Types ───────────────────────────────────────────────────
6
+
7
+ export interface TechStack {
8
+ languages: string[];
9
+ frameworks: string[];
10
+ tools: string[];
11
+ runtime: string | null;
12
+ }
13
+
14
+ /** A skill with its token cost — no classification judgment. */
15
+ export interface SkillEntry {
16
+ name: string;
17
+ tokens: number;
18
+ }
19
+
20
+ /** A non-skill section with token cost and optional note. */
21
+ export interface SectionEntry {
22
+ label: string;
23
+ tokens: number;
24
+ note: string;
25
+ }
26
+
27
+ /** Raw context report — data only, no classification. */
28
+ export interface ContextReport {
29
+ totalTokens: number;
30
+ techStack: TechStack;
31
+ skills: SkillEntry[];
32
+ sections: SectionEntry[];
33
+ }
34
+
35
+ // ── Framework / Tool Detection Maps ─────────────────────────
36
+
37
+ /** Maps package.json dependency names → detected framework or tool */
38
+ const DEP_TO_FRAMEWORK: Record<string, string> = {
39
+ react: "react",
40
+ "react-dom": "react",
41
+ next: "next",
42
+ vue: "vue",
43
+ svelte: "svelte",
44
+ "@sveltejs/kit": "svelte",
45
+ express: "express",
46
+ fastify: "fastify",
47
+ };
48
+
49
+ const DEP_TO_TOOL: Record<string, string> = {
50
+ tailwindcss: "tailwind",
51
+ "@playwright/test": "playwright",
52
+ prisma: "prisma",
53
+ "@prisma/client": "prisma",
54
+ "@shadcn/ui": "shadcn",
55
+ };
56
+
57
+ // ── Tech Stack Detection ────────────────────────────────────
58
+
59
+ /** Read a file via platform.exec; returns content or null on failure. */
60
+ async function tryRead(
61
+ platform: Platform,
62
+ cwd: string,
63
+ filename: string,
64
+ ): Promise<string | null> {
65
+ const r = await platform.exec("cat", [filename], { cwd });
66
+ return r.code === 0 ? r.stdout : null;
67
+ }
68
+
69
+ /** Check if a file exists at `cwd/filename` via exec. */
70
+ async function fileExists(
71
+ platform: Platform,
72
+ cwd: string,
73
+ filename: string,
74
+ ): Promise<boolean> {
75
+ const r = await platform.exec("test", ["-f", filename], { cwd });
76
+ return r.code === 0;
77
+ }
78
+
79
+ /**
80
+ * Deterministic tech-stack detection — no LLM.
81
+ * Inspects package.json, lockfiles, and config files.
82
+ */
83
+ export async function detectTechStack(
84
+ platform: Platform,
85
+ cwd: string,
86
+ ): Promise<TechStack> {
87
+ const languages = new Set<string>();
88
+ const frameworks = new Set<string>();
89
+ const tools = new Set<string>();
90
+ let runtime: string | null = null;
91
+
92
+ // 1. package.json — deps & devDeps
93
+ const pkgRaw = await tryRead(platform, cwd, "package.json");
94
+ if (pkgRaw) {
95
+ try {
96
+ const pkg = JSON.parse(pkgRaw);
97
+ const allDeps: Record<string, string> = {
98
+ ...pkg.dependencies,
99
+ ...pkg.devDependencies,
100
+ };
101
+ for (const dep of Object.keys(allDeps)) {
102
+ const fw = DEP_TO_FRAMEWORK[dep];
103
+ if (fw) frameworks.add(fw);
104
+ const tl = DEP_TO_TOOL[dep];
105
+ if (tl) tools.add(tl);
106
+ }
107
+ if (allDeps.typescript) languages.add("typescript");
108
+ } catch {
109
+ // malformed package.json — continue with file-based detection
110
+ }
111
+ }
112
+
113
+ // 2. Lockfiles → runtime (first match wins, most-specific first)
114
+ if (await fileExists(platform, cwd, "bun.lock")) {
115
+ runtime = "bun";
116
+ } else if (await fileExists(platform, cwd, "package-lock.json")) {
117
+ runtime = "node";
118
+ } else if (await fileExists(platform, cwd, "pnpm-lock.yaml")) {
119
+ runtime = "node";
120
+ } else if (await fileExists(platform, cwd, "yarn.lock")) {
121
+ runtime = "node";
122
+ }
123
+
124
+ // 3. Config files → languages
125
+ const configChecks: [string, string][] = [
126
+ ["tsconfig.json", "typescript"],
127
+ ["Cargo.toml", "rust"],
128
+ ["pyproject.toml", "python"],
129
+ ["requirements.txt", "python"],
130
+ ["go.mod", "go"],
131
+ ["Gemfile", "ruby"],
132
+ ];
133
+ const results = await Promise.all(
134
+ configChecks.map(([file, lang]) =>
135
+ fileExists(platform, cwd, file).then((exists) =>
136
+ exists ? lang : null,
137
+ ),
138
+ ),
139
+ );
140
+ for (const lang of results) {
141
+ if (lang) languages.add(lang);
142
+ }
143
+
144
+ return {
145
+ languages: [...languages],
146
+ frameworks: [...frameworks],
147
+ tools: [...tools],
148
+ runtime,
149
+ };
150
+ }
151
+
152
+ // ── Context Report ──────────────────────────────────────────
153
+
154
+ /**
155
+ * Build a raw context report from parsed prompt data.
156
+ * Gathers token costs per skill and per section, flags anomalies.
157
+ * Does NOT classify or recommend — that's the LLM's job.
158
+ */
159
+ export function buildContextReport(
160
+ sections: PromptSection[],
161
+ skills: ParsedSkill[],
162
+ techStack: TechStack,
163
+ ): ContextReport {
164
+ const totalTokens = sections.reduce(
165
+ (sum, s) => sum + estimateTokens(s.content),
166
+ 0,
167
+ );
168
+
169
+ const skillEntries: SkillEntry[] = skills.map((s) => ({
170
+ name: s.name,
171
+ tokens: s.tokens,
172
+ }));
173
+
174
+ // Annotate non-skill sections with notes for anomalies
175
+ const sectionEntries: SectionEntry[] = [];
176
+
177
+ const routingSections = sections.filter((s) =>
178
+ s.label.toLowerCase().includes("routing"),
179
+ );
180
+ const hasRoutingDupes = routingSections.length > 1;
181
+
182
+ for (const s of sections) {
183
+ // Skip the aggregate "Skills (N)" section — per-skill data is separate
184
+ if (s.label.toLowerCase().startsWith("skills")) continue;
185
+
186
+ const tokens = estimateTokens(s.content);
187
+ let note = "";
188
+
189
+ if (s.label.toLowerCase().includes("routing") && hasRoutingDupes) {
190
+ note = `Duplicate (${routingSections.length} found) — consolidate`;
191
+ } else if (s.label.toLowerCase().includes("memory") && tokens > 500) {
192
+ note = "Large — consider compressing or summarizing";
193
+ }
194
+
195
+ sectionEntries.push({ label: s.label, tokens, note });
196
+ }
197
+
198
+ return { totalTokens, techStack, skills: skillEntries, sections: sectionEntries };
199
+ }
@@ -14,7 +14,8 @@ interface ToolResultEventResult {
14
14
 
15
15
  const BASH_HEAD_LINES = 5;
16
16
  const BASH_TAIL_LINES = 10;
17
- const READ_PREVIEW_LINES = 10;
17
+ const READ_HEAD_LINES = 80;
18
+ const READ_TAIL_LINES = 30;
18
19
  const GREP_MAX_MATCHES = 10;
19
20
  const FIND_MAX_PATHS = 20;
20
21
 
@@ -68,23 +69,25 @@ function compressBash(text: string, details: unknown): string | undefined {
68
69
  ].join("\n");
69
70
  }
70
71
 
71
- /** Compress read tool output */
72
+ /** Compress read tool output — head+tail with hashline preservation */
72
73
  function compressRead(text: string, input: Record<string, unknown>): string | undefined {
73
- // Scoped reads (offset/limit) are already targeted — pass through
74
- if (input.offset !== undefined || input.limit !== undefined) return undefined;
74
+ // Scoped reads (offset/limit/sel) are already targeted — pass through
75
+ if (input.offset != null || input.limit != null || input.sel != null) return undefined;
75
76
 
76
77
  const lines = text.split("\n");
77
78
  const totalLines = lines.length;
78
- const path = typeof input.path === "string" ? input.path : "unknown";
79
79
 
80
- if (totalLines <= READ_PREVIEW_LINES) return undefined;
80
+ if (totalLines <= READ_HEAD_LINES + READ_TAIL_LINES) return undefined;
81
+
82
+ const head = lines.slice(0, READ_HEAD_LINES);
83
+ const tail = lines.slice(-READ_TAIL_LINES);
84
+ const omittedStart = READ_HEAD_LINES + 1;
85
+ const omittedEnd = totalLines - READ_TAIL_LINES;
81
86
 
82
- const preview = lines.slice(0, READ_PREVIEW_LINES);
83
87
  return [
84
- `File: ${path} (${totalLines} lines total)`,
85
- "",
86
- ...preview,
87
- `[...compressed: remaining ${totalLines - READ_PREVIEW_LINES} lines omitted...]`,
88
+ ...head,
89
+ `[...${omittedEnd - omittedStart + 1} lines omitted. Use read(path, sel="L${omittedStart}-L${omittedEnd}") to view...]`,
90
+ ...tail,
88
91
  ].join("\n");
89
92
  }
90
93
 
@@ -1,7 +1,8 @@
1
1
  // src/context-mode/event-extractor.ts
2
+ import { PRIORITY } from "./event-store.js";
2
3
  import type { EventCategory, EventPriority, TrackedEvent } from "./event-store.js";
3
4
 
4
- type Event = Omit<TrackedEvent, "id">;
5
+ type Event = Omit<TrackedEvent, "id" | "dataHash">;
5
6
 
6
7
  const GIT_COMMAND_PATTERNS = [
7
8
  /^git\s+(commit|merge|rebase|checkout|switch|branch|push|pull|stash|reset|cherry-pick|tag)\b/,
@@ -39,8 +40,7 @@ function getTextContent(content: Array<{ type: string; text?: string }>): string
39
40
  return content
40
41
  .filter((c) => c.type === "text" && c.text)
41
42
  .map((c) => c.text!)
42
- .join("\n")
43
- .slice(0, 500); // Cap for storage
43
+ .join("\n");
44
44
  }
45
45
 
46
46
  /** Extract events from a tool result */
@@ -62,21 +62,29 @@ export function extractEvents(
62
62
  events.push(makeEvent(sessionId, "error", {
63
63
  toolName: event.toolName,
64
64
  content: text,
65
- }, "critical", "tool_result"));
65
+ }, PRIORITY.critical, "tool_result"));
66
66
  }
67
67
 
68
68
  switch (event.toolName) {
69
69
  case "bash":
70
70
  extractBash(events, event, sessionId, text);
71
71
  break;
72
- case "read":
72
+ case "read": {
73
+ const readPath = typeof event.input.path === "string" ? event.input.path : "";
74
+ if (/AGENTS\.md$/.test(readPath) || /CLAUDE\.md$/.test(readPath) || /\.omp\//.test(readPath)) {
75
+ events.push(makeEvent(sessionId, "rule", { path: readPath, type: "project-rule" }, PRIORITY.critical, "tool_result"));
76
+ }
77
+ if (readPath.includes("/skills/")) {
78
+ events.push(makeEvent(sessionId, "skill", { path: readPath }, PRIORITY.medium, "tool_result"));
79
+ }
73
80
  extractFile(events, event, sessionId, "read");
74
81
  break;
82
+ }
75
83
  case "edit":
76
- extractFile(events, event, sessionId, "edit", "high");
84
+ extractFile(events, event, sessionId, "edit", PRIORITY.high);
77
85
  break;
78
86
  case "write":
79
- extractFile(events, event, sessionId, "write", "high");
87
+ extractFile(events, event, sessionId, "write", PRIORITY.high);
80
88
  break;
81
89
  case "grep":
82
90
  extractFile(events, event, sessionId, "search");
@@ -87,18 +95,18 @@ export function extractEvents(
87
95
  case "todo_write":
88
96
  events.push(makeEvent(sessionId, "task", {
89
97
  input: event.input,
90
- }, "high", "tool_result"));
98
+ }, PRIORITY.high, "tool_result"));
91
99
  break;
92
100
  default:
93
101
  if (event.toolName.startsWith("ctx_")) {
94
102
  events.push(makeEvent(sessionId, "mcp", {
95
103
  tool: event.toolName,
96
- }, "low", "tool_result"));
104
+ }, PRIORITY.low, "tool_result"));
97
105
  } else if (event.toolName === "task" || event.toolName === "sub_agent") {
98
106
  events.push(makeEvent(sessionId, "subagent", {
99
107
  toolName: event.toolName,
100
108
  input: event.input,
101
- }, "medium", "tool_result"));
109
+ }, PRIORITY.medium, "tool_result"));
102
110
  }
103
111
  // Unknown tools: no events
104
112
  break;
@@ -123,7 +131,7 @@ function extractBash(
123
131
  events.push(makeEvent(sessionId, "git", {
124
132
  command,
125
133
  output: text,
126
- }, "high", "tool_result"));
134
+ }, PRIORITY.high, "tool_result"));
127
135
  }
128
136
 
129
137
  // Non-zero exit (in addition to general isError rule)
@@ -132,14 +140,28 @@ function extractBash(
132
140
  command,
133
141
  exitCode,
134
142
  output: text,
135
- }, "critical", "tool_result"));
143
+ }, PRIORITY.critical, "tool_result"));
136
144
  }
137
145
 
138
146
  // Working directory change
139
147
  if (/\bcd\s+/.test(command)) {
140
148
  events.push(makeEvent(sessionId, "cwd", {
141
149
  command,
142
- }, "low", "tool_result"));
150
+ }, PRIORITY.low, "tool_result"));
151
+ }
152
+
153
+ // Environment/version commands
154
+ const ENV_PATTERNS = [
155
+ /^(node|bun|python|ruby|go)\s+--?version/,
156
+ /^(npm|yarn|pnpm|pip|cargo)\s+--?version/,
157
+ /\bprintenv\b/,
158
+ /\becho\s+\$\w+/,
159
+ ];
160
+ if (ENV_PATTERNS.some((p) => p.test(command))) {
161
+ events.push(makeEvent(sessionId, "env", {
162
+ command,
163
+ output: text,
164
+ }, PRIORITY.medium, "tool_result"));
143
165
  }
144
166
  }
145
167
 
@@ -148,7 +170,7 @@ function extractFile(
148
170
  event: { input: Record<string, unknown> },
149
171
  sessionId: string,
150
172
  op: string,
151
- priority: EventPriority = "medium",
173
+ priority: EventPriority = PRIORITY.medium,
152
174
  ): void {
153
175
  const path = typeof event.input.path === "string" ? event.input.path : "unknown";
154
176
  events.push(makeEvent(sessionId, "file", { op, path }, priority, "tool_result"));
@@ -159,11 +181,18 @@ export function extractPromptEvents(prompt: string, sessionId: string): Event[]
159
181
  const events: Event[] = [];
160
182
 
161
183
  // Always capture the prompt
162
- events.push(makeEvent(sessionId, "prompt", { prompt }, "high", "before_agent_start"));
184
+ events.push(makeEvent(sessionId, "prompt", { prompt }, PRIORITY.high, "before_agent_start"));
163
185
 
164
186
  // Check for decision patterns
165
187
  if (DECISION_PATTERNS.some((p) => p.test(prompt))) {
166
- events.push(makeEvent(sessionId, "decision", { prompt }, "high", "before_agent_start"));
188
+ events.push(makeEvent(sessionId, "decision", { prompt }, PRIORITY.high, "before_agent_start"));
189
+ }
190
+
191
+ // Detect high-level intent markers
192
+ const INTENT_PATTERN = /\b(build|fix|refactor|test|review|deploy|debug|plan|implement|create|delete|remove|update|add|migrate)\b/i;
193
+ const intentMatch = prompt.match(INTENT_PATTERN);
194
+ if (intentMatch) {
195
+ events.push(makeEvent(sessionId, "intent", { intent: intentMatch[1].toLowerCase(), prompt }, PRIORITY.low, "before_agent_start"));
167
196
  }
168
197
 
169
198
  return events;