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,163 @@
1
+ /**
2
+ * design-tree/types — Shared type definitions for the design tree extension.
3
+ */
4
+
5
+ // ─── Node Status ─────────────────────────────────────────────────────────────
6
+
7
+ export type NodeStatus = "seed" | "exploring" | "decided" | "implementing" | "implemented" | "blocked" | "deferred";
8
+
9
+ export const VALID_STATUSES: NodeStatus[] = ["seed", "exploring", "decided", "implementing", "implemented", "blocked", "deferred"];
10
+
11
+ export const STATUS_ICONS: Record<NodeStatus, string> = {
12
+ seed: "◌",
13
+ exploring: "◐",
14
+ decided: "●",
15
+ implementing: "⚙",
16
+ implemented: "✓",
17
+ blocked: "✕",
18
+ deferred: "◑",
19
+ };
20
+
21
+ export const STATUS_COLORS: Record<NodeStatus, string> = {
22
+ seed: "muted",
23
+ exploring: "accent",
24
+ decided: "success",
25
+ implementing: "accent",
26
+ implemented: "success",
27
+ blocked: "error",
28
+ deferred: "warning",
29
+ };
30
+
31
+ // ─── Structured Sections ─────────────────────────────────────────────────────
32
+
33
+ /** A decision recorded in the ## Decisions section */
34
+ export interface DesignDecision {
35
+ title: string;
36
+ status: "exploring" | "decided" | "rejected";
37
+ rationale: string;
38
+ }
39
+
40
+ /** A research entry recorded in the ## Research section */
41
+ export interface ResearchEntry {
42
+ heading: string;
43
+ content: string;
44
+ }
45
+
46
+ /** File scope entry from ## Implementation Notes */
47
+ export interface FileScope {
48
+ path: string;
49
+ description: string;
50
+ action?: "new" | "modified" | "deleted";
51
+ }
52
+
53
+ // ─── Acceptance Criteria ─────────────────────────────────────────────────────
54
+
55
+ /** A Given/When/Then scenario from ## Acceptance Criteria → ### Scenarios */
56
+ export interface AcceptanceCriteriaScenario {
57
+ title: string;
58
+ given: string;
59
+ when: string;
60
+ then: string;
61
+ }
62
+
63
+ /** A falsifiability condition from ## Acceptance Criteria → ### Falsifiability */
64
+ export interface AcceptanceCriteriaFalsifiability {
65
+ condition: string;
66
+ }
67
+
68
+ /** A checkbox constraint from ## Acceptance Criteria → ### Constraints */
69
+ export interface AcceptanceCriteriaConstraint {
70
+ text: string;
71
+ checked: boolean;
72
+ }
73
+
74
+ /** Parsed ## Acceptance Criteria section */
75
+ export interface AcceptanceCriteria {
76
+ scenarios: AcceptanceCriteriaScenario[];
77
+ falsifiability: AcceptanceCriteriaFalsifiability[];
78
+ constraints: AcceptanceCriteriaConstraint[];
79
+ }
80
+
81
+ /** Parsed structured sections from the document body */
82
+ export interface DocumentSections {
83
+ overview: string;
84
+ research: ResearchEntry[];
85
+ decisions: DesignDecision[];
86
+ openQuestions: string[];
87
+ implementationNotes: {
88
+ fileScope: FileScope[];
89
+ constraints: string[];
90
+ rawContent: string;
91
+ };
92
+ acceptanceCriteria: AcceptanceCriteria;
93
+ /** Any content not in a recognized section */
94
+ extraSections: Array<{ heading: string; content: string }>;
95
+ }
96
+
97
+ // ─── Issue Type ──────────────────────────────────────────────────────────────
98
+
99
+ export type IssueType = "epic" | "feature" | "task" | "bug" | "chore";
100
+
101
+ export const VALID_ISSUE_TYPES: IssueType[] = ["epic", "feature", "task", "bug", "chore"];
102
+
103
+ export const ISSUE_TYPE_ICONS: Record<IssueType, string> = {
104
+ epic: "⬡",
105
+ feature: "★",
106
+ task: "◻",
107
+ bug: "✖",
108
+ chore: "⟳",
109
+ };
110
+
111
+ // ─── Priority ────────────────────────────────────────────────────────────────
112
+
113
+ export type Priority = 1 | 2 | 3 | 4 | 5;
114
+
115
+ export const PRIORITY_LABELS: Record<Priority, string> = {
116
+ 1: "critical",
117
+ 2: "high",
118
+ 3: "medium",
119
+ 4: "low",
120
+ 5: "trivial",
121
+ };
122
+
123
+ // ─── Design Node ─────────────────────────────────────────────────────────────
124
+
125
+ export interface DesignNode {
126
+ id: string;
127
+ title: string;
128
+ status: NodeStatus;
129
+ parent?: string;
130
+ dependencies: string[];
131
+ related: string[];
132
+ tags: string[];
133
+ /** Open questions — synced from ## Open Questions body section */
134
+ open_questions: string[];
135
+ /** Explicit branch name override (D1). When set, used instead of feature/<node-id> */
136
+ branch?: string;
137
+ /** Git branches associated with this node — history of all branches that carried this work */
138
+ branches: string[];
139
+ /** OpenSpec change name linked to this node */
140
+ openspec_change?: string;
141
+ /** Issue type classification (epic/feature/task/bug/chore) */
142
+ issue_type?: IssueType;
143
+ /** Priority from 1 (critical) to 5 (trivial) */
144
+ priority?: Priority;
145
+ filePath: string;
146
+ lastModified: number;
147
+ }
148
+
149
+ export interface DesignTree {
150
+ nodes: Map<string, DesignNode>;
151
+ docsDir: string;
152
+ }
153
+
154
+ // ─── Document Template ───────────────────────────────────────────────────────
155
+
156
+ export const SECTION_HEADINGS = {
157
+ overview: "## Overview",
158
+ research: "## Research",
159
+ decisions: "## Decisions",
160
+ openQuestions: "## Open Questions",
161
+ implementationNotes: "## Implementation Notes",
162
+ acceptanceCriteria: "## Acceptance Criteria",
163
+ } as const;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * distill — Context distillation for session handoff
3
+ *
4
+ * Registers `/distill` command that analyzes the current conversation context
5
+ * and produces a portable distillation summary for bootstrapping a fresh session.
6
+ *
7
+ * The extension handles the mechanical parts (directory creation, file writing,
8
+ * git status collection) and delegates the summarization to the LLM via
9
+ * sendUserMessage.
10
+ */
11
+
12
+ import { existsSync, mkdirSync } from "node:fs";
13
+ import { join, basename } from "node:path";
14
+ import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
15
+
16
+ export default function distillExtension(pi: ExtensionAPI) {
17
+
18
+ pi.registerCommand("distill", {
19
+ description: "Create a portable session distillation for fresh context bootstrap",
20
+ handler: async (_args, ctx) => {
21
+ // Gather mechanical context the LLM will need
22
+ const cwd = ctx.cwd;
23
+ const repoName = basename(cwd);
24
+
25
+ // Git status
26
+ let branch = "unknown";
27
+ let recentCommits = "";
28
+ let uncommitted = "";
29
+ try {
30
+ const branchResult = await pi.exec("git", ["branch", "--show-current"], { timeout: 5_000, cwd });
31
+ branch = branchResult.stdout.trim() || "HEAD detached";
32
+ } catch { /* ignore */ }
33
+ try {
34
+ const logResult = await pi.exec(
35
+ "git", ["log", "--oneline", "-10", "--no-decorate"],
36
+ { timeout: 5_000, cwd },
37
+ );
38
+ recentCommits = logResult.stdout.trim();
39
+ } catch { /* ignore */ }
40
+ try {
41
+ const statusResult = await pi.exec("git", ["status", "--porcelain"], { timeout: 5_000, cwd });
42
+ uncommitted = statusResult.stdout.trim() || "(clean)";
43
+ } catch { /* ignore */ }
44
+
45
+ // Ensure distillation directory exists
46
+ const distillDir = join(cwd, ".pi", "distillations");
47
+ mkdirSync(distillDir, { recursive: true });
48
+
49
+ // Generate a timestamp-based filename
50
+ const now = new Date();
51
+ const ts = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
52
+
53
+ // Build the prompt for the LLM to do the actual summarization
54
+ const prompt = [
55
+ "Analyze the full conversation context and create a session distillation.",
56
+ "",
57
+ "**Repository context (already gathered):**",
58
+ `- Working directory: ${cwd}`,
59
+ `- Repository: ${repoName}`,
60
+ `- Branch: ${branch}`,
61
+ `- Recent commits:`,
62
+ "```",
63
+ recentCommits || "(no commits)",
64
+ "```",
65
+ `- Uncommitted changes:`,
66
+ "```",
67
+ uncommitted,
68
+ "```",
69
+ "",
70
+ "**Instructions:**",
71
+ "",
72
+ `Write a distillation file to \`${distillDir}/${ts}-<slug>.md\` where <slug> is a 2-3 word kebab-case description of the session topic.`,
73
+ "",
74
+ "Use this structure:",
75
+ "",
76
+ "```markdown",
77
+ "# Session Distillation: <brief-title>",
78
+ "",
79
+ "Generated: <timestamp>",
80
+ `Working Directory: ${cwd}`,
81
+ `Repository: ${repoName}`,
82
+ "",
83
+ "## Session Overview",
84
+ "<2-3 sentence summary of what was accomplished>",
85
+ "",
86
+ "## Technical State",
87
+ "### Repository Status",
88
+ `- Branch: ${branch}`,
89
+ "- Recent commits: <summarize>",
90
+ "- Uncommitted changes: <summarize>",
91
+ "",
92
+ "### Key Changes This Session",
93
+ "<bulleted list of significant modifications>",
94
+ "",
95
+ "## Decisions Made",
96
+ "<numbered list with brief rationale>",
97
+ "",
98
+ "## Pending Items",
99
+ "### Incomplete Work",
100
+ "### Known Issues",
101
+ "### Planned Next Steps",
102
+ "",
103
+ "## Critical Context",
104
+ "<information that would be difficult to reconstruct>",
105
+ "",
106
+ "## File Reference",
107
+ "Key files for continuation:",
108
+ "- <path>: <purpose>",
109
+ "```",
110
+ "",
111
+ "After writing the file, output a handoff block like:",
112
+ "",
113
+ "```",
114
+ "Distillation saved to: <path>",
115
+ "",
116
+ "To continue in a fresh session, copy this prompt:",
117
+ "---",
118
+ "Continue from distillation: <path>",
119
+ "Read the distillation file and confirm you understand the context before proceeding.",
120
+ "---",
121
+ "```",
122
+ ].join("\n");
123
+
124
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
125
+ },
126
+ });
127
+ }
@@ -0,0 +1,395 @@
1
+ /**
2
+ * effort — Global inference cost control extension.
3
+ *
4
+ * Provides a single `/effort` command to switch between 7 named tiers
5
+ * (Servitor → Omnissiah), each controlling the driver model, thinking level,
6
+ * and downstream settings for cleave dispatch, extraction, and compaction.
7
+ *
8
+ * On session_start: resolves the active tier from PI_EFFORT env var,
9
+ * .pi/config.json, or default (Substantial), writes to sharedState.effort,
10
+ * and switches the driver model + thinking level accordingly.
11
+ *
12
+ * Commands:
13
+ * /effort — Show current tier info
14
+ * /effort <name> — Switch to named tier
15
+ * /effort cap — Lock ceiling at current tier
16
+ * /effort uncap — Remove ceiling lock
17
+ */
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@cwilson613/pi-coding-agent";
20
+ import { readFileSync, existsSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ import type { EffortLevel, EffortState, EffortModelTier, ThinkingLevel } from "./types.ts";
24
+ import { EFFORT_NAMES } from "./types.ts";
25
+ import { tierConfig, parseTierName, DEFAULT_EFFORT_LEVEL, TIER_NAMES } from "./tiers.ts";
26
+ import { sharedState, DASHBOARD_UPDATE_EVENT } from "../shared-state.ts";
27
+ import {
28
+ resolveTier,
29
+ getTierDisplayLabel,
30
+ getDefaultPolicy,
31
+ clampThinkingLevel,
32
+ type ModelTier,
33
+ type RegistryModel,
34
+ } from "../lib/model-routing.ts";
35
+ import { readLastUsedModel, writeLastUsedModel } from "../lib/model-preferences.ts";
36
+ import { readOperatorProfile, loadOperatorRuntimeState, toCapabilityProfile, toCapabilityRuntimeState } from "../lib/operator-profile.ts";
37
+
38
+ // ─── Constants ───────────────────────────────────────────────
39
+
40
+ /** Tier icons indexed by level. */
41
+ const TIER_ICONS: Record<EffortLevel, string> = {
42
+ 1: "○",
43
+ 2: "●",
44
+ 3: "†",
45
+ 4: "⚔",
46
+ 5: "☠",
47
+ 6: "💀",
48
+ 7: "🤖",
49
+ };
50
+
51
+ function getResolverInputs(ctx: ExtensionContext) {
52
+ const policy = sharedState.routingPolicy ?? getDefaultPolicy();
53
+ const profile = toCapabilityProfile(readOperatorProfile(ctx.cwd));
54
+ const runtimeState = toCapabilityRuntimeState(loadOperatorRuntimeState(ctx.cwd));
55
+ return { policy, profile, runtimeState };
56
+ }
57
+
58
+ // ─── Model Switching ─────────────────────────────────────────
59
+
60
+ /**
61
+ * Switch the driver model to match the effort tier's driver setting.
62
+ * Uses the shared resolveTier() resolver with the current session policy.
63
+ * Returns true if the switch succeeded.
64
+ *
65
+ * C3: `all` is fetched once and indexed as a Map so the post-resolution lookup
66
+ * is O(1) with no second linear scan. Both resolveTier and the model lookup
67
+ * operate on the same snapshot.
68
+ */
69
+ async function switchDriverModel(
70
+ pi: ExtensionAPI,
71
+ ctx: ExtensionContext,
72
+ driver: EffortModelTier,
73
+ ): Promise<{ model: RegistryModel; maxThinking?: ThinkingLevel } | null> {
74
+ // Snapshot the registry once; both resolveTier and the model lookup use it
75
+ const all = ctx.modelRegistry.getAll() as unknown as RegistryModel[];
76
+ // Build O(1) index over the same snapshot — no second linear scan (C3)
77
+ const byKey = new Map(all.map((m) => [`${m.provider}/${m.id}`, m]));
78
+ const { policy, profile, runtimeState } = getResolverInputs(ctx);
79
+ const resolved = resolveTier(driver, all, policy, runtimeState, profile);
80
+ if (!resolved) return null;
81
+ // Direct map lookup — no second linear scan of `all`
82
+ const model = byKey.get(`${resolved.provider}/${resolved.modelId}`);
83
+ if (!model) return null;
84
+ const success = await pi.setModel(model as any);
85
+ return success ? { model, maxThinking: resolved.maxThinking as ThinkingLevel | undefined } : null;
86
+ }
87
+
88
+ async function restoreLastUsedModel(
89
+ pi: ExtensionAPI,
90
+ ctx: ExtensionContext,
91
+ ): Promise<RegistryModel | null> {
92
+ const persisted = readLastUsedModel(ctx.cwd);
93
+ if (!persisted) return null;
94
+ const model = ctx.modelRegistry.find(persisted.provider, persisted.modelId) as unknown as RegistryModel | undefined;
95
+ if (!model) return null;
96
+ const success = await pi.setModel(model as any);
97
+ return success ? model : null;
98
+ }
99
+
100
+ /**
101
+ * Resolve the effective extraction tier, honoring the session routing policy.
102
+ *
103
+ * When cheapCloudPreferredOverLocal is true and the effort tier's extraction
104
+ * setting is "local", we upgrade to "retribution" (cheapest cloud tier) so that
105
+ * background extraction work uses a cost-effective cloud model when available.
106
+ * If no cloud model satisfies retribution, falls back to "local" transparently.
107
+ *
108
+ * Spec: "Extraction prefers cheap cloud when configured"
109
+ * "Offline or unavailable cloud falls back safely"
110
+ */
111
+ function resolveExtractionTier(
112
+ extraction: EffortModelTier,
113
+ ctx: ExtensionContext,
114
+ ): { displayTier: string; resolvedModelId?: string } {
115
+ const { policy, profile, runtimeState } = getResolverInputs(ctx);
116
+ const all = ctx.modelRegistry.getAll() as unknown as RegistryModel[];
117
+
118
+ // Determine effective tier: upgrade local→retribution when policy prefers cheap cloud
119
+ const effectiveTier: ModelTier =
120
+ policy.cheapCloudPreferredOverLocal && extraction === "local" ? "retribution" : extraction;
121
+
122
+ const resolved = resolveTier(effectiveTier, all, policy, runtimeState, profile);
123
+
124
+ // If cloud preferred but no cloud model matched, fall back to local.
125
+ // We call resolveTier("local") rather than matchLocalTier() directly because
126
+ // resolveTier is the public API. The cloud-preferring policy is passed through
127
+ // intentionally — resolveTier's "local" path ignores policy entirely and goes
128
+ // straight to matchLocalTier(), so the policy has no effect here. This is
129
+ // safe and avoids importing the private matchLocalTier function.
130
+ const final =
131
+ resolved ?? (effectiveTier !== "local" ? resolveTier("local", all, policy, runtimeState, profile) : undefined);
132
+
133
+ return {
134
+ displayTier: final ? getTierDisplayLabel(final.tier) : getTierDisplayLabel(effectiveTier),
135
+ resolvedModelId: final?.modelId,
136
+ };
137
+ }
138
+
139
+ // ─── Config Resolution ───────────────────────────────────────
140
+
141
+ /**
142
+ * Read the effort tier from .pi/config.json in the project root.
143
+ * Returns undefined if file doesn't exist or has no effort key.
144
+ */
145
+ function readConfigEffort(cwd: string): string | undefined {
146
+ try {
147
+ const configPath = join(cwd, ".pi", "config.json");
148
+ if (!existsSync(configPath)) return undefined;
149
+ const raw = readFileSync(configPath, "utf-8");
150
+ const parsed = JSON.parse(raw);
151
+ return typeof parsed.effort === "string" ? parsed.effort : undefined;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Resolve the initial effort level from (in priority order):
159
+ * 1. PI_EFFORT environment variable
160
+ * 2. .pi/config.json effort field
161
+ * 3. Default (Substantial, level 3)
162
+ */
163
+ function resolveInitialLevel(cwd: string): EffortLevel {
164
+ // 1. Environment variable
165
+ const envValue = process.env.PI_EFFORT;
166
+ if (envValue) {
167
+ const level = parseTierName(envValue);
168
+ if (level !== undefined) return level;
169
+ }
170
+
171
+ // 2. Config file
172
+ const configValue = readConfigEffort(cwd);
173
+ if (configValue) {
174
+ const level = parseTierName(configValue);
175
+ if (level !== undefined) return level;
176
+ }
177
+
178
+ // 3. Default
179
+ return DEFAULT_EFFORT_LEVEL;
180
+ }
181
+
182
+ /**
183
+ * Build an EffortState from a tier level.
184
+ * Preserves existing cap state if provided.
185
+ * resolvedExtractionModelId is always initialized to undefined here;
186
+ * callers must invoke resolveExtractionTier() and populate it before
187
+ * writing to sharedState.effort (W2).
188
+ */
189
+ function buildEffortState(
190
+ level: EffortLevel,
191
+ capped: boolean = false,
192
+ capLevel?: EffortLevel,
193
+ ): EffortState {
194
+ const config = tierConfig(level);
195
+ return {
196
+ ...config,
197
+ capped,
198
+ capLevel,
199
+ resolvedExtractionModelId: undefined,
200
+ };
201
+ }
202
+
203
+ // ─── Display Helpers ─────────────────────────────────────────
204
+
205
+ function formatTierInfo(state: EffortState): string {
206
+ const icon = TIER_ICONS[state.level];
207
+ const capIndicator = state.capped && state.capLevel
208
+ ? ` [CAPPED at ${EFFORT_NAMES[state.capLevel]}]`
209
+ : "";
210
+ const driverLabel = getTierDisplayLabel(state.driver);
211
+ const extractionLabel = getTierDisplayLabel(state.extraction);
212
+ const compactionLabel = getTierDisplayLabel(state.compaction);
213
+ const reviewLabel = getTierDisplayLabel(state.reviewModel);
214
+ const floorLabel = getTierDisplayLabel(state.cleaveFloor);
215
+ const lines = [
216
+ `${icon} **${state.name}** (level ${state.level}/7)${capIndicator}`,
217
+ ` Driver: ${driverLabel} (${state.driver}) | Thinking: ${state.thinking}`,
218
+ ` Extraction: ${extractionLabel} (${state.extraction}) | Compaction: ${compactionLabel} (${state.compaction})`,
219
+ ` Cleave: preferLocal=${state.cleavePreferLocal}, floor=${floorLabel} (${state.cleaveFloor})`,
220
+ ` Review: ${reviewLabel} (${state.reviewModel})`,
221
+ ];
222
+ return lines.join("\n");
223
+ }
224
+
225
+ // ─── Extension Entry Point ───────────────────────────────────
226
+
227
+ export default function (pi: ExtensionAPI) {
228
+ // ── Session Start: resolve and apply effort tier ──
229
+
230
+ pi.on("session_start", async (_event, ctx) => {
231
+ const level = resolveInitialLevel(ctx.cwd);
232
+ const state = buildEffortState(level);
233
+
234
+ // Resolve extraction tier under current routing policy (C1: spec compliance).
235
+ // When cheapCloudPreferredOverLocal is true this upgrades local→retribution and
236
+ // falls back to local if no cloud model is available.
237
+ const extractionResolution = resolveExtractionTier(state.extraction, ctx);
238
+ state.resolvedExtractionModelId = extractionResolution.resolvedModelId;
239
+
240
+ // Write to shared state
241
+ sharedState.effort = state;
242
+ pi.events.emit(DASHBOARD_UPDATE_EVENT, { source: "effort" });
243
+
244
+ // Restore the operator's last explicit model choice when possible.
245
+ // If none is persisted (or it is no longer available), fall back to the
246
+ // current effort tier's resolved driver. As a final guard, keep pi's
247
+ // current startup model rather than warning about an unusable session when
248
+ // a working driver is already present.
249
+ const restoredModel = await restoreLastUsedModel(pi, ctx);
250
+ const switchedDriver = restoredModel ? null : await switchDriverModel(pi, ctx, state.driver);
251
+ const retainedModel = !restoredModel && !switchedDriver && ctx.model ? ctx.model : null;
252
+
253
+ // Set thinking level, respecting candidate ceilings when the effort-driven
254
+ // model switch produced a structured resolver result.
255
+ const effectiveThinking: ThinkingLevel = switchedDriver?.maxThinking
256
+ ? clampThinkingLevel(state.thinking, switchedDriver.maxThinking)
257
+ : restoredModel || retainedModel
258
+ ? state.thinking
259
+ : state.thinking;
260
+ pi.setThinkingLevel(effectiveThinking as any);
261
+
262
+ // Notify operator
263
+ const icon = TIER_ICONS[state.level];
264
+ const modelNote = restoredModel
265
+ ? ` → restored ${restoredModel.provider}/${restoredModel.id}`
266
+ : switchedDriver
267
+ ? ` → ${switchedDriver.model.provider}/${switchedDriver.model.id}`
268
+ : retainedModel
269
+ ? ` → kept ${retainedModel.provider}/${retainedModel.id} (preferred ${state.driver} unavailable)`
270
+ : " (driver model unavailable)";
271
+ ctx.ui.notify(
272
+ `${icon} Effort: ${state.name} (${state.driver}/${effectiveThinking})${modelNote}`,
273
+ restoredModel || switchedDriver || retainedModel ? "info" : "warning",
274
+ );
275
+ });
276
+
277
+ // ── /effort command ──
278
+
279
+ pi.registerCommand("effort", {
280
+ description: "View or change effort tier. Usage: /effort [tier|cap|uncap]",
281
+ getArgumentCompletions: (prefix: string) => {
282
+ const options = [...TIER_NAMES, "cap", "uncap"];
283
+ const lower = prefix.toLowerCase();
284
+ const matches = options.filter((o) => o.toLowerCase().startsWith(lower));
285
+ return matches.map((name) => ({
286
+ label: name,
287
+ value: name,
288
+ }));
289
+ },
290
+ handler: async (args, ctx) => {
291
+ const arg = args.trim();
292
+
293
+ // No args → show current tier
294
+ if (!arg) {
295
+ const state = sharedState.effort;
296
+ if (!state) {
297
+ ctx.ui.notify("⚠️ Effort state not initialized", "warning");
298
+ return;
299
+ }
300
+ ctx.ui.notify(formatTierInfo(state), "info");
301
+ return;
302
+ }
303
+
304
+ // /effort cap
305
+ if (arg.toLowerCase() === "cap") {
306
+ const state = sharedState.effort;
307
+ if (!state) {
308
+ ctx.ui.notify("⚠️ Effort state not initialized", "warning");
309
+ return;
310
+ }
311
+ const icon = TIER_ICONS[state.level];
312
+ const newState = buildEffortState(state.level, true, state.level);
313
+ // Resolve extraction tier and populate before writing to sharedState (W1)
314
+ const extractionResolution = resolveExtractionTier(newState.extraction, ctx);
315
+ newState.resolvedExtractionModelId = extractionResolution.resolvedModelId;
316
+ sharedState.effort = newState;
317
+ pi.events.emit(DASHBOARD_UPDATE_EVENT, { source: "effort" });
318
+ ctx.ui.notify(
319
+ `${icon} Effort capped at ${state.name} (level ${state.level}) — agent cannot upgrade past this tier`,
320
+ "info",
321
+ );
322
+ return;
323
+ }
324
+
325
+ // /effort uncap
326
+ if (arg.toLowerCase() === "uncap") {
327
+ const state = sharedState.effort;
328
+ if (!state) {
329
+ ctx.ui.notify("⚠️ Effort state not initialized", "warning");
330
+ return;
331
+ }
332
+ const icon = TIER_ICONS[state.level];
333
+ const newState = buildEffortState(state.level, false);
334
+ // Resolve extraction tier and populate before writing to sharedState (W1)
335
+ const extractionResolution = resolveExtractionTier(newState.extraction, ctx);
336
+ newState.resolvedExtractionModelId = extractionResolution.resolvedModelId;
337
+ sharedState.effort = newState;
338
+ pi.events.emit(DASHBOARD_UPDATE_EVENT, { source: "effort" });
339
+ ctx.ui.notify(
340
+ `${icon} Effort cap removed — agent can freely upgrade`,
341
+ "info",
342
+ );
343
+ return;
344
+ }
345
+
346
+ // /effort <tier name>
347
+ const level = parseTierName(arg);
348
+ if (level === undefined) {
349
+ const valid = TIER_NAMES.map(
350
+ (name, i) => `${TIER_ICONS[(i + 1) as EffortLevel]} ${name}`,
351
+ ).join(", ");
352
+ ctx.ui.notify(
353
+ `❌ Unknown tier "${arg}". Valid tiers: ${valid}`,
354
+ "error",
355
+ );
356
+ return;
357
+ }
358
+
359
+ // Preserve cap state on switch
360
+ const prev = sharedState.effort;
361
+ const capped = prev?.capped ?? false;
362
+ const capLevel = prev?.capLevel;
363
+ const state = buildEffortState(level, capped, capLevel);
364
+
365
+ // Resolve extraction tier before writing to sharedState (C1, C2)
366
+ const extractionResolution = resolveExtractionTier(state.extraction, ctx);
367
+ state.resolvedExtractionModelId = extractionResolution.resolvedModelId;
368
+
369
+ // Write to shared state only after all fields are populated
370
+ sharedState.effort = state;
371
+ pi.events.emit(DASHBOARD_UPDATE_EVENT, { source: "effort" });
372
+
373
+ // Switch driver model
374
+ const driverModel = await switchDriverModel(pi, ctx, state.driver);
375
+ if (driverModel) {
376
+ writeLastUsedModel(ctx.cwd, { provider: driverModel.model.provider, modelId: driverModel.model.id });
377
+ }
378
+
379
+ // Set thinking level
380
+ const effectiveThinking: ThinkingLevel = driverModel?.maxThinking
381
+ ? clampThinkingLevel(state.thinking, driverModel.maxThinking)
382
+ : state.thinking;
383
+ pi.setThinkingLevel(effectiveThinking as any);
384
+
385
+ const icon = TIER_ICONS[state.level];
386
+ const modelNote = driverModel
387
+ ? ` → ${driverModel.model.provider}/${driverModel.model.id}`
388
+ : " (driver model unavailable)";
389
+ ctx.ui.notify(
390
+ `${icon} Switched to ${state.name} (${state.driver}/${effectiveThinking})${modelNote}`,
391
+ driverModel ? "info" : "warning",
392
+ );
393
+ },
394
+ });
395
+ }