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 +43 -0
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/extensions/friends/client.ts +210 -0
- package/extensions/friends/index.ts +123 -0
- package/extensions/friends/tunnel.ts +92 -0
- package/package.json +19 -4
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
|
-
#
|
|
1
|
+
# pi-friends
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|