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 +18 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/docs/examples.md +49 -0
- package/docs/release.md +57 -0
- package/extensions/index.ts +221 -0
- package/lib/config.ts +85 -0
- package/lib/scanner.ts +45 -0
- package/lib/schema.ts +21 -0
- package/package.json +76 -0
- package/prompts/example.md +9 -0
- package/skills/example-skill/SKILL.md +14 -0
- package/themes/example-theme.json +7 -0
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
|
+
[](https://github.com/eiei114/pi-twins/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/eiei114/pi-twins/actions/workflows/publish.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/pi-twins)
|
|
6
|
+
[](https://www.npmjs.com/package/pi-twins)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://pi.dev/packages)
|
|
9
|
+
[](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
|
package/docs/examples.md
ADDED
|
@@ -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`
|
package/docs/release.md
ADDED
|
@@ -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,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.
|