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.
- package/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// @config LOCAL_INFERENCE_URL "Ollama / OpenAI-compatible inference server URL" [default: http://localhost:11434]
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
|
|
4
|
+
import { Text } from "@cwilson613/pi-tui";
|
|
5
|
+
import { Type } from "@sinclair/typebox";
|
|
6
|
+
import {
|
|
7
|
+
KNOWN_MODELS,
|
|
8
|
+
PREFERRED_ORDER,
|
|
9
|
+
PREFERRED_ORDER_CODE,
|
|
10
|
+
} from "./lib/local-models.ts";
|
|
11
|
+
|
|
12
|
+
// Re-export so existing importers (effort, cleave) continue to work.
|
|
13
|
+
export { PREFERRED_ORDER, PREFERRED_ORDER_CODE };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Offline Driver Extension
|
|
17
|
+
*
|
|
18
|
+
* Provides seamless failover from cloud (Anthropic) to local (Ollama) models.
|
|
19
|
+
* Auto-registers available Ollama models via pi.registerProvider() on session start,
|
|
20
|
+
* eliminating the need for a static models.json config.
|
|
21
|
+
*
|
|
22
|
+
* Registers /offline and /online commands plus a switch_to_offline_driver tool
|
|
23
|
+
* the agent can self-invoke when it detects connectivity issues.
|
|
24
|
+
*
|
|
25
|
+
* Model registry and preference lists live in extensions/lib/local-models.ts.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const OLLAMA_URL = process.env.LOCAL_INFERENCE_URL || "http://localhost:11434";
|
|
29
|
+
const PROVIDER_NAME = "local";
|
|
30
|
+
|
|
31
|
+
// State
|
|
32
|
+
let savedCloudModel: string | null = null;
|
|
33
|
+
let savedCloudProvider: string | null = null;
|
|
34
|
+
let isOffline = false;
|
|
35
|
+
let registeredModels: string[] = [];
|
|
36
|
+
|
|
37
|
+
interface OllamaModel {
|
|
38
|
+
name: string;
|
|
39
|
+
model?: string;
|
|
40
|
+
size?: number;
|
|
41
|
+
details?: { parameter_size?: string; family?: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OfflineDriverSwitchResult {
|
|
45
|
+
success: boolean;
|
|
46
|
+
message: string;
|
|
47
|
+
provider: "local" | "cloud";
|
|
48
|
+
modelId?: string;
|
|
49
|
+
label?: string;
|
|
50
|
+
automatic: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function discoverOllamaModels(): Promise<OllamaModel[]> {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
|
56
|
+
if (!res.ok) return [];
|
|
57
|
+
const data = (await res.json()) as { models?: OllamaModel[] };
|
|
58
|
+
return data.models || [];
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeModelId(name: string): string {
|
|
65
|
+
return name.replace(/:latest$/, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function checkAnthropic(): Promise<boolean> {
|
|
69
|
+
try {
|
|
70
|
+
// Use GET on the models endpoint — lightweight, no billing, confirms API reachability.
|
|
71
|
+
// Any HTTP response (even 401) means the network path works.
|
|
72
|
+
const res = await fetch("https://api.anthropic.com/v1/models", {
|
|
73
|
+
method: "GET",
|
|
74
|
+
headers: { "anthropic-version": "2023-06-01" },
|
|
75
|
+
signal: AbortSignal.timeout(5000),
|
|
76
|
+
});
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Register discovered Ollama models as a pi provider via the official API.
|
|
85
|
+
* This lets pi-ai handle all streaming, token tracking, and protocol details.
|
|
86
|
+
*/
|
|
87
|
+
function registerOllamaProvider(pi: ExtensionAPI, ollamaModels: OllamaModel[]): string[] {
|
|
88
|
+
const chatModels = ollamaModels
|
|
89
|
+
.map((m) => normalizeModelId(m.name))
|
|
90
|
+
.filter((id) => !id.includes("embed")); // exclude embedding models
|
|
91
|
+
|
|
92
|
+
if (chatModels.length === 0) return [];
|
|
93
|
+
|
|
94
|
+
const models = chatModels.map((id) => {
|
|
95
|
+
const known = KNOWN_MODELS[id];
|
|
96
|
+
return {
|
|
97
|
+
id,
|
|
98
|
+
name: known?.label || id,
|
|
99
|
+
reasoning: false,
|
|
100
|
+
input: ["text"] as ("text" | "image")[],
|
|
101
|
+
contextWindow: known?.contextWindow || 131072,
|
|
102
|
+
maxTokens: known?.maxTokens || 32768,
|
|
103
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
104
|
+
compat: {
|
|
105
|
+
supportsDeveloperRole: false,
|
|
106
|
+
supportsReasoningEffort: false,
|
|
107
|
+
maxTokensField: "max_tokens" as const,
|
|
108
|
+
requiresThinkingAsText: true,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.registerProvider(PROVIDER_NAME, {
|
|
114
|
+
baseUrl: `${OLLAMA_URL}/v1`,
|
|
115
|
+
api: "openai-completions",
|
|
116
|
+
apiKey: "ollama",
|
|
117
|
+
models,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return chatModels;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function switchToOfflineDriver(
|
|
124
|
+
pi: ExtensionAPI,
|
|
125
|
+
ctx: any,
|
|
126
|
+
options: { preferredModel?: string; automatic?: boolean } = {}
|
|
127
|
+
): Promise<OfflineDriverSwitchResult> {
|
|
128
|
+
const preferredModel = options.preferredModel;
|
|
129
|
+
const automatic = options.automatic ?? false;
|
|
130
|
+
if (isOffline) {
|
|
131
|
+
return { success: true, message: "Already in offline mode.", provider: "local", automatic };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Save current cloud model for /online restoration
|
|
135
|
+
const current = ctx.model;
|
|
136
|
+
if (current && current.provider !== PROVIDER_NAME) {
|
|
137
|
+
savedCloudModel = current.id;
|
|
138
|
+
savedCloudProvider = current.provider;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Re-discover and register in case models changed
|
|
142
|
+
const ollamaModels = await discoverOllamaModels();
|
|
143
|
+
if (ollamaModels.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
message: `Ollama not available at ${OLLAMA_URL}. Is it running? Start with: ollama serve`,
|
|
147
|
+
provider: "local",
|
|
148
|
+
automatic,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
registeredModels = registerOllamaProvider(pi, ollamaModels);
|
|
152
|
+
|
|
153
|
+
// Select model: preferred > priority order > first available
|
|
154
|
+
const targetId = preferredModel && registeredModels.includes(preferredModel)
|
|
155
|
+
? preferredModel
|
|
156
|
+
: PREFERRED_ORDER.find((id) => registeredModels.includes(id)) || registeredModels[0];
|
|
157
|
+
|
|
158
|
+
if (!targetId) {
|
|
159
|
+
return { success: false, message: "No chat models available in Ollama.", provider: "local", automatic };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const model = ctx.modelRegistry.find(PROVIDER_NAME, targetId);
|
|
163
|
+
if (!model) {
|
|
164
|
+
return {
|
|
165
|
+
success: false,
|
|
166
|
+
message: `Model ${targetId} not found in registry after registration.`,
|
|
167
|
+
provider: "local",
|
|
168
|
+
automatic,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const success = await pi.setModel(model);
|
|
173
|
+
if (success) {
|
|
174
|
+
isOffline = true;
|
|
175
|
+
const known = KNOWN_MODELS[targetId];
|
|
176
|
+
const icon = known?.icon || "🏠";
|
|
177
|
+
const label = known?.label || targetId;
|
|
178
|
+
ctx.ui.setStatus("offline-driver", `${icon} OFFLINE: ${label}`);
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
message: `Switched to offline driver: ${label} (${targetId})`,
|
|
182
|
+
provider: "local",
|
|
183
|
+
modelId: targetId,
|
|
184
|
+
label,
|
|
185
|
+
automatic,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { success: false, message: "Failed to set offline model.", provider: "local", automatic };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function restoreCloudDriver(
|
|
193
|
+
pi: ExtensionAPI,
|
|
194
|
+
ctx: any,
|
|
195
|
+
options: { automatic?: boolean } = {}
|
|
196
|
+
): Promise<OfflineDriverSwitchResult> {
|
|
197
|
+
const automatic = options.automatic ?? false;
|
|
198
|
+
if (!isOffline) {
|
|
199
|
+
return { success: true, message: "Already in online mode.", provider: "cloud", automatic };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const provider = savedCloudProvider || "anthropic";
|
|
203
|
+
let modelId = savedCloudModel;
|
|
204
|
+
|
|
205
|
+
// If no saved model, find the best available gloriana-class model by prefix
|
|
206
|
+
if (!modelId) {
|
|
207
|
+
const all = ctx.modelRegistry.getAll();
|
|
208
|
+
const topTier = all
|
|
209
|
+
.filter((m: any) => m.provider === "anthropic" && m.id.startsWith("claude-opus"))
|
|
210
|
+
.sort((a: any, b: any) => b.id.localeCompare(a.id));
|
|
211
|
+
modelId = topTier[0]?.id;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!modelId) {
|
|
215
|
+
return { success: false, message: "No Anthropic gloriana-class model found in registry.", provider: "cloud", automatic };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
219
|
+
|
|
220
|
+
if (!model) {
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
message: `Cannot restore cloud model ${provider}/${modelId} — not found in registry.`,
|
|
224
|
+
provider: "cloud",
|
|
225
|
+
automatic,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const anthropicOk = await checkAnthropic();
|
|
230
|
+
if (!anthropicOk) {
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
message: "Anthropic API still unreachable. Staying offline. Retry with /online when connectivity is restored.",
|
|
234
|
+
provider: "cloud",
|
|
235
|
+
automatic,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const success = await pi.setModel(model);
|
|
240
|
+
if (success) {
|
|
241
|
+
isOffline = false;
|
|
242
|
+
ctx.ui.setStatus("offline-driver", "");
|
|
243
|
+
return {
|
|
244
|
+
success: true,
|
|
245
|
+
message: `Restored cloud driver: ${provider}/${modelId}`,
|
|
246
|
+
provider: "cloud",
|
|
247
|
+
modelId,
|
|
248
|
+
label: `${provider}/${modelId}`,
|
|
249
|
+
automatic,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { success: false, message: "Failed to restore cloud model.", provider: "cloud", automatic };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default function (pi: ExtensionAPI) {
|
|
257
|
+
// Auto-discover and register Ollama models on session start
|
|
258
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
259
|
+
const [anthropicOk, ollamaModels] = await Promise.all([
|
|
260
|
+
checkAnthropic(),
|
|
261
|
+
discoverOllamaModels(),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
// Register Ollama models via pi.registerProvider() — pi-ai handles streaming
|
|
265
|
+
if (ollamaModels.length > 0) {
|
|
266
|
+
registeredModels = registerOllamaProvider(pi, ollamaModels);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const driverModels = registeredModels.filter((id) => PREFERRED_ORDER.includes(id));
|
|
270
|
+
const parts: string[] = [];
|
|
271
|
+
|
|
272
|
+
if (anthropicOk) {
|
|
273
|
+
parts.push("☁️ Anthropic: reachable");
|
|
274
|
+
} else {
|
|
275
|
+
parts.push("⚠️ Anthropic: UNREACHABLE");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (registeredModels.length > 0) {
|
|
279
|
+
const names = driverModels.map((id) => KNOWN_MODELS[id]?.label || id).join(", ");
|
|
280
|
+
parts.push(
|
|
281
|
+
`🏠 Ollama: ${driverModels.length} driver model${driverModels.length !== 1 ? "s" : ""} registered${driverModels.length > 0 ? ` (${names})` : ""}`
|
|
282
|
+
);
|
|
283
|
+
} else {
|
|
284
|
+
parts.push("🏠 Ollama: not running");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
ctx.ui.notify(parts.join(" | "), anthropicOk ? "info" : "warning");
|
|
288
|
+
|
|
289
|
+
// Save starting cloud model
|
|
290
|
+
const current = ctx.model;
|
|
291
|
+
if (current && current.provider !== PROVIDER_NAME) {
|
|
292
|
+
savedCloudModel = current.id;
|
|
293
|
+
savedCloudProvider = current.provider;
|
|
294
|
+
}
|
|
295
|
+
isOffline = false;
|
|
296
|
+
|
|
297
|
+
if (!anthropicOk && driverModels.length > 0) {
|
|
298
|
+
ctx.ui.notify("💡 Cloud unavailable. Use /offline to switch to local driver.", "warning");
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// /offline command
|
|
303
|
+
pi.registerCommand("offline", {
|
|
304
|
+
description: "Switch to best available local model as the driving agent",
|
|
305
|
+
handler: async (args, ctx) => {
|
|
306
|
+
const preferredModel = args?.trim() || undefined;
|
|
307
|
+
const result = await switchToOfflineDriver(pi, ctx, { preferredModel });
|
|
308
|
+
ctx.ui.notify(result.message, result.success ? "info" : "error");
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// /online command
|
|
313
|
+
pi.registerCommand("online", {
|
|
314
|
+
description: "Restore the cloud (Anthropic) model as the driving agent",
|
|
315
|
+
handler: async (_args, ctx) => {
|
|
316
|
+
const result = await restoreCloudDriver(pi, ctx);
|
|
317
|
+
ctx.ui.notify(result.message, result.success ? "info" : "error");
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Agent-invocable tool for self-recovery
|
|
322
|
+
pi.registerTool({
|
|
323
|
+
name: "switch_to_offline_driver",
|
|
324
|
+
label: "Switch to Offline Driver",
|
|
325
|
+
description:
|
|
326
|
+
"Switch the driving model from cloud (Anthropic) to a local offline model (Ollama). " +
|
|
327
|
+
"Use when you detect connectivity issues, API errors, or when the user requests offline mode. " +
|
|
328
|
+
"The best available local model is auto-selected: Nemotron 3 Nano (1M context), " +
|
|
329
|
+
"Devstral Small 2 (384K, code-focused), or Qwen3 30B (256K, general).",
|
|
330
|
+
promptSnippet: "Switch from cloud to local Ollama model for offline operation or API failure recovery",
|
|
331
|
+
promptGuidelines: [
|
|
332
|
+
"Use when detecting repeated API errors, timeouts, or connectivity failures",
|
|
333
|
+
],
|
|
334
|
+
parameters: Type.Object({
|
|
335
|
+
reason: Type.String({
|
|
336
|
+
description: "Why switching to offline mode",
|
|
337
|
+
}),
|
|
338
|
+
preferred_model: Type.Optional(
|
|
339
|
+
Type.String({
|
|
340
|
+
description:
|
|
341
|
+
"Optional: specific model ID to use. Examples by size: 70B→qwen2.5:72b/llama3.3:70b, 32B→qwen3:32b/qwen2.5-coder:32b, 14B→qwen3:14b, 8B→qwen3:8b/llama3.1:8b, 4B→qwen3:4b. Omit to auto-select best available.",
|
|
342
|
+
})
|
|
343
|
+
),
|
|
344
|
+
}),
|
|
345
|
+
execute: async (
|
|
346
|
+
_toolCallId,
|
|
347
|
+
params: { reason: string; preferred_model?: string },
|
|
348
|
+
_signal,
|
|
349
|
+
_onUpdate,
|
|
350
|
+
ctx
|
|
351
|
+
) => {
|
|
352
|
+
const result = await switchToOfflineDriver(pi, ctx, {
|
|
353
|
+
preferredModel: params.preferred_model,
|
|
354
|
+
automatic: true,
|
|
355
|
+
});
|
|
356
|
+
if (result.success) {
|
|
357
|
+
ctx.ui.notify(`🔌 Offline: ${params.reason}`, "info");
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
content: [
|
|
361
|
+
{
|
|
362
|
+
type: "text" as const,
|
|
363
|
+
text: `${result.success ? "✅" : "❌"} ${result.message}${result.success ? ` (reason: ${params.reason})` : ""}`,
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
details: { success: result.success, message: result.message },
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
renderCall(args, t) {
|
|
370
|
+
const reason = typeof args.reason === "string" ? args.reason : "";
|
|
371
|
+
const model = typeof args.preferred_model === "string" ? args.preferred_model : "";
|
|
372
|
+
const truncReason = reason.length > 72 ? `${reason.slice(0, 69)}…` : reason;
|
|
373
|
+
const modelSuffix = model ? ` ${t.fg("dim", model)}` : "";
|
|
374
|
+
return new Text(
|
|
375
|
+
`${t.fg("warning", "⟳")} ${t.fg("toolTitle", "offline-driver")}${modelSuffix} ${t.fg("muted", truncReason)}`,
|
|
376
|
+
0, 0
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
renderResult(result, _opts, t) {
|
|
380
|
+
const details = result.details as { success?: boolean; message?: string } | undefined;
|
|
381
|
+
const firstContent = result.content?.[0];
|
|
382
|
+
const msg = details?.message ?? (firstContent?.type === "text" ? firstContent.text : "") ?? "";
|
|
383
|
+
// Parse "Switched to offline driver: Display Name (model-id)"
|
|
384
|
+
const switchMatch = msg.match(/Switched to offline driver:\s*(.+?)\s*\(([^)]+)\)/);
|
|
385
|
+
if (switchMatch) {
|
|
386
|
+
const displayName = switchMatch[1];
|
|
387
|
+
const modelId = switchMatch[2];
|
|
388
|
+
return new Text(
|
|
389
|
+
`${t.fg("success", "✓")} ${t.fg("toolTitle", "offline")} ${t.fg("accent", t.bold(displayName))} ${t.fg("dim", modelId)}`,
|
|
390
|
+
0, 0
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (details?.success === false) {
|
|
394
|
+
const errMsg = msg.replace(/^❌\s*/, "").replace(/Failed to switch.*?:\s*/i, "");
|
|
395
|
+
return new Text(
|
|
396
|
+
`${t.fg("error", "✗")} ${t.fg("toolTitle", "offline-driver")} ${t.fg("error", errMsg.slice(0, 80))}`,
|
|
397
|
+
0, 0
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return new Text(t.fg("toolOutput", msg.replace(/^[✅❌]\s*/, "")), 0, 0);
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design tree archive/lifecycle gate helpers.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes OpenSpec ↔ design-tree binding truth so status surfaces,
|
|
5
|
+
* reconciliation, and archive transitions all agree on whether a change is
|
|
6
|
+
* bound to a design node.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { scanDesignDocs, writeNodeDocument, getNodeSections } from "../design-tree/tree.ts";
|
|
11
|
+
import type { DesignNode } from "../design-tree/types.ts";
|
|
12
|
+
|
|
13
|
+
export type OpenSpecBindingMatch = "explicit" | "id-fallback";
|
|
14
|
+
|
|
15
|
+
// ─── Design-phase spec binding ───────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Result of resolving whether a design node has a design-phase OpenSpec change.
|
|
19
|
+
*
|
|
20
|
+
* Paths checked:
|
|
21
|
+
* - active: openspec/design/<nodeId>/ (a live design-phase change)
|
|
22
|
+
* - archived: openspec/design-archive/YYYY-MM-DD-<nodeId>/ (completed and archived)
|
|
23
|
+
*
|
|
24
|
+
* Exactly one of {active, archived} can be true; missing is true when neither exists.
|
|
25
|
+
*/
|
|
26
|
+
export interface DesignSpecBinding {
|
|
27
|
+
/** A completed design-phase change exists in openspec/design-archive/. */
|
|
28
|
+
archived: boolean;
|
|
29
|
+
/** A live design-phase change exists in openspec/design/. */
|
|
30
|
+
active: boolean;
|
|
31
|
+
/** No design-phase change found in either location. */
|
|
32
|
+
missing: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Scan the design-phase OpenSpec directories for a node's design change.
|
|
37
|
+
*
|
|
38
|
+
* @param cwd Project root
|
|
39
|
+
* @param nodeId Design node ID to match
|
|
40
|
+
*/
|
|
41
|
+
export function resolveDesignSpecBinding(cwd: string, nodeId: string): DesignSpecBinding {
|
|
42
|
+
const designDir = path.join(cwd, "openspec", "design", nodeId);
|
|
43
|
+
const designArchiveDir = path.join(cwd, "openspec", "design-archive");
|
|
44
|
+
|
|
45
|
+
// W1: require at least one file inside the directory before treating it as active
|
|
46
|
+
const active =
|
|
47
|
+
fs.existsSync(designDir) &&
|
|
48
|
+
fs.statSync(designDir).isDirectory() &&
|
|
49
|
+
fs.readdirSync(designDir).length > 0;
|
|
50
|
+
|
|
51
|
+
// W2: scan both branches unconditionally — active takes precedence over archived
|
|
52
|
+
// but we still detect archived so callers can surface manual-recovery conflicts.
|
|
53
|
+
let archived = false;
|
|
54
|
+
if (fs.existsSync(designArchiveDir)) {
|
|
55
|
+
for (const entry of fs.readdirSync(designArchiveDir, { withFileTypes: true })) {
|
|
56
|
+
if (!entry.isDirectory()) continue;
|
|
57
|
+
// Match YYYY-MM-DD-<nodeId> convention
|
|
58
|
+
const match = entry.name.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
|
|
59
|
+
if (match && match[1] === nodeId) {
|
|
60
|
+
archived = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Precedence: active wins over archived (active change is not yet complete)
|
|
67
|
+
return { archived: archived && !active, active, missing: !active && !archived };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface OpenSpecBindingResolution {
|
|
71
|
+
bound: boolean;
|
|
72
|
+
changeName: string | null;
|
|
73
|
+
match: OpenSpecBindingMatch | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function listKnownOpenSpecChangeNames(cwd: string): Set<string> {
|
|
77
|
+
const names = new Set<string>();
|
|
78
|
+
const openspecDir = path.join(cwd, "openspec");
|
|
79
|
+
const changesDir = path.join(openspecDir, "changes");
|
|
80
|
+
const archiveDir = path.join(openspecDir, "archive");
|
|
81
|
+
|
|
82
|
+
if (fs.existsSync(changesDir)) {
|
|
83
|
+
for (const entry of fs.readdirSync(changesDir, { withFileTypes: true })) {
|
|
84
|
+
if (entry.isDirectory()) names.add(entry.name);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (fs.existsSync(archiveDir)) {
|
|
89
|
+
for (const entry of fs.readdirSync(archiveDir, { withFileTypes: true })) {
|
|
90
|
+
if (!entry.isDirectory()) continue;
|
|
91
|
+
const match = entry.name.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
|
|
92
|
+
names.add(match ? match[1] : entry.name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return names;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveNodeOpenSpecBinding(cwd: string, node: DesignNode): OpenSpecBindingResolution {
|
|
100
|
+
const knownChangeNames = listKnownOpenSpecChangeNames(cwd);
|
|
101
|
+
|
|
102
|
+
if (node.openspec_change) {
|
|
103
|
+
if (knownChangeNames.has(node.openspec_change)) {
|
|
104
|
+
return {
|
|
105
|
+
bound: true,
|
|
106
|
+
changeName: node.openspec_change,
|
|
107
|
+
match: "explicit",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
bound: false,
|
|
112
|
+
changeName: node.openspec_change,
|
|
113
|
+
match: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (knownChangeNames.has(node.id)) {
|
|
118
|
+
return {
|
|
119
|
+
bound: true,
|
|
120
|
+
changeName: node.id,
|
|
121
|
+
match: "id-fallback",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
bound: false,
|
|
127
|
+
changeName: null,
|
|
128
|
+
match: null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolveBoundDesignNodes(cwd: string, changeName: string): DesignNode[] {
|
|
133
|
+
const docsDir = path.join(cwd, "docs");
|
|
134
|
+
if (!fs.existsSync(docsDir)) return [];
|
|
135
|
+
|
|
136
|
+
const tree = scanDesignDocs(docsDir);
|
|
137
|
+
return Array.from(tree.nodes.values()).filter((node) => {
|
|
138
|
+
const binding = resolveNodeOpenSpecBinding(cwd, node);
|
|
139
|
+
return binding.bound && binding.changeName === changeName;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Scan the design tree for nodes matching the archived OpenSpec change.
|
|
145
|
+
* Matches by explicit `openspec_change` frontmatter field OR by convention
|
|
146
|
+
* (node ID = change name) using the shared binding resolver. Transitions
|
|
147
|
+
* `implementing` or `decided` nodes to `implemented`.
|
|
148
|
+
*
|
|
149
|
+
* @param cwd Project root (parent of the docs/ directory)
|
|
150
|
+
* @param changeName OpenSpec change name to match against
|
|
151
|
+
* @returns IDs of nodes transitioned to implemented
|
|
152
|
+
*/
|
|
153
|
+
export function transitionDesignNodesOnArchive(cwd: string, changeName: string): string[] {
|
|
154
|
+
const transitioned: string[] = [];
|
|
155
|
+
|
|
156
|
+
for (const node of resolveBoundDesignNodes(cwd, changeName)) {
|
|
157
|
+
const transitionable = node.status === "implementing" || node.status === "decided";
|
|
158
|
+
if (!transitionable) continue;
|
|
159
|
+
const sections = getNodeSections(node);
|
|
160
|
+
writeNodeDocument({ ...node, status: "implemented" }, sections);
|
|
161
|
+
transitioned.push(node.id);
|
|
162
|
+
}
|
|
163
|
+
return transitioned;
|
|
164
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Delete local git branches that are fully merged into HEAD.
|
|
5
|
+
*
|
|
6
|
+
* Safety rules:
|
|
7
|
+
* - Never deletes `main` or `master`
|
|
8
|
+
* - Never deletes the current HEAD branch
|
|
9
|
+
* - Uses `git branch -d` (safe delete) — unmerged branches are skipped, not force-deleted
|
|
10
|
+
* - Deduplicates input before processing
|
|
11
|
+
* - Never touches remote refs
|
|
12
|
+
*/
|
|
13
|
+
export async function deleteMergedBranches(
|
|
14
|
+
pi: ExtensionAPI,
|
|
15
|
+
cwd: string,
|
|
16
|
+
branches: string[],
|
|
17
|
+
): Promise<{ deleted: string[]; skipped: string[] }> {
|
|
18
|
+
const deleted: string[] = [];
|
|
19
|
+
const skipped: string[] = [];
|
|
20
|
+
|
|
21
|
+
if (branches.length === 0) return { deleted, skipped };
|
|
22
|
+
|
|
23
|
+
// Get current branch name
|
|
24
|
+
let currentBranch = "";
|
|
25
|
+
try {
|
|
26
|
+
const r = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeout: 5_000 });
|
|
27
|
+
currentBranch = r.stdout.trim();
|
|
28
|
+
} catch {
|
|
29
|
+
// Can't determine HEAD — skip everything to be safe
|
|
30
|
+
return { deleted, skipped: [...new Set(branches)] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PROTECTED = new Set(["main", "master"]);
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
|
|
36
|
+
for (const branch of branches) {
|
|
37
|
+
if (seen.has(branch)) continue;
|
|
38
|
+
seen.add(branch);
|
|
39
|
+
|
|
40
|
+
// Skip protected and current branch
|
|
41
|
+
if (PROTECTED.has(branch) || branch === currentBranch) {
|
|
42
|
+
skipped.push(branch);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Verify fully merged into HEAD
|
|
47
|
+
try {
|
|
48
|
+
await pi.exec("git", ["merge-base", "--is-ancestor", branch, "HEAD"], { cwd, timeout: 5_000 });
|
|
49
|
+
} catch {
|
|
50
|
+
skipped.push(branch);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Safe delete
|
|
55
|
+
try {
|
|
56
|
+
await pi.exec("git", ["branch", "-d", branch], { cwd, timeout: 5_000 });
|
|
57
|
+
deleted.push(branch);
|
|
58
|
+
} catch {
|
|
59
|
+
skipped.push(branch);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { deleted, skipped };
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { sharedState, DASHBOARD_UPDATE_EVENT } from "../shared-state.ts";
|
|
4
|
+
import { debug } from "../debug.ts";
|
|
5
|
+
import { listChanges } from "./spec.ts";
|
|
6
|
+
import { buildLifecycleSummary } from "./lifecycle.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Emit OpenSpec state to sharedState for the unified dashboard.
|
|
10
|
+
* Reads all active changes, maps to the dashboard shape, and fires
|
|
11
|
+
* the dashboard:update event for re-render.
|
|
12
|
+
*/
|
|
13
|
+
export function emitOpenSpecState(cwd: string, pi: ExtensionAPI): void {
|
|
14
|
+
try {
|
|
15
|
+
const changes = listChanges(cwd);
|
|
16
|
+
const mapped = changes.map((c) => {
|
|
17
|
+
const artifacts: Array<"proposal" | "design" | "specs" | "tasks"> = [];
|
|
18
|
+
if (c.hasProposal) artifacts.push("proposal");
|
|
19
|
+
if (c.hasDesign) artifacts.push("design");
|
|
20
|
+
if (c.hasSpecs) artifacts.push("specs");
|
|
21
|
+
if (c.hasTasks) artifacts.push("tasks");
|
|
22
|
+
const specDomains = c.specs.map((s) => s.domain).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
// Resolve canonical lifecycle summary — single source of truth for
|
|
25
|
+
// readiness and verification substate, shared with status/get surfaces.
|
|
26
|
+
const lifecycle = buildLifecycleSummary(cwd, c);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
name: c.name,
|
|
30
|
+
stage: lifecycle.stage,
|
|
31
|
+
verificationSubstate: lifecycle.verificationSubstate,
|
|
32
|
+
archiveReady: lifecycle.archiveReady,
|
|
33
|
+
bindingStatus: lifecycle.bindingStatus,
|
|
34
|
+
tasksDone: lifecycle.doneTasks,
|
|
35
|
+
tasksTotal: lifecycle.totalTasks,
|
|
36
|
+
artifacts,
|
|
37
|
+
specDomains,
|
|
38
|
+
path: c.path,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
sharedState.openspec = { changes: mapped };
|
|
42
|
+
debug("openspec", "emitState", { count: mapped.length, cwd });
|
|
43
|
+
pi.events.emit(DASHBOARD_UPDATE_EVENT, { source: "openspec" });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
debug("openspec", "emitState:error", { error: err instanceof Error ? err.message : String(err), cwd });
|
|
46
|
+
// Non-fatal — clear stale dashboard state so consumers see an empty list rather than stale data
|
|
47
|
+
sharedState.openspec = { changes: [] };
|
|
48
|
+
pi.events.emit(DASHBOARD_UPDATE_EVENT, { source: "openspec" });
|
|
49
|
+
}
|
|
50
|
+
}
|