pi-friends 0.0.0-stage → 1.0.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/AGENTS.md ADDED
@@ -0,0 +1,43 @@
1
+ # AGENTS.md: pi-friends
2
+
3
+ Purpose and design: `README.md`. Public repo (nkavassalis), personal project, MIT.
4
+
5
+ Rules:
6
+ - **Public-safe, always.** Never commit IPs, hostnames, usernames, home paths, keys or live config.
7
+ Hosts and keys live in an untracked `.env` or in `~/.config/pi-friends/`. This repo gets installed
8
+ by strangers; assume every line is read by someone who is not Nick.
9
+ - Never hardcode a friend's name, endpoint or model into code. Friends are config; the only place a
10
+ specific friend may appear is `scripts/install-flint.sh` (it provisions one named example) and the
11
+ README, where it is labelled as a measured example rather than a requirement.
12
+ - Every claim about quality or speed is measured, with the result file next to it, or labelled unmeasured.
13
+ - Don't change how any server runs here: serving belongs to spark-tuning and mac-tuning.
14
+ - Dates YYYY-MM-DD; date every statement of current state.
15
+
16
+ ## Layout
17
+ | Path | What |
18
+ |---|---|
19
+ | `package.json` | npm/pi package manifest; the `pi-package` keyword is what makes it discoverable in the pi gallery |
20
+ | `extensions/friends/index.ts` | the pi extension: registers one `ask_<name>` tool per configured friend, plus `/friends` |
21
+ | `extensions/friends/client.ts` | config (legacy + `friends[]` shapes), per-friend one-at-a-time queue, the streamed request, brief check (no pi imports) |
22
+ | `extensions/friends/tunnel.ts` | optional on-demand SSH tunnel (used only with a `tunnel` config block) |
23
+ | `test/client.test.ts`, `test/tunnel.test.ts`, `test/fake_ssh.mjs` | offline tests: fake OpenAI-compatible SSE server, fake ssh |
24
+ | `scripts/tunnel-flint.sh` | manual alternative: loopback-only SSH tunnel to the Mac, self-restarting |
25
+ | `scripts/install-flint.sh [--uninstall]` | provisions the flint example: symlink, key, default config |
26
+
27
+ ## Tests (run before every commit)
28
+ ```bash
29
+ node --experimental-strip-types --test test/*.test.ts
30
+ bash -n scripts/*.sh
31
+ git diff --cached | grep -nE '([0-9]{1,3}\.){3}[0-9]{1,3}|/home/|/Users/|hf_[A-Za-z0-9]{10,}|gh[pousr]_[A-Za-z0-9]{10,}' # 127.0.0.1 is fine
32
+ ```
33
+ Run pi from scripts with `< /dev/null`: piped stdin becomes part of the prompt.
34
+
35
+ ## Notes for agents
36
+ - `client.ts` must stay importable without pi (that is what makes the tests offline); all pi
37
+ imports live in `index.ts`.
38
+ - A friend's server is assumed single-generation, so `Queue` is per friend, not global — two
39
+ different servers should not serialize against each other.
40
+ - `loadFriends()` re-reads the file on every call (config edits need no restart) but tools register
41
+ once at startup, so adding a friend needs a pi restart; changing its `baseUrl` does not.
42
+ - The pre-rename config dir `~/.config/pi-escalation/` is still read as a fallback
43
+ (`resolveCfg`); remove that fallback once no install uses it (dated 2026-10-29).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nick Kavassalis
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 CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # pi-friends
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [pi](https://pi.dev/) extension for consulting **other models on hard problems** — without ever
4
+ handing them your session. Each *friend* is any OpenAI-compatible chat endpoint; each gets its own
5
+ `ask_<name>` tool. The agent writes a self-contained brief, the friend sees **only the brief** (no
6
+ conversation history, no files), and only the final answer comes back.
7
+
8
+ ```json
9
+ { "friends": [
10
+ { "name": "flint", "baseUrl": "http://<reasoning-box>:11502/v1", "model": "flint" },
11
+ { "name": "sage", "baseUrl": "http://<review-box>:8000/v1", "model": "sage",
12
+ "system": "You review plans. Answer with flaws ranked by severity." }
13
+ ]}
14
+ ```
15
+ → tools `ask_flint` and `ask_sage`, each with its own queue, persona, description and key file.
16
+ Adding a friend is a config entry plus a key file — no code.
17
+
18
+ ## Why this shape
19
+
20
+ Big-model escalation usually means switching the session's model, which re-sends the whole
21
+ conversation. That is slow (prefill dominates on small hardware), leaks the entire history to a
22
+ third endpoint, and pollutes the friend's context with tool-call noise. Friends invert it:
23
+
24
+ - **One turn, brief only.** The friend prefills ~1–16K tokens you *chose*, not 100K you didn't.
25
+ - **Answer, not transcript.** Reasoning tokens are counted for stats but never returned; the agent
26
+ gets the solution, applies it, and runs the tests. (Verification is the agent's job — a friend's
27
+ answer is a strong suggestion, not authority.)
28
+ - **Good briefs are a skill, so the tool teaches it.** The tool description tells the agent what a
29
+ brief must contain (goal, code pasted, failing output, constraints, what was tried) and refuses
30
+ briefs over `maxBriefChars` instead of silently truncating them.
31
+ - **Per-friend queues with progress.** Local servers (llama.cpp, rec, vLLM single-worker) serve one
32
+ generation at a time; the tool serializes and reports "waiting for N earlier call(s)".
33
+
34
+ A measured example of the trade-off (same problems, seeds and sandbox on both machines; details in
35
+ [spark-tuning](https://github.com/nkavassalis/spark-tuning) and
36
+ [mac-tuning](https://github.com/nkavassalis/mac-tuning)): a local agent model (Qwen FP4, 2× GB10)
37
+ solves ~51% of hard LiveCodeBench problems; a reasoning model on a Mac mini (Recursive Flint int4)
38
+ solves ~71% of the same set — and solves almost everything the agent model solves. Routing the hard
39
+ problems to the friend beat racing both models. **Your numbers will differ; measure before you
40
+ trust any friend.** Flint's edge is measured on competitive programming; its use as a *design
41
+ reviewer* (which the tool also supports) is promising but unmeasured.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pi install git:github.com/nkavassalis/pi-friends@v1.0.0 # or npm: pi install npm:pi-friends
47
+ # or from a clone, for development:
48
+ pi --extension extensions/friends/index.ts # one run, nothing installed globally
49
+ ```
50
+
51
+ Then write `~/.config/pi-friends/config.json` (see the shape above) and a key file per friend that
52
+ auths: `~/.config/pi-friends/<name>_key` (mode 600; contents never displayed by anything here).
53
+ `/friends` inside pi lists configured friends — tool, endpoint, model, key status, tunnel/direct.
54
+ Config is re-read per call, so `baseUrl`/key edits take effect without a restart; a **new friend**
55
+ needs a pi restart (tools register at startup).
56
+
57
+ If you host Flint with [Recursive's `rec`](https://github.com/recursive-org) on a Mac,
58
+ `scripts/install-flint.sh` provisions that one friend (fetches the key over SSH, writes the config);
59
+ `scripts/tunnel-flint.sh` is the manual SSH-tunnel fallback. Everything else in this repo is
60
+ friend-agnostic.
61
+
62
+ ## Settings
63
+
64
+ `~/.config/pi-friends/config.json` — either `{"friends":[{...}, ...]}` or a single friend inline
65
+ (legacy shape; loads as one friend named `friend`). Unknown keys are passed through; missing keys
66
+ take the defaults in `client.ts`.
67
+
68
+ | Setting | Default | Notes |
69
+ |---|---|---|
70
+ | `name` | `friend` | tool becomes `ask_<name>` (sanitized to `[a-z0-9_]`) |
71
+ | `baseUrl` | `http://127.0.0.1:11501/v1` | OpenAI-compatible base URL |
72
+ | `model` | `friend` | sent as `model` in the request |
73
+ | `keyFile` | `~/.config/pi-friends/<name>_key` | bearer token; read at call time, never logged |
74
+ | `system` | built-in | one-turn persona; set per friend (e.g. reviewer vs solver) |
75
+ | `description` / `label` / `guidelines` | built-in | override the tool's prompt text; `{{tool}}` is substituted |
76
+ | `maxTokens` | 32768 | reasoning **and** answer share this; reasoning-only exhaustion is reported as such |
77
+ | `temperature` / `topP` / `topK` | 0.6 / 0.95 / 20 | the measured-best setting for flint; tune per friend |
78
+ | `timeoutMin` | 20 | whole request, queue wait excluded |
79
+ | `maxBriefChars` | 64000 | longer briefs are refused with advice, never truncated |
80
+ | `tunnel` | unset | optional `{host, identity, localPort, remotePort}`: the tool opens/repairs a loopback `ssh -N -L` itself — for friends on machines that aren't always reachable |
81
+
82
+ ## Privacy & safety
83
+
84
+ - The friend receives **only** the brief and the system persona. No session history, no file paths
85
+ unless the agent pasted them, no environment variables.
86
+ - Keys live in 600-mode files and are read at call time; nothing prints their contents.
87
+ - Briefs travel as plain HTTP by default — fine on a trusted LAN, put TLS or a tunnel in front of
88
+ anything else. Treat anything in a brief as disclosed to that endpoint's operator.
89
+ - The tool refuses empty and oversized briefs; it never silently cuts.
90
+
91
+ ## Tests
92
+
93
+ ```bash
94
+ node --experimental-strip-types --test test/*.test.ts # offline: fake OpenAI-compatible SSE server + fake ssh
95
+ bash -n scripts/*.sh
96
+ ```
97
+
98
+ 16 tests cover streaming (reasoning in `delta.reasoning`, answer in `delta.content`, usage in the
99
+ final empty-choices chunk), request shape and auth headers, timeouts, abort, the queue, the brief
100
+ limit, config loading (legacy + friends shapes, name sanitizing, per-name key defaults) and the
101
+ tunnel state machine.
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,210 @@
1
+ // Friend client: config, a one-at-a-time queue, and one streamed chat request.
2
+ // No pi imports, so `node --test` can run it (test/client.test.ts) against a fake server.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { TunnelConfig } from "./tunnel.ts";
7
+
8
+ export interface FriendConfig {
9
+ name?: string; // friend name; the tool is ask_<name>. Default "friend" (a legacy single-friend config loads as one friend named "friend")
10
+ baseUrl: string; // OpenAI-compatible base, e.g. http://<mac>:11502/v1 (mac-tuning's LAN relay in front of rec)
11
+ model: string;
12
+ keyFile: string; // file holding the server's API key
13
+ maxTokens: number; // reasoning + answer; reasoning models need a large budget
14
+ temperature: number;
15
+ topP: number;
16
+ topK: number;
17
+ timeoutMin: number; // whole request, queue wait excluded
18
+ maxBriefChars: number; // longer briefs are refused (slow hosts prefill ~600 tok/s), never cut
19
+ tunnel?: TunnelConfig; // set: the tool opens/repairs the SSH tunnel itself before each call (tunnel.ts)
20
+ system?: string; // persona for the one-turn chat (index.ts has a default)
21
+ description?: string; // tool description override
22
+ label?: string; // display name in the tool UI
23
+ guidelines?: string[]; // prompt-guideline overrides; {{tool}} is replaced with the tool name
24
+ }
25
+
26
+ export const DEFAULTS: FriendConfig = {
27
+ name: "friend",
28
+ baseUrl: "http://127.0.0.1:11501/v1",
29
+ model: "friend",
30
+ keyFile: "~/.config/pi-friends/friend_key",
31
+ maxTokens: 32768,
32
+ temperature: 0.6, // measured best for the flint friend (see README benchmarks)
33
+ topP: 0.95,
34
+ topK: 20,
35
+ timeoutMin: 20,
36
+ maxBriefChars: 64000, // ~16-18K tokens, ~30 s of prefill on a slow host
37
+ };
38
+
39
+ export const CONFIG_PATH = join(homedir(), ".config", "pi-friends", "config.json");
40
+ const LEGACY_CONFIG_PATH = join(homedir(), ".config", "pi-escalation", "config.json"); // pre-rename location, read-fallback only
41
+
42
+ /** New location wins; fall back to the pre-rename dir so a half-migrated box still works. */
43
+ function resolveCfg(p: string): string {
44
+ return p === CONFIG_PATH && !existsSync(p) && existsSync(LEGACY_CONFIG_PATH) ? LEGACY_CONFIG_PATH : p;
45
+ }
46
+
47
+ const expand = (p: string) => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
48
+
49
+ export function loadConfig(p = CONFIG_PATH): FriendConfig {
50
+ const path = resolveCfg(p);
51
+ let file: Partial<FriendConfig> = {};
52
+ try {
53
+ file = JSON.parse(readFileSync(path, "utf8"));
54
+ } catch (e: any) {
55
+ if (e?.code !== "ENOENT") throw new Error(`friends: cannot read ${path}: ${e.message}`);
56
+ }
57
+ return { ...DEFAULTS, ...file };
58
+ }
59
+
60
+ /**
61
+ * All friends to register. config.json is either the original single-friend shape
62
+ * (loads as one friend named "friend" — existing installs keep working) or
63
+ * {"friends":[{name, baseUrl, ...}, ...]}. Each named friend defaults its keyFile to
64
+ * ~/.config/pi-friends/<name>_key; names are sanitized to [a-z0-9_] so the tool
65
+ * name ask_<name> is always valid.
66
+ */
67
+ export function loadFriends(p = CONFIG_PATH): FriendConfig[] {
68
+ const path = resolveCfg(p);
69
+ let file: any = {};
70
+ try {
71
+ file = JSON.parse(readFileSync(path, "utf8"));
72
+ } catch (e: any) {
73
+ if (e?.code !== "ENOENT") throw new Error(`friends: cannot read ${path}: ${e.message}`);
74
+ }
75
+ if (Array.isArray(file?.friends) && file.friends.length > 0) {
76
+ return file.friends.map((f: any) => {
77
+ const clean = String(f?.name ?? "friend").toLowerCase().replace(/[^a-z0-9_]/g, "_");
78
+ return { ...DEFAULTS, ...f, name: clean, keyFile: f.keyFile ?? `~/.config/pi-friends/${clean}_key` } as FriendConfig;
79
+ });
80
+ }
81
+ return [{ ...DEFAULTS, ...file }]; // legacy single-friend config
82
+ }
83
+
84
+ export function readKey(cfg: FriendConfig): string {
85
+ try {
86
+ return readFileSync(expand(cfg.keyFile), "utf8").trim();
87
+ } catch (e: any) {
88
+ throw new Error(`friends: cannot read the API key file ${cfg.keyFile}: ${e.message}`);
89
+ }
90
+ }
91
+
92
+ /** Runs tasks one at a time: most local servers serve a single generation, so parallel calls would only queue there blind. */
93
+ export class Queue {
94
+ private tail: Promise<unknown> = Promise.resolve();
95
+ private pending = 0;
96
+ /** Calls queued or running. */
97
+ get length() {
98
+ return this.pending;
99
+ }
100
+ run<T>(task: () => Promise<T>): Promise<T> {
101
+ this.pending++;
102
+ const go = () => task().finally(() => this.pending--);
103
+ const next = this.tail.then(go, go);
104
+ this.tail = next.catch(() => undefined);
105
+ return next;
106
+ }
107
+ }
108
+
109
+ export interface FriendResult {
110
+ answer: string;
111
+ reasoningChars: number;
112
+ finish: string; // "stop" | "length" | ...
113
+ promptTokens: number;
114
+ completionTokens: number;
115
+ cachedTokens: number;
116
+ seconds: number;
117
+ }
118
+
119
+ export type Progress = (p: { seconds: number; reasoningChars: number; answerChars: number }) => void;
120
+
121
+ /** One fresh single-turn chat: the brief is the whole context, no session history. Streams so long reasoning never hits a
122
+ * header timeout; reasoning is counted, not returned. */
123
+ export async function askFriend(
124
+ cfg: FriendConfig,
125
+ key: string,
126
+ brief: string,
127
+ signal?: AbortSignal,
128
+ onProgress?: Progress,
129
+ system?: string,
130
+ ): Promise<FriendResult> {
131
+ const t0 = Date.now();
132
+ const timeout = AbortSignal.timeout(cfg.timeoutMin * 60_000);
133
+ const sig = signal ? AbortSignal.any([signal, timeout]) : timeout;
134
+ const messages = [...(system ? [{ role: "system", content: system }] : []), { role: "user", content: brief }];
135
+ let res: Response;
136
+ try {
137
+ res = await fetch(`${cfg.baseUrl.replace(/\/$/, "")}/chat/completions`, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
140
+ body: JSON.stringify({
141
+ model: cfg.model,
142
+ messages,
143
+ stream: true,
144
+ stream_options: { include_usage: true },
145
+ max_tokens: cfg.maxTokens,
146
+ temperature: cfg.temperature,
147
+ top_p: cfg.topP,
148
+ top_k: cfg.topK,
149
+ }),
150
+ signal: sig,
151
+ });
152
+ } catch (e: any) {
153
+ throw new Error(timeout.aborted ? `friends: timed out after ${cfg.timeoutMin} min` : `friends: cannot reach ${cfg.name ?? "friend"} at ${cfg.baseUrl} (is the server up, and is baseUrl right?): ${e.message}`);
154
+ }
155
+ if (!res.ok || !res.body) {
156
+ const text = await res.text().catch(() => "");
157
+ throw new Error(`friends: ${cfg.name ?? "friend"} answered HTTP ${res.status}: ${text.slice(0, 300)}`);
158
+ }
159
+ let answer = "", reasoningChars = 0, finish = "", buf = "", lastTick = 0;
160
+ let usage: any = {};
161
+ const dec = new TextDecoder();
162
+ try {
163
+ for await (const chunk of res.body as any) {
164
+ buf += dec.decode(chunk, { stream: true });
165
+ let nl: number;
166
+ while ((nl = buf.indexOf("\n")) >= 0) {
167
+ const line = buf.slice(0, nl).trim();
168
+ buf = buf.slice(nl + 1);
169
+ if (!line.startsWith("data:")) continue;
170
+ const data = line.slice(5).trim();
171
+ if (data === "[DONE]") continue;
172
+ const ev = JSON.parse(data);
173
+ if (ev.usage) usage = ev.usage;
174
+ for (const ch of ev.choices ?? []) {
175
+ const d = ch.delta ?? {};
176
+ if (typeof d.content === "string") answer += d.content;
177
+ const r = d.reasoning ?? d.reasoning_content;
178
+ if (typeof r === "string") reasoningChars += r.length;
179
+ if (ch.finish_reason) finish = ch.finish_reason;
180
+ }
181
+ }
182
+ if (onProgress && Date.now() - lastTick > 2000) {
183
+ lastTick = Date.now();
184
+ onProgress({ seconds: (lastTick - t0) / 1000, reasoningChars, answerChars: answer.length });
185
+ }
186
+ }
187
+ } catch (e: any) {
188
+ if (timeout.aborted) throw new Error(`friends: timed out after ${cfg.timeoutMin} min (${reasoningChars} reasoning chars so far)`);
189
+ throw e;
190
+ }
191
+ return {
192
+ answer: answer.trim(),
193
+ reasoningChars,
194
+ finish: finish || "unknown",
195
+ promptTokens: usage.prompt_tokens ?? 0,
196
+ completionTokens: usage.completion_tokens ?? 0,
197
+ cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
198
+ seconds: (Date.now() - t0) / 1000,
199
+ };
200
+ }
201
+
202
+ export function checkBrief(cfg: FriendConfig, brief: string): string | undefined {
203
+ if (!brief.trim()) return "friends: the brief is empty.";
204
+ if (brief.length > cfg.maxBriefChars)
205
+ return (
206
+ `friends: the brief is ${brief.length} characters, over the ${cfg.maxBriefChars} limit (the Mac prefills slowly). ` +
207
+ "Send only the code and context the problem needs, then call the tool again."
208
+ );
209
+ return undefined;
210
+ }
@@ -0,0 +1,123 @@
1
+ // friends: consult other models on hard, self-contained problems (or plans to review) from the session model to a "friend" —
2
+ // another machine's model that sees ONLY the brief, never the conversation or the files. Friends are config:
3
+ // ~/.config/pi-friends/config.json holds {"friends":[...]} or (legacy) one friend inline:
4
+ // {"friends":[{name, baseUrl, model, keyFile, system?, description?, label?, guidelines?, maxTokens?, temperature?,
5
+ // topP?, topK?, timeoutMin?, maxBriefChars?, tunnel?}, ...]}. Each friend registers its own ask_<name> tool with its
6
+ // own queue (each server serves one generation at a time). Design and measurements: README.md.
7
+ import { Type } from "@earendil-works/pi-ai";
8
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { askFriend, checkBrief, loadFriends, Queue, readKey, type FriendConfig } from "./client.ts";
13
+ import { ensureTunnel } from "./tunnel.ts";
14
+
15
+ const DEFAULT_SYSTEM =
16
+ "You are consulted by another coding agent on a hard problem. The brief below is all the context you have. " +
17
+ "Reason it through, then answer with the solution itself: the code (complete functions, not fragments) and a short " +
18
+ "explanation of the key idea and why it is correct. If the brief lacks something you need, say exactly what.";
19
+
20
+ function makeTool(f: FriendConfig) {
21
+ const name = f.name ?? "friend";
22
+ const toolName = `ask_${name}`;
23
+ const label = f.label ?? name;
24
+ const queue = new Queue(); // per friend: separate servers, independent one-at-a-time slots
25
+
26
+ const description =
27
+ f.description ??
28
+ `Hand a hard, self-contained problem to ${label}. It sees ONLY the brief, not this conversation or the files, so the ` +
29
+ `brief must contain everything: the goal, the relevant code, the exact failing test or error output, constraints ` +
30
+ `(language, signatures, limits) and what was already tried. Also useful as a design reviewer: send the plan plus its ` +
31
+ `constraints and invariants and ask for flaws ranked by severity. Slow: expect 1-10 minutes. Returns the answer; ` +
32
+ `verify it (apply it, run the tests) before relying on it.`;
33
+
34
+ const guidelines = (
35
+ f.guidelines ?? [
36
+ "Use {{tool}} for hard algorithmic problems, or after two failed attempts at the same failing test. Do not use it for routine edits, file lookups or questions about this repository.",
37
+ "Use it to review a proposed design or feature plan before implementation starts when the user asks for a second opinion: paste the plan, the interfaces it touches, the constraints and invariants it must respect, and ask for flaws ranked by severity, concrete alternatives, and tests that are missing. You decide whether to adopt the critique; say what you took and what you rejected and why.",
38
+ "Write the {{tool}} brief so that a strong engineer who has never seen this repository could solve it: paste the code and error output, do not refer to files by path alone.",
39
+ ]
40
+ ).map((g) => g.replaceAll("{{tool}}", toolName));
41
+
42
+ return defineTool({
43
+ name: toolName,
44
+ label: `Ask ${label}`,
45
+ description,
46
+ promptSnippet: `${toolName}: hand a hard self-contained algorithm or debugging problem (or a plan to review) to ${label} (slow)`,
47
+ promptGuidelines: guidelines,
48
+ parameters: Type.Object({
49
+ brief: Type.String({ description: `Self-contained problem statement with all code, errors and constraints ${label} needs` }),
50
+ }),
51
+ executionMode: "sequential",
52
+
53
+ async execute(_toolCallId, params, signal, onUpdate) {
54
+ // re-read config per call so baseUrl/key edits take effect without a restart
55
+ const cfg = loadFriends().find((x) => x.name === name) ?? f;
56
+ const bad = checkBrief(cfg, params.brief);
57
+ if (bad) throw new Error(bad.replace(/^friends:/, `${toolName}:`));
58
+ const key = readKey(cfg);
59
+ const ahead = queue.length;
60
+ if (ahead > 0) onUpdate?.({ content: [{ type: "text", text: `Waiting for ${ahead} earlier ${toolName} call(s)...` }], details: undefined });
61
+ const r = await queue.run(async () => {
62
+ if (cfg.tunnel) {
63
+ const t = await ensureTunnel(cfg.tunnel);
64
+ if (t !== "up") onUpdate?.({ content: [{ type: "text", text: `SSH tunnel to ${cfg.tunnel.host} ${t}` }], details: undefined });
65
+ }
66
+ return askFriend(cfg, key, params.brief, signal, (p) =>
67
+ onUpdate?.({
68
+ content: [{ type: "text", text: `${label} thinking: ${Math.round(p.seconds)} s, ${p.reasoningChars} reasoning chars, ${p.answerChars} answer chars` }],
69
+ details: undefined,
70
+ }), cfg.system ?? DEFAULT_SYSTEM);
71
+ });
72
+ const stats = `[${toolName}: ${Math.round(r.seconds)} s, ${r.promptTokens} prompt tokens (${r.cachedTokens} cached), ${r.completionTokens} completion tokens, finish=${r.finish}]`;
73
+ if (!r.answer) {
74
+ throw new Error(
75
+ r.finish === "length"
76
+ ? `${toolName}: ${label} used the whole ${cfg.maxTokens}-token budget reasoning and gave no answer. ${stats} Narrow the problem or split it, then retry.`
77
+ : `${toolName}: ${label} returned no answer. ${stats}`,
78
+ );
79
+ }
80
+ const cut = r.finish === "length" ? "\n\nNote: the answer was cut off at the token budget; it may be incomplete." : "";
81
+ return {
82
+ content: [{ type: "text", text: `${r.answer}${cut}\n\n${stats}` }],
83
+ details: { friend: name, seconds: r.seconds, finish: r.finish, reasoningChars: r.reasoningChars },
84
+ usage: {
85
+ input: r.promptTokens,
86
+ output: r.completionTokens,
87
+ cacheRead: r.cachedTokens,
88
+ cacheWrite: 0,
89
+ totalTokens: r.promptTokens + r.completionTokens,
90
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
91
+ },
92
+ };
93
+ },
94
+ });
95
+ }
96
+
97
+ export default function (pi: ExtensionAPI) {
98
+ for (const f of loadFriends()) pi.registerTool(makeTool(f));
99
+
100
+ // /friends — who is configured, and which tools are live. Status only: the key FILE's
101
+ // presence is reported, its contents never are (same rule as meta-guard's FORBIDDEN list).
102
+ pi.registerCommand("friends", {
103
+ description: "List configured friends (escalation models): tool, endpoint, model, key status",
104
+ handler: async (_args, ctx) => {
105
+ const cur = loadFriends(); // fresh read: config edits show up without a restart
106
+ const lines = [`${cur.length} friend(s) configured — every one is an active tool (no single "current" friend):`];
107
+ for (const f of cur) {
108
+ const kf = f.keyFile?.startsWith("~/") ? join(homedir(), f.keyFile.slice(2)) : f.keyFile ?? "";
109
+ const key = kf && existsSync(kf) ? (readFileSync(kf, "utf8").trim() ? "key ok" : "key EMPTY") : "key MISSING";
110
+ const bits = [
111
+ `tool ask_${f.name}`,
112
+ f.baseUrl,
113
+ `model=${f.model}`,
114
+ key,
115
+ f.tunnel ? `tunnel via ${f.tunnel.host}` : "direct",
116
+ f.system ? "custom persona" : "default persona",
117
+ ];
118
+ lines.push(` ${f.name}: ${bits.join(" | ")}`);
119
+ }
120
+ ctx.ui.notify(lines.join("\n"), "info");
121
+ },
122
+ });
123
+ }
@@ -0,0 +1,92 @@
1
+ // On-demand SSH tunnel for a friend behind SSH (e.g. a laptop that is not always on the LAN). This machine has no service manager (a container with sshd as PID 1), so
2
+ // instead of a service the tool checks the tunnel before every call: it starts ssh if nothing listens on the local port, and
3
+ // restarts it if the host in the config changed since it was started. State (pid + the settings it was started with) lives in
4
+ // ~/.config/pi-friends/tunnel.json. No pi imports (tested by test/tunnel.test.ts).
5
+ import { spawn } from "node:child_process";
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { connect } from "node:net";
10
+
11
+ export interface TunnelConfig {
12
+ host: string; // ssh target of the Mac, e.g. user@<mac-ip>: change this to point at another address
13
+ identity?: string; // ssh identity file
14
+ localPort: number; // loopback port on this machine (must match baseUrl)
15
+ remotePort: number; // rec's port on the Mac's loopback
16
+ sshCommand?: string; // default "ssh" (tests use a fake)
17
+ }
18
+
19
+ export const STATE_PATH = join(homedir(), ".config", "pi-escalation", "tunnel.json");
20
+ const expand = (p: string) => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
21
+
22
+ export function portOpen(port: number, ms = 1000): Promise<boolean> {
23
+ return new Promise((resolve) => {
24
+ const s = connect({ host: "127.0.0.1", port });
25
+ const done = (ok: boolean) => {
26
+ s.destroy();
27
+ resolve(ok);
28
+ };
29
+ s.setTimeout(ms, () => done(false));
30
+ s.once("connect", () => done(true));
31
+ s.once("error", () => done(false));
32
+ });
33
+ }
34
+
35
+ const alive = (pid: number) => {
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ };
43
+
44
+ const sameSettings = (a: TunnelConfig, b: TunnelConfig) =>
45
+ a.host === b.host && a.identity === b.identity && a.localPort === b.localPort && a.remotePort === b.remotePort;
46
+
47
+ function readState(path: string): { pid: number; cfg: TunnelConfig } | undefined {
48
+ try {
49
+ return JSON.parse(readFileSync(path, "utf8"));
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /** Make sure 127.0.0.1:localPort forwards to the configured Mac. Returns what it did, for the tool's progress line. */
56
+ export async function ensureTunnel(t: TunnelConfig, statePath = STATE_PATH, waitMs = 15000): Promise<"up" | "started" | "restarted"> {
57
+ const st = readState(statePath);
58
+ const ours = st && alive(st.pid);
59
+ const open = await portOpen(t.localPort);
60
+ if (open && (!ours || sameSettings(st!.cfg, t))) return "up"; // ours with the same settings, or one started by hand
61
+ if (ours) {
62
+ // settings changed (e.g. a new host), or our ssh is alive but the forward is dead
63
+ process.kill(st!.pid, "SIGTERM");
64
+ for (let i = 0; i < 50 && (await portOpen(t.localPort, 200)); i++) await new Promise((r) => setTimeout(r, 100));
65
+ }
66
+ const args = [
67
+ ...(t.identity ? ["-i", expand(t.identity)] : []),
68
+ "-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ConnectTimeout=10",
69
+ "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4",
70
+ "-L", `127.0.0.1:${t.localPort}:127.0.0.1:${t.remotePort}`, t.host,
71
+ ];
72
+ const child = spawn(t.sshCommand ?? "ssh", args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
73
+ let err = "";
74
+ child.stderr!.on("data", (d) => (err += d));
75
+ let exited: number | null = null;
76
+ child.once("exit", (code) => (exited = code ?? -1));
77
+ const t0 = Date.now();
78
+ while (Date.now() - t0 < waitMs) {
79
+ if (await portOpen(t.localPort, 300)) {
80
+ child.stderr!.destroy();
81
+ child.unref(); // outlives pi; the next call reuses it
82
+ writeFileSync(statePath, JSON.stringify({ pid: child.pid, cfg: t }), { mode: 0o600 });
83
+ return ours ? "restarted" : "started";
84
+ }
85
+ if (exited !== null) break;
86
+ await new Promise((r) => setTimeout(r, 200));
87
+ }
88
+ try {
89
+ child.kill();
90
+ } catch {}
91
+ throw new Error(`friends: could not open the SSH tunnel to ${t.host} (${exited !== null ? `ssh exited ${exited}` : "timed out"}): ${err.trim().slice(0, 300)}`);
92
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "pi-friends",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "pi extension: consult other models on hard problems. Each friend (any OpenAI-compatible endpoint) gets its own ask_<name> tool; briefs are self-contained one-turn exchanges, answers come back without reasoning transcripts.",
5
+ "keywords": ["pi-package", "pi-extension", "pi", "llm", "coding-agent", "escalation"],
6
+ "license": "MIT",
7
+ "author": "Nick Kavassalis",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/nkavassalis/pi-friends.git"
11
+ },
12
+ "homepage": "https://github.com/nkavassalis/pi-friends#readme",
13
+ "bugs": { "url": "https://github.com/nkavassalis/pi-friends/issues" },
14
+ "pi": {
15
+ "extensions": ["./extensions/friends/index.ts"]
16
+ },
17
+ "files": ["extensions/friends", "README.md", "LICENSE", "AGENTS.md"],
18
+ "scripts": {
19
+ "test": "node --experimental-strip-types --test test/*.test.ts"
20
+ }
21
+ }