Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules

# Path B full-pi bundle (13 MB generated artifact; rebuild with `node js/build-pi-full.mjs`)
crates/pocket-pi/js/pi-full.bundle.js
crates/pocket-pi/js/pi-full.bundle.js.gz
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 26 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,10 @@ There are two ways to run pi on this API:
Rust `streamFn`s (Anthropic + OpenAI SSE). Drive it directly with
`boot` / `prompt` / `register_tool` / `pump`. Smallest footprint; best when you
want a lightweight agent and native tools, not pi's full CLI feature set.
- **The full, unmodified `pi-coding-agent`** (Path B). Load the bundle with
`rt.run_module(".../pi-full.bundle.js")`, then drive `createAgentSession` — this
is what unlocks extensions, session persistence, and pi's own tool suite.
- **The full, unmodified `pi-coding-agent`**. Load the bundle — either embedded
(`rt.load_full_pi()`, with the `embed-full-pi` feature) or from disk
(`rt.run_module(".../pi-full.bundle.js")`) — then drive `createAgentSession`.
This unlocks extensions, session persistence, and pi's own tool suite.
[`js/pi-full/driver.js`](js/pi-full/driver.js) is the reference harness.

Other `PiRuntime` methods: `run_module`, `eval_script`, `get_global_json`,
Expand All @@ -167,42 +168,50 @@ Other `PiRuntime` methods: `run_module`, `eval_script`, `get_global_json`,

---

## How pi runs unmodified (Path B)
## How pi runs unmodified

QuickJS's module linker **null-derefs** (`js_inner_module_linking`,
`quickjs.c:30806`) when you import `createAgentSession` and pull pi-coding-agent's
~500-module circular graph — an engine-level bug on graphs that large. The fix is
to hand QuickJS **one** module instead of five hundred: esbuild bundles the
*unmodified* pi source into a single ES module (only `node:*` left external), and
Pocket Pi's Node/Web layer satisfies it at runtime. Bundling isn't forking —
every dependency is the real, unmodified upstream package.
every dependency is the real, unmodified upstream package. (The one substitution
is `undici`, pi's HTTP-*proxy* transport, aliased to a stub — Pocket Pi proxies in
the native hub, so undici is never used; this keeps its whole web-fetch stack out
of the bundle.)

```sh
cd js && npm install
node build-pi-full.mjs # → crates/pocket-pi/js/pi-full.bundle.js (~13 MB, git-ignored)
node build-pi-full.mjs # → pi-full.bundle.js (~9 MB minified) + .js.gz (~1.8 MB), git-ignored
```

**Staying in sync with upstream pi** is `npm update` + rebuild — no patches to
carry. The bundle is git-ignored (a generated artifact), so the integration tests
The `.gz` is embedded into the binary under the `embed-full-pi` feature, for a
single self-contained executable (see Footprint).

**Staying in sync with upstream pi** (`@earendil-works/pi-coding-agent`) is
`npm update` + rebuild — no patches to carry. The bundle is git-ignored (a
generated artifact), so the integration tests
that use it are `#[ignore]` and run locally; the crate itself builds with only
Rust.

---

## Footprint

The runtime is tiny. The trimmed embeddable core ships as a single self-contained
binary — QuickJS + pi's `Agent` core + LLM streaming + the oxc TypeScript loader,
with **nothing to `npm install` at the destination**:
Everything ships as a single self-contained binary — **nothing to `npm install`
at the destination**. Even a binary carrying the *whole unmodified pi* (the ~9 MB
bundle, gzip-embedded to ~1.8 MB via the `embed-full-pi` feature) is one file:

| Shipping pi as… | Size |
|---|---|
| **Pocket Pi** — trimmed core, single self-contained binary | **5.9 MB** |
| `bun build --compile` (providers external; embeds JavaScriptCore) | 61 MB |
| node runtime + `node_modules` (pi-agent-core + pi-ai + deps, 106 pkgs) | 114 MB + 131 MB |
| **Pocket Pi** — one binary, full unmodified pi embedded | **~8.9 MB** |
| **Pocket Pi** — one binary, trimmed core only (no full bundle) | **~7 MB** |
| `bun build --compile` (providers external; embeds JavaScriptCore) | ~61 MB |
| node runtime + `node_modules` (pi-agent-core + pi-ai + deps) | ~114 MB + ~131 MB |

Running the *full* unmodified pi adds the ~13 MB JS bundle, loaded at runtime — a
generated artifact, not a `node_modules` tree.
The ~7 MB base is dominated by **oxc** (~1.6 MB — the TypeScript transpiler for
extensions), TLS `rustls`+`ring` (~0.6 MB), `regex` (~0.5 MB), and QuickJS (~0.5 MB).

---

Expand All @@ -212,7 +221,7 @@ generated artifact, not a `node_modules` tree.
cargo test # unit + module-system suite (23 tests); no bundle needed
cargo clippy --workspace --all-targets -- -D warnings

# Path B integration tests are #[ignore] — build the bundle first:
# The bundle-backed integration tests are #[ignore] — build the bundle first:
cd js && npm install && node build-pi-full.mjs
cargo test -p pocket-pi loads_bundled_pi_coding_agent -- --ignored # bundle evaluates
cargo test -p pocket-pi binds_extension_into_session -- --ignored # extension binds to a session (offline)
Expand Down
11 changes: 11 additions & 0 deletions crates/pocket-pi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,16 @@ oxc_span = "0.140"
oxc_transformer = "0.140"
regex = "1"

# Gzip decode for the embedded full-pi bundle (pure-Rust backend, no C). Only
# pulled in by the `embed-full-pi` feature.
flate2 = { version = "1", default-features = false, features = ["rust_backend"], optional = true }

[features]
# Embed the full, unmodified pi-coding-agent bundle (gzip) into the binary for a
# single self-contained distributable. Off by default so dev/CI builds — which
# path-load the bundle in #[ignore] tests — don't need it built. Turning it on
# requires `node js/build-pi-full.mjs` to have produced pi-full.bundle.js.gz.
embed-full-pi = ["dep:flate2"]

[dev-dependencies]
env_logger = "0.11"
11 changes: 11 additions & 0 deletions crates/pocket-pi/examples/self_contained.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! A self-contained agent binary: the full, unmodified pi-coding-agent is
//! embedded (gzip) in the executable — no external .js. Build with:
//! node js/build-pi-full.mjs
//! cargo run --release --features embed-full-pi --example self_contained
use pocket_pi::PiRuntime;

fn main() {
let mut rt = PiRuntime::new().expect("runtime");
rt.load_full_pi().expect("load embedded full pi");
println!("full pi loaded: {:?}", rt.get_global_json("__piFullLoaded"));
}
27 changes: 24 additions & 3 deletions crates/pocket-pi/js/node/async_hooks.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
export class AsyncLocalStorage { run(_s, cb) { return cb(); } getStore() { return undefined; } enterWith() {} disable() {} }
export function createHook() { return { enable() {}, disable() {} }; }
export default { AsyncLocalStorage, createHook };
// node:async_hooks — single-threaded, synchronous stubs. AsyncLocalStorage runs
// callbacks inline; AsyncResource is a real (no-op) base class so libraries that
// `class X extends AsyncResource {}` (e.g. undici) load and construct.
export class AsyncLocalStorage {
run(_store, cb, ...args) { return cb(...args); }
getStore() { return this._store; }
enterWith(store) { this._store = store; }
exit(cb, ...args) { return cb(...args); }
disable() {}
}
export class AsyncResource {
constructor(type, opts) { this.type = type; this._opts = opts; }
runInAsyncScope(fn, thisArg, ...args) { return fn.apply(thisArg, args); }
emitDestroy() { return this; }
asyncId() { return 0; }
triggerAsyncId() { return 0; }
bind(fn) { return fn; }
static bind(fn) { return fn; }
}
export function createHook() { return { enable() { return this; }, disable() { return this; } }; }
export function executionAsyncId() { return 0; }
export function triggerAsyncId() { return 0; }
export function executionAsyncResource() { return {}; }
export default { AsyncLocalStorage, AsyncResource, createHook, executionAsyncId, triggerAsyncId, executionAsyncResource };
35 changes: 35 additions & 0 deletions crates/pocket-pi/js/node/console.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// node:console — wraps QuickJS's global console. The `Console` class lets code
// construct its own logger (undici's mock formatter does `new Console(...)`).
const g = globalThis.console || {};
const out = (...a) => { try { (g.log || (() => {}))(...a); } catch {} };
const err = (...a) => { try { (g.error || g.log || (() => {}))(...a); } catch {} };

export class Console {
constructor(_stdout, _stderr) {}
log(...a) { out(...a); }
info(...a) { out(...a); }
debug(...a) { out(...a); }
dir(...a) { out(...a); }
warn(...a) { err(...a); }
error(...a) { err(...a); }
trace(...a) { err(...a); }
table(...a) { out(...a); }
group(...a) { out(...a); }
groupCollapsed(...a) { out(...a); }
groupEnd() {}
assert(cond, ...a) { if (!cond) err("Assertion failed:", ...a); }
count() {}
countReset() {}
time() {}
timeEnd() {}
timeLog() {}
clear() {}
}

const instance = new Console();
export const log = instance.log;
export const info = instance.info;
export const warn = instance.warn;
export const error = instance.error;
export const debug = instance.debug;
export default instance;
11 changes: 10 additions & 1 deletion crates/pocket-pi/js/node/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ export function createHash(_algo) {
};
}
export function createHmac(algo, key) { const h = createHash(algo); h.update(String(key) + ":"); return h; }
export function getHashes() { return ["sha1", "sha256", "sha384", "sha512", "md5"]; }
export function getCiphers() { return []; }
export function timingSafeEqual(a, b) {
if (!a || !b || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
export const constants = {};
export const webcrypto = globalThis.crypto;
export function getRandomValues(a) { return globalThis.crypto.getRandomValues(a); }
export default { randomBytes, randomUUID, randomFillSync, createHash, createHmac, webcrypto, getRandomValues };
export default { randomBytes, randomUUID, randomFillSync, createHash, createHmac, getHashes, getCiphers, timingSafeEqual, constants, webcrypto, getRandomValues };
72 changes: 55 additions & 17 deletions crates/pocket-pi/js/node/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ export function readFileSync(path, options) {
if (res.err) throw enoent(res.err, path);
return decode(res.bytes, encoding);
}
export function writeFileSync(path, data, options) {
export function writeFileSync(pathOrFd, data, options) {
// writeFileSync also accepts a file descriptor (SessionManager uses this).
if (typeof pathOrFd === "number") { fdWrite(pathOrFd, data, options); return; }
const encoding = typeof options === "string" ? options : (options && options.encoding) || "utf8";
let bytes;
if (typeof data === "string") {
const B = globalThis.__nodeBuffer;
bytes = B ? Array.from(B.from(data, encoding)) : Array.from(data, (c) => c.charCodeAt(0));
} else bytes = Array.from(data);
const res = fs.writeFile(String(path), bytes);
const res = fs.writeFile(String(pathOrFd), bytes);
if (res.err) throw new Error(res.err);
}
export function existsSync(path) {
Expand Down Expand Up @@ -73,31 +75,67 @@ export function accessSync(path, _mode) {
// until a milestone needs them.
const nope = (name) => () => { throw new Error(`fs.${name} is not implemented in Pocket Pi`); };

// Minimal fd-backed reads: openSync slurps the whole file (fine for the small
// session files pi peeks); readSync copies a window into the caller's buffer.
// This is exactly the pattern SessionManager uses to read a session header.
// fd-backed I/O over the whole-file native ops. openSync honors the read/write/
// append/exclusive flags SessionManager uses ("r", "wx", "a", …); reads slurp the
// file and readSync copies a window; fd writes append through to the path.
const __fds = new Map();
let __nextFd = 3;
export function openSync(path, _flags, _mode) {
const res = fs.readFile(String(path));
if (res.err) throw enoent(res.err, path);
function ebadf() { const e = new Error("EBADF: bad file descriptor"); e.code = "EBADF"; return e; }
function parseFlags(flags) {
const f = String(flags || "r");
return {
write: /[wa+]/.test(f),
append: /a/.test(f),
create: /[wa]/.test(f),
excl: /x/.test(f),
truncate: /w/.test(f) && !/\+/.test(f),
read: /r|\+/.test(f),
};
}
export function openSync(path, flags, _mode) {
path = String(path);
const f = parseFlags(flags);
const exists = existsSync(path);
if (f.excl && exists) { const e = new Error(`EEXIST: file already exists, open '${path}'`); e.code = "EEXIST"; throw e; }
if (!f.create && !exists) throw enoent("open", path);
if (f.truncate || (f.create && !exists)) writeFileSync(path, ""); // create/truncate
const bytes = f.read && !f.truncate && existsSync(path) ? (fs.readFile(path).bytes || []) : [];
const fd = __nextFd++;
__fds.set(fd, { bytes: res.bytes, pos: 0 });
__fds.set(fd, { path, flags: f, bytes, pos: 0 });
return fd;
}
export function readSync(fd, buffer, offset, length, position) {
const f = __fds.get(fd);
if (!f) { const e = new Error("EBADF: bad file descriptor"); e.code = "EBADF"; throw e; }
const start = position == null || position < 0 ? f.pos : position;
const e = __fds.get(fd);
if (!e) throw ebadf();
const start = position == null || position < 0 ? e.pos : position;
let n = 0;
for (; n < length && start + n < f.bytes.length; n++) {
buffer[offset + n] = f.bytes[start + n];
}
if (position == null || position < 0) f.pos = start + n;
for (; n < length && start + n < e.bytes.length; n++) buffer[offset + n] = e.bytes[start + n];
if (position == null || position < 0) e.pos = start + n;
return n;
}
function toStr(data, options) {
if (typeof data === "string") return data;
const enc = (typeof options === "string" ? options : options && options.encoding) || "utf8";
const B = globalThis.__nodeBuffer;
return B ? B.from(data).toString(enc) : String.fromCharCode(...data);
}
// Append `data` through the fd to its file (native writeFile overwrites, so we
// read-modify-write). Fine for the small, append-mostly session store.
function fdWrite(fd, data, options) {
const e = __fds.get(fd);
if (!e) throw ebadf();
const prev = existsSync(e.path) ? readFileSync(e.path, "utf8") : "";
writeFileSync(e.path, prev + toStr(data, options), "utf8");
}
export function writeSync(fd, data, _offOrPos, _length, _position) {
const s = typeof data === "string" ? data : toStr(data);
fdWrite(fd, s);
return typeof data === "string" ? s.length : data.length;
}
export function closeSync(fd) { __fds.delete(fd); }
export const writeSync = nope("writeSync");
export const fsyncSync = () => {};
export const fdatasyncSync = () => {};
export const ftruncateSync = () => {};
export function appendFileSync(path, data, options) {
const prev = existsSync(path) ? readFileSync(path, "utf8") : "";
writeFileSync(path, prev + (typeof data === "string" ? data : ""), options);
Expand Down
12 changes: 10 additions & 2 deletions crates/pocket-pi/js/node/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,17 @@ export function parse(path) {
return { root, dir, base, ext, name: ext ? base.slice(0, -ext.length) : base };
}

export const posix = { sep, delimiter: ":", normalize, isAbsolute, join, resolve, dirname, basename, extname, relative, parse };
export function toNamespacedPath(path) { return path; } // no-op on POSIX
export function format(obj) {
const dir = obj.dir || obj.root || "";
const base = obj.base || `${obj.name || ""}${obj.ext || ""}`;
if (!dir) return base;
return dir === obj.root ? `${dir}${base}` : `${dir}${sep}${base}`;
}

export const posix = { sep, delimiter: ":", normalize, isAbsolute, join, resolve, dirname, basename, extname, relative, parse, format, toNamespacedPath };
// We only implement POSIX semantics; win32 is a passthrough so imports resolve.
export const win32 = { ...posix, sep: "\\", delimiter: ";" };
export { sep };
export const delimiter = ":";
export default { sep, delimiter, normalize, isAbsolute, join, resolve, dirname, basename, extname, relative, parse, posix, win32 };
export default { sep, delimiter, normalize, isAbsolute, join, resolve, dirname, basename, extname, relative, parse, format, toNamespacedPath, posix, win32 };
15 changes: 14 additions & 1 deletion crates/pocket-pi/js/node/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ export function callbackify(fn) {
}

export function deprecate(fn) { return fn; }
// debuglog(section) → a logger; enabled only if NODE_DEBUG lists the section.
export function debuglog(section, cb) {
const env = (globalThis.process && globalThis.process.env && globalThis.process.env.NODE_DEBUG) || "";
const on = env.split(/[\s,]+/).includes(section);
const fn = on ? (...args) => { try { console.error(`${section}:`, format(...args)); } catch {} } : () => {};
if (typeof cb === "function") cb(fn);
return fn;
}
export const debug = debuglog;
export function inspect2() {}
export const isDeepStrictEqual = (a, b) => { try { return JSON.stringify(a) === JSON.stringify(b); } catch { return a === b; } };
export function stripVTControlCharacters(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, ""); }
export const _extend = Object.assign;

export const types = {
isPromise: (v) => v && typeof v.then === "function",
Expand All @@ -60,4 +73,4 @@ export const types = {
export const TextEncoder = globalThis.TextEncoder;
export const TextDecoder = globalThis.TextDecoder;

export default { inherits, format, inspect, promisify, callbackify, deprecate, types, TextEncoder, TextDecoder };
export default { inherits, format, inspect, promisify, callbackify, deprecate, debuglog, debug, isDeepStrictEqual, stripVTControlCharacters, _extend, types, TextEncoder, TextDecoder };
Loading
Loading