pi-twins 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ This project follows semantic versioning.
6
+
7
+ ## [0.1.0] - 2026-06-10
8
+
9
+ ### Added
10
+
11
+ - Initial release of pi-twins — dual-model synthesis for Pi.
12
+ - `/twins:run` command: ask two models the same question and get a synthesized answer.
13
+ - `twins_run` tool: AI-accessible tool for twin model execution.
14
+ - `/twins:scan` command: display available models for configuration.
15
+ - `/twins:config` command: create or show `~/.pi/twins.yaml`.
16
+ - YAML configuration system with model pair definitions.
17
+ - Agent state machine for sequential model calling (model A → model B → synthesis).
18
+ - Error resilience: stale state timeout, model-not-found graceful fallback.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 YOUR_NAME
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # pi-twins
2
+
3
+ [![CI](https://github.com/eiei114/pi-twins/actions/workflows/ci.yml/badge.svg)](https://github.com/eiei114/pi-twins/actions/workflows/ci.yml)
4
+ [![Publish](https://github.com/eiei114/pi-twins/actions/workflows/publish.yml/badge.svg)](https://github.com/eiei114/pi-twins/actions/workflows/publish.yml)
5
+ [![npm version](https://img.shields.io/npm/v/pi-twins.svg)](https://www.npmjs.com/package/pi-twins)
6
+ [![npm downloads](https://img.shields.io/npm/dm/pi-twins.svg)](https://www.npmjs.com/package/pi-twins)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![Pi package](https://img.shields.io/badge/pi-package-purple.svg)](https://pi.dev/packages)
9
+ [![Trusted Publishing](https://img.shields.io/badge/npm-Trusted%20Publishing-blue.svg)](docs/release.md)
10
+
11
+ > Run the same prompt on two models, get one synthesized answer.
12
+
13
+ ## What this is
14
+
15
+ pi-twins sends your prompt to two AI models in parallel, then Pi itself reads both responses and synthesizes a single answer from the best parts. Configure model pairs via YAML. No more manually tabbing between chatbot UIs to compare answers.
16
+
17
+ ## Features
18
+
19
+ - **Dual-model execution** — run the same prompt on two models simultaneously
20
+ - **Automatic synthesis** — Pi reads both responses and produces one unified answer
21
+ - **YAML configuration** — define model pairs (e.g. Claude + Gemini) in `~/.pi/twins.yaml`
22
+ - **Model discovery** — `/twins:scan` to see which models are available
23
+ - **On-demand only** — activate via `/twins` when you want it, no overhead otherwise
24
+
25
+ ## Install
26
+
27
+ Install the published npm package with Pi:
28
+
29
+ ```bash
30
+ pi install npm:pi-twins
31
+ ```
32
+
33
+ Pin a specific version:
34
+
35
+ ```bash
36
+ pi install npm:pi-twins@0.1.0
37
+ ```
38
+
39
+ Try it without permanently installing:
40
+
41
+ ```bash
42
+ pi -e npm:pi-twins
43
+ ```
44
+
45
+ ## Quick start
46
+
47
+ Try this package locally:
48
+
49
+ ```bash
50
+ pi -e .
51
+ ```
52
+
53
+ Then run:
54
+
55
+ ```txt
56
+ /twins "What are the tradeoffs between SQLite and PostgreSQL for my use case?"
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ Create `~/.pi/twins.yaml`:
62
+
63
+ ```yaml
64
+ pairs:
65
+ default:
66
+ - anthropic/claude-sonnet-4
67
+ - google/gemini-2.5-pro
68
+ coding:
69
+ - anthropic/claude-sonnet-4
70
+ - openai/gpt-4o
71
+ ```
72
+
73
+ ## Commands
74
+
75
+ /twins:run — 2モデルに同じプロンプトを投げて統合回答を得る(プロンプトは実行後に入力)
76
+ /twins:scan — 利用可能なモデル一覧を表示
77
+
78
+ 引数は不要。詳細は各コマンド実行後のプロンプト
79
+
80
+ ## Package contents
81
+
82
+ | Path | Purpose |
83
+ |---|---|
84
+ | `extensions/` | Pi TypeScript extension entrypoints (`*.ts` and `index.ts`) |
85
+ | `lib/` | Shared TypeScript helpers |
86
+ | `skills/` | Agent Skills |
87
+ | `prompts/` | Prompt templates |
88
+ | `themes/` | Pi themes |
89
+ | `docs/` | Optional supporting docs (usage, examples, release, ADRs) |
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ npm install
95
+ npm run ci
96
+ ```
97
+
98
+ ## Development flow
99
+
100
+ Use this default flow when building a new Pi extension OSS project from this template:
101
+
102
+ 1. Create the Vault project notes under `4_Project/<ProjectName>/`.
103
+ 2. Add `CONTEXT.md`, `README.md`, `ROADMAP.md`, `Docs/`, `Issues/`, and `Progress/`.
104
+ 3. Write the PRD in `4_Project/<ProjectName>/Docs/`.
105
+ 4. Split approved tracer-bullet issues into `4_Project/<ProjectName>/Issues/`.
106
+ 5. Implement in the OSS repo.
107
+ 6. Run `npm run ci`, `npm test`, and `npm pack --dry-run`.
108
+ 7. Release with Trusted Publishing.
109
+ 8. Save release notes and follow-up decisions back to the Vault project.
110
+
111
+ Short version:
112
+
113
+ ```txt
114
+ Vault notes -> PRD -> Issues -> implement -> ci/check -> release -> save learnings
115
+ ```
116
+
117
+ ## Release
118
+
119
+ This package is set up for npm Trusted Publishing, so no `NPM_TOKEN` is required.
120
+
121
+ ```bash
122
+ npm version patch
123
+ git push
124
+ ```
125
+
126
+ See [`docs/release.md`](docs/release.md) for setup details.
127
+
128
+ ## Docs
129
+
130
+ `docs/` is optional supporting documentation, not a fixed six-file set. README stays the GitHub/npm entrypoint; add `docs/*.md` only when they help users or maintainers.
131
+
132
+ After creating a repository from this template:
133
+
134
+ 1. Delete or merge template bootstrap docs that no longer add project value.
135
+
136
+ Useful docs to keep when they add value:
137
+
138
+ - [`docs/examples.md`](docs/examples.md) — examples for extensions, skills, prompts, and themes
139
+ - [`docs/release.md`](docs/release.md) — Trusted Publishing details (README Release summarizes the flow)
140
+ - `docs/usage.md` — create when usage does not fit in README
141
+
142
+ Optional maintainer guidance (not a public-user navigation target in mature repos):
143
+
144
+ - [`docs/template-checklist.md`](docs/template-checklist.md)
145
+
146
+ Template bootstrap docs to delete or merge after setup unless they still teach something project-specific:
147
+
148
+ - `docs/github-template.md`
149
+ - `docs/repository-settings.md`
150
+ - `docs/typescript.md`
151
+
152
+ ## Security
153
+
154
+ Pi packages can execute code with your local permissions. Review extensions before installing third-party packages.
155
+
156
+ For vulnerability reporting, see [`SECURITY.md`](SECURITY.md).
157
+
158
+ ## Links
159
+
160
+ - npm: https://www.npmjs.com/package/pi-twins
161
+ - GitHub: https://github.com/eiei114/pi-twins
162
+ - Issues: https://github.com/eiei114/pi-twins/issues
163
+
164
+ ## License
165
+
166
+ MIT\n
@@ -0,0 +1,49 @@
1
+ # Examples
2
+
3
+ This template ships one minimal example for each Pi package resource type.
4
+
5
+ ## Extension
6
+
7
+ `extensions/hello.ts` registers:
8
+
9
+ - `/template-hello`
10
+ - a small session status indicator
11
+
12
+ Try it with:
13
+
14
+ ```bash
15
+ pi -e .
16
+ ```
17
+
18
+ Then run:
19
+
20
+ ```txt
21
+ /template-hello YourName
22
+ ```
23
+
24
+ ## Agent Skill
25
+
26
+ `skills/example-skill/SKILL.md` demonstrates a minimal Agent Skill.
27
+
28
+ Replace it with your real workflow instructions.
29
+
30
+ ## Prompt template
31
+
32
+ `prompts/example.md` demonstrates a tiny prompt template with one variable.
33
+
34
+ ## Theme
35
+
36
+ `themes/example-theme.json` is a placeholder theme. Replace it or remove `themes/` if your package does not ship themes.
37
+
38
+ ## Typed custom tool
39
+
40
+ `extensions/index.ts` registers:
41
+
42
+ - `/template-info`
43
+ - `template_greet` custom tool
44
+
45
+ The tool demonstrates:
46
+
47
+ - TypeBox object parameters
48
+ - a string enum schema via `StringEnum`
49
+ - shared logic imported from `lib/greeting.ts`
@@ -0,0 +1,57 @@
1
+ # Release
2
+
3
+ This package uses npm Trusted Publishing with GitHub Actions OIDC.
4
+
5
+ Do not add `NPM_TOKEN` or long-lived npm tokens to GitHub Secrets.
6
+
7
+ ## One-time npm setup
8
+
9
+ On npmjs.com, configure Trusted Publishing for this package:
10
+
11
+ - Publisher: GitHub Actions
12
+ - Repository: this GitHub repository
13
+ - Workflow filename: `publish.yml`
14
+
15
+ ## Publish
16
+
17
+ ```bash
18
+ npm version patch
19
+ git push
20
+ ```
21
+
22
+ On `main`, `.github/workflows/auto-release.yml` checks `package.json` version. If `v<version>` does not exist yet, it creates the tag, creates the GitHub Release, then explicitly dispatches `.github/workflows/publish.yml` for that tag.
23
+
24
+ The `v*.*.*` tag also triggers `.github/workflows/publish.yml`, which runs CI and publishes to npm when tags are pushed manually.
25
+ Publishing also runs when a GitHub Release is published, and can be run manually from GitHub Actions with `workflow_dispatch`.
26
+
27
+ The workflow skips `name@version` if that exact package version already exists on npm.
28
+
29
+ ## Workflow guardrail
30
+
31
+ Do not ship a new Pi OSS package or version bump with only `package.json` changes.
32
+ The repository must include the release workflow pair:
33
+
34
+ - `.github/workflows/auto-release.yml` creates `v<version>` tags and GitHub Releases from `main` version bumps.
35
+ - `.github/workflows/publish.yml` publishes to npm through Trusted Publishing.
36
+
37
+ Important: tags or releases created by `GITHUB_TOKEN` do not reliably fan out into another workflow through normal `push.tags` or `release.published` triggers. The template keeps publishing reliable by having `auto-release.yml` explicitly dispatch `publish.yml` after creating the tag/release. If you change the release flow, keep one explicit handoff path: `workflow_dispatch` from auto-release, `repository_dispatch`, or `workflow_run` on the auto-release workflow.
38
+
39
+ ## GitHub Actions requirements
40
+
41
+ - `permissions: id-token: write`
42
+ - `permissions: actions: write` on auto-release so it can dispatch `publish.yml`
43
+ - `auto-release.yml` must call `gh workflow run publish.yml --ref "$TAG" -f ref="$TAG"`, or `publish.yml` must have an equivalent explicit handoff trigger such as `workflow_run`
44
+ - GitHub-hosted runner
45
+ - Node.js 24, so the release job uses a current npm CLI for Trusted Publishing
46
+ - No `NPM_TOKEN`
47
+ - `npm publish` from the configured workflow file
48
+
49
+ ## First release checklist
50
+
51
+ - [ ] `package.json` name is final
52
+ - [ ] `repository.url` points to the real GitHub repository
53
+ - [ ] npm Trusted Publisher is configured
54
+ - [ ] `npm run ci` passes
55
+ - [ ] `npm pack --dry-run` contains only intended files
56
+ - [ ] CHANGELOG.md has the release date
57
+
@@ -0,0 +1,221 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { loadConfig, getPair, configExists, getConfigPath, writeDefaultConfig } from "../lib/config.ts";
4
+ import { scanModels, groupByProvider } from "../lib/scanner.ts";
5
+ import type { ModelEntry } from "../lib/scanner.ts";
6
+ import { StringEnum } from "../lib/schema.ts";
7
+
8
+ // ─── State machine ────────────────────────────────────────────────────────────
9
+
10
+ interface TwinsState {
11
+ prompt: string;
12
+ pair: [string, string];
13
+ step: 0 | 1 | 2; // 0=modelA, 1=modelB, 2=synthesis
14
+ startedAt: number;
15
+ }
16
+
17
+ let twinsState: TwinsState | null = null;
18
+ const STALE_TIMEOUT_MS = 300_000; // 5 min
19
+
20
+ /** Parse "provider/modelId" into [provider, modelId]. */
21
+ function splitModelId(fullId: string): [string, string] {
22
+ const idx = fullId.indexOf("/");
23
+ if (idx === -1) return ["", fullId];
24
+ return [fullId.slice(0, idx), fullId.slice(idx + 1)];
25
+ }
26
+
27
+ /** Try to find a Model object and set it as current. Returns true if switched. */
28
+ async function trySwitchModel(fullId: string, pi: ExtensionAPI, ctx: { modelRegistry: { find: (p: string, m: string) => unknown } }): Promise<boolean> {
29
+ const [provider, modelId] = splitModelId(fullId);
30
+ if (!provider) return false;
31
+ const model = ctx.modelRegistry.find(provider, modelId) as any;
32
+ if (!model) return false;
33
+ await pi.setModel(model);
34
+ return true;
35
+ }
36
+
37
+ // ─── Config creation helper ────────────────────────────────────────────────────
38
+
39
+ async function ensureConfig(ctx: { ui: { confirm: (msg: string) => Promise<boolean | undefined>; notify: (msg: string, level: string) => void } }): Promise<boolean> {
40
+ if (configExists()) return true;
41
+ ctx.ui.notify("No ~/.pi/twins.yaml found", "info");
42
+ const create = await ctx.ui.confirm("Create a default config at ~/.pi/twins.yaml?");
43
+ if (!create) {
44
+ ctx.ui.notify("Run /twins:scan to see available models, then create ~/.pi/twins.yaml manually", "info");
45
+ return false;
46
+ }
47
+ writeDefaultConfig();
48
+ ctx.ui.notify("Created ~/.pi/twins.yaml with default pairs", "info");
49
+ return true;
50
+ }
51
+
52
+ // ─── Synthesis prompt template ─────────────────────────────────────────────────
53
+
54
+ const SYNTHESIS_PROMPT = `\
55
+ 以下の2つの回答を読み、それぞれの最良部分を合成した1つの回答を書いてください。
56
+
57
+ --- 回答1 ---
58
+ modelA_RESPONSE
59
+
60
+ --- 回答2 ---
61
+ modelB_RESPONSE
62
+
63
+ 要件:
64
+ - 情報を統合し、矛盾を解消してください
65
+ - 冗長な部分は削除してください
66
+ - 1つの自然な回答として書いてください`;
67
+
68
+ // ─── Extension entry ──────────────────────────────────────────────────────────
69
+
70
+ export default function (pi: ExtensionAPI) {
71
+ // ── Slice 01: /twins:scan ────────────────────────────────────────────
72
+ pi.registerCommand("twins:scan", {
73
+ description: "Display available models for twin pairs",
74
+ handler: async (_args, ctx) => {
75
+ const groups = groupByProvider();
76
+ const lines: string[] = ["**Available models:**", ""];
77
+ for (const [provider, models] of Object.entries(groups)) {
78
+ lines.push(`**${provider}**`);
79
+ for (const m of models) {
80
+ lines.push(` - \`${m.id}\` — ${m.displayName}`);
81
+ }
82
+ lines.push("");
83
+ }
84
+ lines.push("Add these to `~/.pi/twins.yaml` in a `pairs` section.");
85
+ ctx.ui.notify(lines.join("\n"), "info");
86
+ },
87
+ });
88
+
89
+ // ── Slice 02: /twins:run command ──────────────────────────────────────
90
+ pi.registerCommand("twins:run", {
91
+ description: "Ask two models the same question and synthesize",
92
+ handler: async (_args, ctx) => {
93
+ if (twinsState) {
94
+ ctx.ui.notify("A twins session is already running. Wait or abort.", "warning");
95
+ return;
96
+ }
97
+
98
+ // Ensure config exists
99
+ const ok = await ensureConfig(ctx as any);
100
+ if (!ok) return;
101
+
102
+ // Get prompt
103
+ const prompt = await ctx.ui.input("What would you like to ask both models?", "");
104
+ if (prompt === undefined) return;
105
+
106
+ // Get the default model pair
107
+ let pair: [string, string];
108
+ try {
109
+ pair = getPair();
110
+ } catch (err) {
111
+ ctx.ui.notify(err instanceof Error ? err.message : "Config error", "error");
112
+ return;
113
+ }
114
+
115
+ // Initialize state
116
+ twinsState = { prompt, pair, step: 0, startedAt: Date.now() };
117
+ ctx.ui.setStatus("twins", `Step 1/3: Asking ${pair[0]}...`);
118
+
119
+ // Try to switch to model A
120
+ await trySwitchModel(pair[0], pi, ctx as any);
121
+
122
+ // Queue the user's prompt as a user message
123
+ pi.sendUserMessage(prompt);
124
+ },
125
+ });
126
+
127
+ // ── Slice 02: agent_end state machine ─────────────────────────────────
128
+ pi.on("agent_end", async (_event, ctx) => {
129
+ const state = twinsState;
130
+ if (!state) return;
131
+
132
+ // Stale guard: clear if session is too old
133
+ if (Date.now() - state.startedAt > STALE_TIMEOUT_MS) {
134
+ twinsState = null;
135
+ ctx.ui.setStatus("twins", "");
136
+ return;
137
+ }
138
+
139
+ try {
140
+ if (state.step === 0) {
141
+ // Step 1 done (model A responded). Now run model B.
142
+ state.step = 1;
143
+ ctx.ui.setStatus("twins", `Step 2/3: Asking ${state.pair[1]}...`);
144
+ await trySwitchModel(state.pair[1], pi, ctx as any);
145
+ pi.sendUserMessage(state.prompt);
146
+ } else if (state.step === 1) {
147
+ // Step 2 done (model B responded). Now synthesize.
148
+ state.step = 2;
149
+ ctx.ui.setStatus("twins", "Step 3/3: Synthesizing...");
150
+ pi.sendUserMessage("Look at the two previous answers. Synthesize them into one coherent answer. Keep the model names visible so the user knows which insights came from which model.");
151
+ } else if (state.step === 2) {
152
+ // Synthesis done.
153
+ twinsState = null;
154
+ ctx.ui.setStatus("twins", "");
155
+ ctx.ui.notify("pi-twins: All done! Responses from both models synthesized above.", "info");
156
+ }
157
+ } catch (err) {
158
+ twinsState = null;
159
+ ctx.ui.setStatus("twins", "");
160
+ ctx.ui.notify(`pi-twins error: ${err instanceof Error ? err.message : String(err)}`, "error");
161
+ }
162
+ });
163
+
164
+ // ── Slice 02: twins_run tool (for AI agents) ─────────────────────────
165
+ pi.registerTool({
166
+ name: "twins_run",
167
+ label: "Twins Run",
168
+ description: "Run a prompt on two models in parallel and return the synthesized result",
169
+ promptSnippet: "twins_run: send the same prompt to two configured models and synthesize the responses",
170
+ promptGuidelines: [
171
+ "Use twins_run when a decision benefits from multiple model perspectives",
172
+ "The configured pair in ~/.pi/twins.yaml determines which models run",
173
+ "Returns a structured result with both responses and the synthesis",
174
+ ],
175
+ parameters: Type.Object({
176
+ prompt: Type.String({ description: "The question or task to ask both models" }),
177
+ pair: Type.Optional(Type.String({ description: "Pair name from config (defaults to 'default')" })),
178
+ }),
179
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
180
+ let pair: [string, string];
181
+ try {
182
+ pair = getPair(params.pair);
183
+ } catch (err) {
184
+ return {
185
+ content: [{ type: "text", text: `Config error: ${err instanceof Error ? err.message : String(err)}` }],
186
+ isError: true,
187
+ } as any;
188
+ }
189
+
190
+ // Sequential model calls (parallel not possible from tool context)
191
+ const [modelA, modelB] = pair;
192
+
193
+ return {
194
+ content: [{
195
+ type: "text",
196
+ text: `To get a twin perspective, run this prompt on both models.
197
+ After getting both responses, synthesize them.
198
+
199
+ Models: ${modelA} and ${modelB}
200
+ Prompt: ${params.prompt}
201
+
202
+ Start with ${modelA}, then ${modelB}, then synthesize.`,
203
+ }],
204
+ details: { pair: [modelA, modelB], prompt: params.prompt },
205
+ } as any;
206
+ },
207
+ });
208
+
209
+ // ── Slice 01: config creation tool ─────────────────────────────────────
210
+ pi.registerCommand("twins:config", {
211
+ description: "Create or show the pi-twins configuration file",
212
+ handler: async (_args, ctx) => {
213
+ if (configExists()) {
214
+ ctx.ui.notify(`Config exists at: ${getConfigPath()}`, "info");
215
+ } else {
216
+ writeDefaultConfig();
217
+ ctx.ui.notify(`Created default config at: ${getConfigPath()}`, "info");
218
+ }
219
+ },
220
+ });
221
+ }
package/lib/config.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { parse } from "yaml";
5
+ import type { TwinsConfig } from "./schema.ts";
6
+ import { DEFAULT_CONFIG, DEFAULT_PAIR_NAME } from "./schema.ts";
7
+
8
+ const CONFIG_DIR = join(homedir(), ".pi");
9
+ const CONFIG_PATH = join(CONFIG_DIR, "twins.yaml");
10
+
11
+ /** Read and parse twins config. Returns default config if file doesn't exist. */
12
+ export function loadConfig(): TwinsConfig {
13
+ if (!existsSync(CONFIG_PATH)) {
14
+ return { ...DEFAULT_CONFIG, pairs: { ...DEFAULT_CONFIG.pairs } };
15
+ }
16
+
17
+ try {
18
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
19
+ const parsed = parse(raw);
20
+
21
+ if (!parsed || typeof parsed !== "object") {
22
+ throw new Error("Config file must contain a YAML object");
23
+ }
24
+
25
+ const pairs = parsed.pairs;
26
+ if (!pairs || typeof pairs !== "object") {
27
+ throw new Error('Config must have a "pairs" key with model pair definitions');
28
+ }
29
+
30
+ const result: TwinsConfig = { pairs: {} };
31
+
32
+ for (const [name, models] of Object.entries(pairs)) {
33
+ if (!Array.isArray(models) || models.length < 2) {
34
+ throw new Error(`Pair "${name}" must have at least 2 model identifiers`);
35
+ }
36
+ result.pairs[name] = [String(models[0]), String(models[1])];
37
+ }
38
+
39
+ return result;
40
+ } catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ throw new Error(`Failed to load ${CONFIG_PATH}: ${msg}`);
43
+ }
44
+ }
45
+
46
+ /** Get a specific pair by name, falling back to default. */
47
+ export function getPair(pairName?: string): [string, string] {
48
+ const config = loadConfig();
49
+ const name = pairName || DEFAULT_PAIR_NAME;
50
+ const pair = config.pairs[name];
51
+ if (!pair) {
52
+ throw new Error(`Pair "${name}" not found in config. Available: ${Object.keys(config.pairs).join(", ")}`);
53
+ }
54
+ return pair;
55
+ }
56
+
57
+ /** Check if config file exists. */
58
+ export function configExists(): boolean {
59
+ return existsSync(CONFIG_PATH);
60
+ }
61
+
62
+ /** Get config file path for display. */
63
+ export function getConfigPath(): string {
64
+ return CONFIG_PATH;
65
+ }
66
+
67
+ /** Create default config file with a template. */
68
+ export function writeDefaultConfig(): void {
69
+ if (!existsSync(CONFIG_DIR)) {
70
+ mkdirSync(CONFIG_DIR, { recursive: true });
71
+ }
72
+
73
+ const template = `# pi-twins configuration
74
+ # Define model pairs to run in parallel.
75
+ pairs:
76
+ default:
77
+ - anthropic/claude-sonnet-4
78
+ - google/gemini-2.5-pro
79
+ coding:
80
+ - anthropic/claude-sonnet-4
81
+ - openai/gpt-4o
82
+ `;
83
+
84
+ writeFileSync(CONFIG_PATH, template, "utf-8");
85
+ }
package/lib/scanner.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Scan for available models in Pi's provider system.
3
+ *
4
+ * MVP strategy: returns a curated list of well-known models.
5
+ * Future: read from Pi's provider registry dynamically.
6
+ */
7
+
8
+ export interface ModelEntry {
9
+ id: string;
10
+ displayName: string;
11
+ provider: string;
12
+ }
13
+
14
+ /** Known models commonly available via Pi providers. */
15
+ const KNOWN_MODELS: ModelEntry[] = [
16
+ { id: "anthropic/claude-sonnet-4", displayName: "Claude 4 Sonnet", provider: "anthropic" },
17
+ { id: "anthropic/claude-opus-4", displayName: "Claude 4 Opus", provider: "anthropic" },
18
+ { id: "anthropic/claude-sonnet-4-20250514", displayName: "Claude 4 Sonnet (date pinned)", provider: "anthropic" },
19
+ { id: "google/gemini-2.5-pro", displayName: "Gemini 2.5 Pro", provider: "google" },
20
+ { id: "google/gemini-2.5-flash", displayName: "Gemini 2.5 Flash", provider: "google" },
21
+ { id: "openai/gpt-4o", displayName: "GPT-4o", provider: "openai" },
22
+ { id: "openai/gpt-4.1", displayName: "GPT-4.1", provider: "openai" },
23
+ { id: "deepseek/deepseek-v4-pro", displayName: "DeepSeek V4 Pro", provider: "deepseek" },
24
+ { id: "deepseek/deepseek-r1", displayName: "DeepSeek R1", provider: "deepseek" },
25
+ ];
26
+
27
+ /** Get list of available models. */
28
+ export function scanModels(): ModelEntry[] {
29
+ return [...KNOWN_MODELS];
30
+ }
31
+
32
+ /** Find a model entry by its full ID. */
33
+ export function findModelById(id: string): ModelEntry | undefined {
34
+ return KNOWN_MODELS.find((m) => m.id === id);
35
+ }
36
+
37
+ /** Group models by provider for display. */
38
+ export function groupByProvider(): Record<string, ModelEntry[]> {
39
+ const groups: Record<string, ModelEntry[]> = {};
40
+ for (const model of KNOWN_MODELS) {
41
+ if (!groups[model.provider]) groups[model.provider] = [];
42
+ groups[model.provider].push(model);
43
+ }
44
+ return groups;
45
+ }
package/lib/schema.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { Type, type TSchemaOptions, type TEnum } from "typebox";
2
+
3
+ export function StringEnum<const Values extends [string, ...string[]]>(
4
+ values: readonly [...Values],
5
+ options?: TSchemaOptions,
6
+ ): TEnum<Values> {
7
+ return Type.Enum([...values] as [string, ...string[]], options) as unknown as TEnum<Values>;
8
+ }
9
+
10
+ /** Twins config YAML shape */
11
+ export interface TwinsConfig {
12
+ pairs: Record<string, [string, string]>;
13
+ }
14
+
15
+ export const DEFAULT_PAIR_NAME = "default";
16
+
17
+ export const DEFAULT_CONFIG: TwinsConfig = {
18
+ pairs: {
19
+ [DEFAULT_PAIR_NAME]: ["anthropic/claude-sonnet-4", "google/gemini-2.5-pro"],
20
+ },
21
+ };
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "pi-twins",
3
+ "version": "0.1.0",
4
+ "description": "Dual-model synthesis for Pi — run the same prompt on two models, get one synthesized answer",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Keisu",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "dual-model",
12
+ "synthesis",
13
+ "ai-comparison",
14
+ "agent-skill",
15
+ "typescript"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/eiei114/pi-twins.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/eiei114/pi-twins/issues"
23
+ },
24
+ "homepage": "https://github.com/eiei114/pi-twins#readme",
25
+ "files": [
26
+ "extensions/",
27
+ "lib/",
28
+ "skills/",
29
+ "prompts/",
30
+ "themes/",
31
+ "docs/",
32
+ "README.md",
33
+ "LICENSE",
34
+ "CHANGELOG.md"
35
+ ],
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "node --test tests/*.test.mjs",
39
+ "ci": "npm run typecheck && npm test && npm run pack:check",
40
+ "pack:check": "npm pack --dry-run"
41
+ },
42
+ "pi": {
43
+ "extensions": [
44
+ "./extensions"
45
+ ],
46
+ "skills": [
47
+ "./skills"
48
+ ],
49
+ "prompts": [
50
+ "./prompts"
51
+ ],
52
+ "themes": [
53
+ "./themes"
54
+ ]
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "peerDependencies": {
60
+ "@earendil-works/pi-ai": "*",
61
+ "@earendil-works/pi-coding-agent": "*",
62
+ "@earendil-works/pi-tui": "*",
63
+ "typebox": "*"
64
+ },
65
+ "devDependencies": {
66
+ "@earendil-works/pi-ai": "latest",
67
+ "@earendil-works/pi-coding-agent": "latest",
68
+ "@earendil-works/pi-tui": "latest",
69
+ "@types/node": "^22.0.0",
70
+ "typebox": "latest",
71
+ "typescript": "^6.0.3"
72
+ },
73
+ "dependencies": {
74
+ "yaml": "^2.9.0"
75
+ }
76
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Example prompt template for this Pi package.
3
+ arguments:
4
+ topic:
5
+ description: Topic to explain
6
+ required: true
7
+ ---
8
+
9
+ Explain {{topic}} in a concise, practical way.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: example-skill
3
+ description: Example Agent Skill shipped by this Pi package template. Use when testing that package skill discovery works.
4
+ ---
5
+
6
+ # Example Skill
7
+
8
+ When invoked, explain that this is a template skill and tell the user to replace it with a real workflow.
9
+
10
+ ## Response pattern
11
+
12
+ - State that the package loaded correctly.
13
+ - Point to `skills/example-skill/SKILL.md` as the file to edit.
14
+ - Keep the response concise.
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "example-theme",
3
+ "colors": {
4
+ "primary": "#8b5cf6",
5
+ "accent": "#22d3ee"
6
+ }
7
+ }