omegon 0.6.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.
Files changed (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * URI resolver for context-aware OSC 8 hyperlinks.
3
+ *
4
+ * Routes file paths to the best URI scheme based on:
5
+ * - File extension (.md → mdserve, code → editor, .excalidraw → Obsidian)
6
+ * - Running services (mdserve port in shared state)
7
+ * - User config (.pi/config.json editor preference)
8
+ * - Obsidian vault detection (walk parents for .obsidian/)
9
+ *
10
+ * Fallback is always file://
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { basename, dirname, extname, join, relative } from "node:path";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface PiConfig {
21
+ editor?: string;
22
+ }
23
+
24
+ export interface ResolveUriOptions {
25
+ mdservePort?: number;
26
+ config?: PiConfig;
27
+ /** Override project root for mdserve relative paths. Defaults to cwd. */
28
+ projectRoot?: string;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Constants
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const CODE_EXTS = new Set([
36
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
37
+ ".py", ".rs", ".go", ".c", ".cpp", ".cc", ".h", ".hpp",
38
+ ".java", ".kt", ".rb", ".lua", ".sh", ".bash", ".zsh",
39
+ ".css", ".scss", ".less", ".sql", ".swift", ".zig",
40
+ ".vue", ".svelte", ".elm", ".hs", ".ml", ".ex", ".exs",
41
+ ".php", ".pl", ".r", ".scala", ".dart", ".nim",
42
+ ]);
43
+
44
+ const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdx"]);
45
+
46
+ const KNOWN_EDITORS = new Set(["vscode", "cursor", "zed"]);
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Config loading
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Load .pi/config.json from the given directory (or cwd).
54
+ * Returns empty config on missing/invalid file — never throws.
55
+ */
56
+ export function loadConfig(root?: string): PiConfig {
57
+ const configPath = join(root ?? process.cwd(), ".pi", "config.json");
58
+ try {
59
+ if (!existsSync(configPath)) return {};
60
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
61
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
62
+ return { editor: typeof raw.editor === "string" ? raw.editor : undefined };
63
+ }
64
+ return {};
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Obsidian vault detection
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Walk parent directories looking for .obsidian/ folder.
76
+ * Returns { vaultName, vaultRoot } or undefined.
77
+ */
78
+ export function detectObsidianVault(absPath: string): { vaultName: string; vaultRoot: string } | undefined {
79
+ let dir = dirname(absPath);
80
+ const seen = new Set<string>();
81
+ while (dir && !seen.has(dir)) {
82
+ seen.add(dir);
83
+ if (existsSync(join(dir, ".obsidian"))) {
84
+ return { vaultName: basename(dir), vaultRoot: dir };
85
+ }
86
+ const parent = dirname(dir);
87
+ if (parent === dir) break;
88
+ dir = parent;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // URI resolution
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Resolve the best URI for a given absolute file path.
99
+ */
100
+ export function resolveUri(absPath: string, options?: ResolveUriOptions): string {
101
+ const ext = extname(absPath).toLowerCase();
102
+ const config = options?.config ?? loadConfig();
103
+ const mdservePort = options?.mdservePort;
104
+ const projectRoot = options?.projectRoot ?? process.cwd();
105
+
106
+ // Markdown → mdserve if running, else file://
107
+ if (MARKDOWN_EXTS.has(ext)) {
108
+ if (mdservePort) {
109
+ const rel = relative(projectRoot, absPath);
110
+ return `http://localhost:${mdservePort}/${rel}`;
111
+ }
112
+ return `file://${absPath}`;
113
+ }
114
+
115
+ // Excalidraw → Obsidian if vault detected, else file://
116
+ if (ext === ".excalidraw") {
117
+ const vault = detectObsidianVault(absPath);
118
+ if (vault) {
119
+ const relPath = relative(vault.vaultRoot, absPath);
120
+ return `obsidian://open?vault=${encodeURIComponent(vault.vaultName)}&file=${encodeURIComponent(relPath)}`;
121
+ }
122
+ return `file://${absPath}`;
123
+ }
124
+
125
+ // Code files → editor scheme if configured, else file://
126
+ if (CODE_EXTS.has(ext)) {
127
+ const editor = config.editor;
128
+ if (editor && KNOWN_EDITORS.has(editor)) {
129
+ return `${editor}://file/${absPath}`;
130
+ }
131
+ return `file://${absPath}`;
132
+ }
133
+
134
+ // Everything else (images, PDFs, etc.) → file://
135
+ return `file://${absPath}`;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // OSC 8 helpers
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Wrap text in an OSC 8 hyperlink escape sequence.
144
+ * Terminals that don't support OSC 8 simply ignore the sequences.
145
+ */
146
+ export function osc8Link(uri: string, text: string): string {
147
+ return `\x1b]8;;${uri}\x1b\\${text}\x1b]8;;\x1b\\`;
148
+ }
@@ -0,0 +1,182 @@
1
+ // @secret BRAVE_API_KEY "Brave Search API key"
2
+ // @secret TAVILY_API_KEY "Tavily Search API key"
3
+ // @secret SERPER_API_KEY "Serper/Google Search API key"
4
+
5
+ import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
6
+ import { Type } from "@sinclair/typebox";
7
+ import { StringEnum } from "../lib/typebox-helpers";
8
+ import { getAvailableProviders, getProvider, type SearchResult } from "./providers.ts";
9
+
10
+ function deduplicateResults(results: SearchResult[]): SearchResult[] {
11
+ const seen = new Map<string, SearchResult>();
12
+ for (const r of results) {
13
+ const key = r.url.replace(/\/$/, "").toLowerCase();
14
+ if (seen.has(key)) {
15
+ // Merge provider attribution
16
+ const existing = seen.get(key)!;
17
+ if (!existing.provider.includes(r.provider)) {
18
+ existing.provider += `, ${r.provider}`;
19
+ }
20
+ // Prefer longer snippet
21
+ if (r.snippet.length > existing.snippet.length) {
22
+ existing.snippet = r.snippet;
23
+ }
24
+ if (r.content && (!existing.content || r.content.length > existing.content.length)) {
25
+ existing.content = r.content;
26
+ }
27
+ } else {
28
+ seen.set(key, { ...r });
29
+ }
30
+ }
31
+ return Array.from(seen.values());
32
+ }
33
+
34
+ function formatResults(results: SearchResult[], mode: string): string {
35
+ if (results.length === 0) return "No results found.";
36
+
37
+ const lines: string[] = [];
38
+ for (const r of results) {
39
+ lines.push(`### ${r.title}`);
40
+ lines.push(`**URL:** ${r.url}`);
41
+ lines.push(`**Source:** ${r.provider}`);
42
+ lines.push(`${r.snippet}`);
43
+ if (r.content) {
44
+ lines.push(`\n<extracted_content>\n${r.content.slice(0, 2000)}\n</extracted_content>`);
45
+ }
46
+ lines.push("");
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+
51
+ export default function (pi: ExtensionAPI) {
52
+ // Secrets are resolved into process.env by the 00-secrets extension
53
+ // before this extension loads. No .env files needed.
54
+
55
+ pi.registerTool({
56
+ name: "web_search",
57
+ label: "Web Search",
58
+ description: `Search the web using multiple providers. Available modes:
59
+ - "quick": Use a single provider (fastest)
60
+ - "deep": Use a single provider, more results
61
+ - "compare": Fan out to ALL configured providers in parallel, deduplicate results. Best for research and verification.
62
+
63
+ Available providers: brave (independent index), tavily (AI-optimized, extracts content), serper (Google results).
64
+ Only providers with configured API keys are available.`,
65
+ promptSnippet: "Search the web via multiple providers (brave, tavily, serper) with quick/deep/compare modes",
66
+ promptGuidelines: [
67
+ "Use 'compare' mode for research requiring cross-source verification",
68
+ ],
69
+ parameters: Type.Object({
70
+ query: Type.String({ description: "Search query" }),
71
+ provider: Type.Optional(
72
+ StringEnum(["brave", "tavily", "serper"], {
73
+ description: "Specific provider. Omit to auto-select (quick) or fan out (compare).",
74
+ })
75
+ ),
76
+ mode: Type.Optional(
77
+ StringEnum(["quick", "deep", "compare"], {
78
+ description: "Search mode. Default: quick",
79
+ })
80
+ ),
81
+ max_results: Type.Optional(
82
+ Type.Number({ description: "Max results per provider. Default: 5", minimum: 1, maximum: 20 })
83
+ ),
84
+ topic: Type.Optional(
85
+ StringEnum(["general", "news"], {
86
+ description: "Search topic. Default: general",
87
+ })
88
+ ),
89
+ }),
90
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
91
+ const mode = params.mode || "quick";
92
+ const maxResults = params.max_results || (mode === "deep" ? 10 : 5);
93
+ const topic = params.topic || "general";
94
+ const available = getAvailableProviders();
95
+
96
+ if (available.length === 0) {
97
+ return {
98
+ content: [{
99
+ type: "text",
100
+ text: "No search providers configured. Run `/secrets configure BRAVE_API_KEY` (or TAVILY_API_KEY, SERPER_API_KEY) to set up at least one provider.",
101
+ }],
102
+ details: { error: true },
103
+ };
104
+ }
105
+
106
+ let results: SearchResult[] = [];
107
+ let providersUsed: string[] = [];
108
+
109
+ if (mode === "compare") {
110
+ // Fan out to all available providers in parallel
111
+ const settled = await Promise.allSettled(
112
+ available.map((p) => p.search(params.query, maxResults, topic))
113
+ );
114
+ for (let i = 0; i < settled.length; i++) {
115
+ const outcome = settled[i];
116
+ if (outcome.status === "fulfilled") {
117
+ results.push(...outcome.value);
118
+ providersUsed.push(available[i].name);
119
+ } else {
120
+ providersUsed.push(`${available[i].name} (error: ${outcome.reason?.message || "unknown"})`);
121
+ }
122
+ }
123
+ results = deduplicateResults(results);
124
+ } else {
125
+ // Single provider
126
+ let provider;
127
+ if (params.provider) {
128
+ provider = getProvider(params.provider);
129
+ if (!provider) {
130
+ return {
131
+ content: [{
132
+ type: "text",
133
+ text: `Provider "${params.provider}" not available. Configured: ${available.map((p) => p.name).join(", ")}`,
134
+ }],
135
+ details: { error: true },
136
+ };
137
+ }
138
+ } else {
139
+ // Auto-select: prefer tavily (content extraction), then serper (google), then brave
140
+ provider =
141
+ available.find((p) => p.name === "tavily") ||
142
+ available.find((p) => p.name === "serper") ||
143
+ available[0];
144
+ }
145
+ try {
146
+ results = await provider.search(params.query, maxResults, topic);
147
+ providersUsed.push(provider.name);
148
+ } catch (err: any) {
149
+ return {
150
+ content: [{ type: "text", text: `Search error (${provider.name}): ${err.message}` }],
151
+ details: { error: true },
152
+ };
153
+ }
154
+ }
155
+
156
+ const header = `**Query:** ${params.query}\n**Mode:** ${mode} | **Providers:** ${providersUsed.join(", ")} | **Results:** ${results.length}\n\n---\n\n`;
157
+ const body = formatResults(results, mode);
158
+
159
+ return {
160
+ content: [{ type: "text", text: header + body }],
161
+ details: {
162
+ resultCount: results.length,
163
+ providers: providersUsed,
164
+ mode,
165
+ },
166
+ };
167
+ },
168
+ });
169
+
170
+ // Notify on load with provider status
171
+ pi.on("session_start", async (_event, ctx) => {
172
+ const available = getAvailableProviders();
173
+ if (available.length > 0) {
174
+ ctx.ui.notify(
175
+ `Web Search: ${available.map((p) => p.name).join(", ")} ready`,
176
+ "info"
177
+ );
178
+ } else {
179
+ ctx.ui.notify("Web Search: No API keys configured", "warning");
180
+ }
181
+ });
182
+ }
@@ -0,0 +1,121 @@
1
+ // Web search provider implementations
2
+
3
+ export interface SearchResult {
4
+ title: string;
5
+ url: string;
6
+ snippet: string;
7
+ content?: string; // Tavily returns extracted content
8
+ provider: string;
9
+ }
10
+
11
+ export interface SearchProvider {
12
+ name: string;
13
+ available: boolean;
14
+ search(query: string, maxResults: number, topic: string): Promise<SearchResult[]>;
15
+ }
16
+
17
+ // --- Brave Search ---
18
+
19
+ function braveProvider(): SearchProvider {
20
+ const apiKey = process.env.BRAVE_API_KEY;
21
+ return {
22
+ name: "brave",
23
+ available: !!apiKey,
24
+ async search(query, maxResults, topic) {
25
+ const params = new URLSearchParams({
26
+ q: query,
27
+ count: String(maxResults),
28
+ ...(topic === "news" ? { freshness: "pd" } : {}),
29
+ });
30
+ const res = await fetch(
31
+ `https://api.search.brave.com/res/v1/web/search?${params}`,
32
+ { headers: { "X-Subscription-Token": apiKey!, Accept: "application/json" } }
33
+ );
34
+ if (!res.ok) throw new Error(`Brave ${res.status}: ${await res.text()}`);
35
+ const data = await res.json();
36
+ return (data.web?.results || []).slice(0, maxResults).map((r: any) => ({
37
+ title: r.title,
38
+ url: r.url,
39
+ snippet: r.description || "",
40
+ provider: "brave",
41
+ }));
42
+ },
43
+ };
44
+ }
45
+
46
+ // --- Tavily ---
47
+
48
+ function tavilyProvider(): SearchProvider {
49
+ const apiKey = process.env.TAVILY_API_KEY;
50
+ return {
51
+ name: "tavily",
52
+ available: !!apiKey,
53
+ async search(query, maxResults, topic) {
54
+ const res = await fetch("https://api.tavily.com/search", {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify({
58
+ api_key: apiKey,
59
+ query,
60
+ max_results: maxResults,
61
+ include_answer: false,
62
+ include_raw_content: false,
63
+ topic: topic === "news" ? "news" : "general",
64
+ }),
65
+ });
66
+ if (!res.ok) throw new Error(`Tavily ${res.status}: ${await res.text()}`);
67
+ const data = await res.json();
68
+ return (data.results || []).slice(0, maxResults).map((r: any) => ({
69
+ title: r.title,
70
+ url: r.url,
71
+ snippet: r.content || "",
72
+ content: r.raw_content || undefined,
73
+ provider: "tavily",
74
+ }));
75
+ },
76
+ };
77
+ }
78
+
79
+ // --- Serper (Google) ---
80
+
81
+ function serperProvider(): SearchProvider {
82
+ const apiKey = process.env.SERPER_API_KEY;
83
+ return {
84
+ name: "serper",
85
+ available: !!apiKey,
86
+ async search(query, maxResults, topic) {
87
+ const endpoint =
88
+ topic === "news"
89
+ ? "https://google.serper.dev/news"
90
+ : "https://google.serper.dev/search";
91
+ const res = await fetch(endpoint, {
92
+ method: "POST",
93
+ headers: { "X-API-KEY": apiKey!, "Content-Type": "application/json" },
94
+ body: JSON.stringify({ q: query, num: maxResults }),
95
+ });
96
+ if (!res.ok) throw new Error(`Serper ${res.status}: ${await res.text()}`);
97
+ const data = await res.json();
98
+ const results = topic === "news" ? data.news || [] : data.organic || [];
99
+ return results.slice(0, maxResults).map((r: any) => ({
100
+ title: r.title,
101
+ url: r.link,
102
+ snippet: r.snippet || r.description || "",
103
+ provider: "serper",
104
+ }));
105
+ },
106
+ };
107
+ }
108
+
109
+ // --- Registry ---
110
+
111
+ export function getProviders(): SearchProvider[] {
112
+ return [braveProvider(), tavilyProvider(), serperProvider()];
113
+ }
114
+
115
+ export function getAvailableProviders(): SearchProvider[] {
116
+ return getProviders().filter((p) => p.available);
117
+ }
118
+
119
+ export function getProvider(name: string): SearchProvider | undefined {
120
+ return getProviders().find((p) => p.name === name && p.available);
121
+ }
@@ -0,0 +1,110 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
3
+ import { startWebUIServer, type WebUIServer } from "./server.ts";
4
+
5
+ let server: WebUIServer | null = null;
6
+ let spawnFn: typeof nodeSpawn = nodeSpawn;
7
+
8
+ export function _setSpawnFn(fn: typeof nodeSpawn): typeof nodeSpawn {
9
+ const prev = spawnFn;
10
+ spawnFn = fn;
11
+ return prev;
12
+ }
13
+
14
+ export function _setServer(next: WebUIServer | null): void {
15
+ server = next;
16
+ }
17
+
18
+ /** Returns the current server instance. Prefer this over the live-binding export for reliable cross-Node-version semantics. */
19
+ export function _getServer(): WebUIServer | null {
20
+ return server;
21
+ }
22
+
23
+ function openBrowser(url: string, ctx: Parameters<typeof notify>[0]): void {
24
+ let cmd: string;
25
+ let args: string[];
26
+
27
+ if (process.platform === "darwin") {
28
+ cmd = "open";
29
+ args = [url];
30
+ } else if (process.platform === "win32") {
31
+ // cmd.exe `start` requires an empty-string window-title when the next token is a URL.
32
+ cmd = "cmd";
33
+ args = ["/c", "start", "", url];
34
+ } else {
35
+ cmd = "xdg-open";
36
+ args = [url];
37
+ }
38
+
39
+ const child = spawnFn(cmd, args, { stdio: "ignore" });
40
+ if (typeof child.on === "function") {
41
+ child.on("error", (err: Error) => {
42
+ notify(ctx, `Failed to open browser (${cmd}): ${err.message}`);
43
+ });
44
+ }
45
+ if (typeof child.unref === "function") {
46
+ child.unref();
47
+ }
48
+ }
49
+
50
+ function notify(ctx: { ui?: { notify?: (msg: string, level?: "info" | "warning" | "error") => void } }, message: string): void {
51
+ if (typeof ctx.ui?.notify === "function") ctx.ui.notify(message, "info");
52
+ }
53
+
54
+ export default function webUiExtension(pi: ExtensionAPI): void {
55
+ pi.registerCommand("web-ui", {
56
+ description: "Localhost-only read-only web UI dashboard (/web-ui [start|stop|status|open])",
57
+ handler: async (args, ctx) => {
58
+ const subcommand = args.trim().split(/\s+/)[0]?.toLowerCase() || "status";
59
+
60
+ switch (subcommand) {
61
+ case "status":
62
+ case "": {
63
+ if (!server) {
64
+ notify(ctx, "web-ui server is stopped. Run `/web-ui start` to start it.");
65
+ } else {
66
+ const uptimeSec = Math.round((Date.now() - server.startedAt) / 1000);
67
+ notify(ctx, `web-ui server is running at ${server.url} (uptime ${uptimeSec}s)`);
68
+ }
69
+ return;
70
+ }
71
+ case "start": {
72
+ if (server) {
73
+ notify(ctx, `web-ui server is already running at ${server.url}`);
74
+ return;
75
+ }
76
+ server = await startWebUIServer({ repoRoot: ctx.cwd ?? process.cwd() });
77
+ notify(ctx, `web-ui server started at ${server.url}. Run \`/web-ui open\` to open it in your browser.`);
78
+ return;
79
+ }
80
+ case "stop": {
81
+ if (!server) {
82
+ notify(ctx, "web-ui server is not running.");
83
+ return;
84
+ }
85
+ await server.stop().catch(() => {});
86
+ server = null;
87
+ notify(ctx, "web-ui server stopped.");
88
+ return;
89
+ }
90
+ case "open": {
91
+ if (!server) {
92
+ notify(ctx, "web-ui server is not running. Run `/web-ui start` first.");
93
+ return;
94
+ }
95
+ openBrowser(server.url, ctx);
96
+ notify(ctx, `Opening ${server.url} in your default browser…`);
97
+ return;
98
+ }
99
+ default:
100
+ notify(ctx, "Usage: /web-ui [start|stop|status|open]");
101
+ }
102
+ },
103
+ });
104
+
105
+ pi.on("session_shutdown", async () => {
106
+ if (!server) return;
107
+ await server.stop().catch(() => {});
108
+ server = null;
109
+ });
110
+ }