@pmndrs/pine 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +180 -2
- package/bin/pine.js +27 -0
- package/docs/SKILL.md +189 -0
- package/docs/references/agents.md +368 -0
- package/docs/references/coding.md +126 -0
- package/docs/references/configuration.md +229 -0
- package/docs/references/events.md +263 -0
- package/docs/references/mcp.md +257 -0
- package/docs/references/operations.md +280 -0
- package/docs/references/overview.md +186 -0
- package/docs/references/security.md +489 -0
- package/docs/references/setup.md +181 -0
- package/docs/references/sources.md +768 -0
- package/docs/references/stack.md +105 -0
- package/docs/references/tui.md +348 -0
- package/examples/home/.env.example +5 -0
- package/examples/home/USER.md +4 -0
- package/examples/home/agents/main/agent.json +4 -0
- package/examples/home/agents/main/workspace/AGENTS.md +14 -0
- package/examples/home/agents/main/workspace/MEMORY.md +4 -0
- package/examples/home/agents/main/workspace/SOUL.md +4 -0
- package/examples/home/models.json +62 -0
- package/examples/home/pine.json +30 -0
- package/examples/home/skills/README.txt +7 -0
- package/examples/launchd/com.pine.daemon.plist +32 -0
- package/mcp/browser.d.ts +34 -0
- package/mcp/browser.js +228 -0
- package/mcp/commands.d.ts +20 -0
- package/mcp/commands.js +88 -0
- package/mcp/lib.d.ts +13 -0
- package/mcp/lib.js +50 -0
- package/mcp/web.d.ts +6 -0
- package/mcp/web.js +140 -0
- package/package.json +32 -4
- package/src/agent-admin.d.ts +24 -0
- package/src/agent-admin.js +55 -0
- package/src/agents.d.ts +343 -0
- package/src/agents.js +1587 -0
- package/src/cli.d.ts +2 -0
- package/src/cli.js +526 -0
- package/src/config.d.ts +351 -0
- package/src/config.js +514 -0
- package/src/dev/claude-model.d.ts +28 -0
- package/src/dev/claude-model.js +174 -0
- package/src/env-file.d.ts +4 -0
- package/src/env-file.js +40 -0
- package/src/http.d.ts +46 -0
- package/src/http.js +407 -0
- package/src/limiter.d.ts +32 -0
- package/src/limiter.js +84 -0
- package/src/login.d.ts +21 -0
- package/src/login.js +126 -0
- package/src/manual.d.ts +61 -0
- package/src/manual.js +425 -0
- package/src/mcp-config.d.ts +40 -0
- package/src/mcp-config.js +132 -0
- package/src/net.d.ts +36 -0
- package/src/net.js +122 -0
- package/src/pine.d.ts +32 -0
- package/src/pine.js +142 -0
- package/src/router.d.ts +56 -0
- package/src/router.js +164 -0
- package/src/sandbox-fs.d.ts +27 -0
- package/src/sandbox-fs.js +172 -0
- package/src/security.d.ts +121 -0
- package/src/security.js +363 -0
- package/src/skills.d.ts +67 -0
- package/src/skills.js +163 -0
- package/src/source-types.d.ts +61 -0
- package/src/source-types.js +1 -0
- package/src/sources/command.d.ts +4 -0
- package/src/sources/command.js +77 -0
- package/src/sources/folder.d.ts +6 -0
- package/src/sources/folder.js +116 -0
- package/src/sources/ftp.d.ts +4 -0
- package/src/sources/ftp.js +307 -0
- package/src/sources/http.d.ts +4 -0
- package/src/sources/http.js +201 -0
- package/src/sources/mqtt.d.ts +18 -0
- package/src/sources/mqtt.js +97 -0
- package/src/sources/telegram.d.ts +22 -0
- package/src/sources/telegram.js +269 -0
- package/src/sources/webhook.d.ts +4 -0
- package/src/sources/webhook.js +52 -0
- package/src/sources.d.ts +339 -0
- package/src/sources.js +955 -0
- package/src/store.d.ts +89 -0
- package/src/store.js +251 -0
- package/src/timers.d.ts +83 -0
- package/src/timers.js +318 -0
- package/src/tools.d.ts +6 -0
- package/src/tools.js +777 -0
- package/src/tui/app.d.ts +209 -0
- package/src/tui/app.js +2223 -0
- package/src/tui/client.d.ts +178 -0
- package/src/tui/client.js +116 -0
- package/src/tui/code-pane.d.ts +51 -0
- package/src/tui/code-pane.js +398 -0
- package/src/tui/highlight.d.ts +8 -0
- package/src/tui/highlight.js +86 -0
- package/src/tui/picture.d.ts +141 -0
- package/src/tui/picture.js +571 -0
- package/src/tui/run.d.ts +8 -0
- package/src/tui/run.js +97 -0
- package/src/tui/theme.d.ts +32 -0
- package/src/tui/theme.js +128 -0
- package/src/types.d.ts +124 -0
- package/src/types.js +1 -0
- package/src/update.d.ts +25 -0
- package/src/update.js +110 -0
- package/src/util.d.ts +32 -0
- package/src/util.js +116 -0
- package/src/watching.d.ts +20 -0
- package/src/watching.js +68 -0
- package/src/workspace-view.d.ts +38 -0
- package/src/workspace-view.js +160 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Development model server: an OpenAI-compatible /v1/chat/completions endpoint whose answers come
|
|
2
|
+
// from `claude -p`. It lets pine (and pi) run against a capable model before a local one is set
|
|
3
|
+
// up. Each request starts one `claude` process, so it is slow and uses your Claude plan; do not
|
|
4
|
+
// point a production daemon at it.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
const RULES = `You are standing in for a language model behind an OpenAI-style chat completions API. An agent harness sends you its system prompt, the tools it offers and the conversation so far. Your only job is to write the next assistant message.
|
|
11
|
+
|
|
12
|
+
You have no tools of your own in this role. The tools listed in <tools> belong to the harness: you ask for them in your reply, the harness runs them and sends the conversation back with the results in <tool_result> blocks.
|
|
13
|
+
|
|
14
|
+
Reply with exactly one JSON object and nothing else (no prose before or after, no code fence):
|
|
15
|
+
|
|
16
|
+
{"text": "<what the assistant says, may be empty>", "tool_calls": [{"name": "<tool name>", "arguments": { ... }}]}
|
|
17
|
+
|
|
18
|
+
- "arguments" must match the tool's parameter schema.
|
|
19
|
+
- Every tool in <tools> is available. Use them the way the harness system prompt expects: act, do not ask for permission to use a tool the role is meant to use.
|
|
20
|
+
- Several calls in one reply are fine. Never state the outcome of a call before you have seen its <tool_result>.
|
|
21
|
+
- When the task is done or needs no tool, put the answer in "text" and use "tool_calls": [].
|
|
22
|
+
|
|
23
|
+
Play the role the harness system prompt defines.`;
|
|
24
|
+
/** The model's reply is one JSON object; tolerate a code fence or stray text around it. */
|
|
25
|
+
export function parseReply(raw) {
|
|
26
|
+
const start = raw.indexOf("{");
|
|
27
|
+
const end = raw.lastIndexOf("}");
|
|
28
|
+
if (start >= 0 && end > start) {
|
|
29
|
+
try {
|
|
30
|
+
const o = JSON.parse(raw.slice(start, end + 1));
|
|
31
|
+
if (o && typeof o === "object" && ("text" in o || "tool_calls" in o)) {
|
|
32
|
+
return { text: String(o.text ?? ""), tool_calls: Array.isArray(o.tool_calls) ? o.tool_calls.filter((c) => c && typeof c.name === "string") : [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// fall through: treat the whole reply as text
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { text: raw.trim(), tool_calls: [] };
|
|
40
|
+
}
|
|
41
|
+
const textOf = (content) => typeof content === "string" ? content : Array.isArray(content) ? content.map((p) => (p.type === "text" ? p.text : `[${p.type} omitted]`)).join("") : "";
|
|
42
|
+
export function buildPrompt(body) {
|
|
43
|
+
const messages = body.messages ?? [];
|
|
44
|
+
const system = messages.filter((m) => m.role === "system" || m.role === "developer").map((m) => textOf(m.content)).join("\n\n");
|
|
45
|
+
const tools = (body.tools ?? []).map((t) => ({ name: t.function?.name ?? t.name, description: t.function?.description ?? t.description, parameters: t.function?.parameters ?? t.parameters }));
|
|
46
|
+
const turns = messages
|
|
47
|
+
.filter((m) => m.role !== "system" && m.role !== "developer")
|
|
48
|
+
.map((m) => {
|
|
49
|
+
if (m.role === "tool")
|
|
50
|
+
return `<tool_result id="${m.tool_call_id ?? ""}">\n${textOf(m.content)}\n</tool_result>`;
|
|
51
|
+
if (m.role === "assistant") {
|
|
52
|
+
const calls = (m.tool_calls ?? []).map((c) => `<tool_call id="${c.id}" name="${c.function?.name}">${c.function?.arguments ?? "{}"}</tool_call>`).join("\n");
|
|
53
|
+
return `<assistant>\n${[textOf(m.content), calls].filter(Boolean).join("\n")}\n</assistant>`;
|
|
54
|
+
}
|
|
55
|
+
return `<user>\n${textOf(m.content)}\n</user>`;
|
|
56
|
+
});
|
|
57
|
+
return `<harness_system_prompt>\n${system}\n</harness_system_prompt>\n\n<tools>\n${JSON.stringify(tools, null, 1)}\n</tools>\n\n<conversation>\n${turns.join("\n")}\n</conversation>\n\nWrite the next assistant message now, as the single JSON object described in your instructions.`;
|
|
58
|
+
}
|
|
59
|
+
export async function startClaudeModel(opts = {}) {
|
|
60
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pine-claude-model-"));
|
|
61
|
+
const max = opts.concurrency ?? 3;
|
|
62
|
+
const log = opts.log ?? (() => { });
|
|
63
|
+
let active = 0;
|
|
64
|
+
const queue = [];
|
|
65
|
+
const stats = { requests: 0, failures: 0 };
|
|
66
|
+
const take = () => (active < max ? (active++, Promise.resolve()) : new Promise((r) => queue.push(r)));
|
|
67
|
+
const give = () => {
|
|
68
|
+
const next = queue.shift();
|
|
69
|
+
if (next)
|
|
70
|
+
next();
|
|
71
|
+
else
|
|
72
|
+
active--;
|
|
73
|
+
};
|
|
74
|
+
const ask = (prompt, model, signal) => new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawn(opts.claudePath ?? "claude", ["-p", "--model", model, "--output-format", "json", "--tools", "", "--strict-mcp-config", "--no-session-persistence", "--setting-sources", "", "--disable-slash-commands", "--system-prompt", RULES], { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
76
|
+
let out = "";
|
|
77
|
+
let err = "";
|
|
78
|
+
child.stdout.on("data", (d) => (out += d));
|
|
79
|
+
child.stderr.on("data", (d) => (err += d));
|
|
80
|
+
const onAbort = () => child.kill("SIGKILL");
|
|
81
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
82
|
+
child.on("error", reject);
|
|
83
|
+
child.on("close", (code) => {
|
|
84
|
+
signal.removeEventListener("abort", onAbort);
|
|
85
|
+
if (signal.aborted)
|
|
86
|
+
return reject(new Error("aborted"));
|
|
87
|
+
try {
|
|
88
|
+
const json = JSON.parse(out);
|
|
89
|
+
if (json.is_error)
|
|
90
|
+
throw new Error(String(json.result ?? "claude reported an error"));
|
|
91
|
+
resolve(parseReply(String(json.result ?? "")));
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
reject(new Error(`claude -p failed (exit ${code}): ${e.message} ${err.slice(0, 300)}`));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
child.stdin.end(prompt);
|
|
98
|
+
});
|
|
99
|
+
const server = http.createServer(async (req, res) => {
|
|
100
|
+
if (req.method === "GET") {
|
|
101
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
102
|
+
return res.end(JSON.stringify({ object: "list", data: ["haiku", "sonnet", "opus"].map((id) => ({ id: `claude-${id}`, object: "model" })) }));
|
|
103
|
+
}
|
|
104
|
+
let raw = "";
|
|
105
|
+
for await (const c of req)
|
|
106
|
+
raw += c;
|
|
107
|
+
let body;
|
|
108
|
+
try {
|
|
109
|
+
body = JSON.parse(raw);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
113
|
+
return res.end(JSON.stringify({ error: { message: "invalid JSON" } }));
|
|
114
|
+
}
|
|
115
|
+
const abort = new AbortController();
|
|
116
|
+
res.on("close", () => !res.writableEnded && abort.abort());
|
|
117
|
+
const model = /^(claude-)?(haiku|sonnet|opus|fable)$/.test(String(body.model)) ? String(body.model).replace(/^claude-/, "") : (opts.model ?? "haiku");
|
|
118
|
+
const prompt = buildPrompt(body);
|
|
119
|
+
stats.requests++;
|
|
120
|
+
const t0 = Date.now();
|
|
121
|
+
await take();
|
|
122
|
+
let answer;
|
|
123
|
+
try {
|
|
124
|
+
answer = await ask(prompt, model, abort.signal);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
stats.failures++;
|
|
128
|
+
give();
|
|
129
|
+
if (abort.signal.aborted)
|
|
130
|
+
return;
|
|
131
|
+
log(`request failed: ${e.message}`);
|
|
132
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
133
|
+
return res.end(JSON.stringify({ error: { message: e.message, type: "server_error" } }));
|
|
134
|
+
}
|
|
135
|
+
give();
|
|
136
|
+
const calls = answer.tool_calls
|
|
137
|
+
.map((c, i) => ({ index: i, id: `call_${stats.requests}_${i}`, type: "function", function: { name: c.name, arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}) } }));
|
|
138
|
+
log(`${model} ${Date.now() - t0} ms: ${calls.length ? calls.map((c) => c.function.name).join(", ") : `text (${answer.text.length} chars)`}`);
|
|
139
|
+
const usage = { prompt_tokens: Math.ceil(prompt.length / 4), completion_tokens: Math.ceil((answer.text.length + JSON.stringify(calls).length) / 4), total_tokens: 0 };
|
|
140
|
+
usage.total_tokens = usage.prompt_tokens + usage.completion_tokens;
|
|
141
|
+
const id = `chatcmpl-claude-${stats.requests}`;
|
|
142
|
+
const base = { id, created: Math.floor(Date.now() / 1000), model: body.model };
|
|
143
|
+
const finish = calls.length ? "tool_calls" : "stop";
|
|
144
|
+
if (!body.stream) {
|
|
145
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
146
|
+
return res.end(JSON.stringify({ ...base, object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: answer.text, ...(calls.length ? { tool_calls: calls.map(({ index: _i, ...c }) => c) } : {}) }, finish_reason: finish }], usage }));
|
|
147
|
+
}
|
|
148
|
+
const chunk = (delta, finish_reason = null, extra = {}) => res.write(`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`);
|
|
149
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
150
|
+
chunk({ role: "assistant", content: "" });
|
|
151
|
+
for (let i = 0; i < answer.text.length; i += 80)
|
|
152
|
+
chunk({ content: answer.text.slice(i, i + 80) });
|
|
153
|
+
for (const c of calls)
|
|
154
|
+
chunk({ tool_calls: [c] });
|
|
155
|
+
chunk({}, finish, { usage });
|
|
156
|
+
res.write("data: [DONE]\n\n");
|
|
157
|
+
res.end();
|
|
158
|
+
});
|
|
159
|
+
await new Promise((resolve, reject) => {
|
|
160
|
+
server.once("error", (e) => reject(e.code === "EADDRINUSE" ? new Error(`port ${opts.port} is already in use; pick another with --port and set the same port in models.json`) : e));
|
|
161
|
+
server.listen(opts.port ?? 0, opts.host ?? "127.0.0.1", resolve);
|
|
162
|
+
});
|
|
163
|
+
const port = server.address().port;
|
|
164
|
+
return {
|
|
165
|
+
port,
|
|
166
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
167
|
+
stats,
|
|
168
|
+
close: () => new Promise((r) => {
|
|
169
|
+
server.closeAllConnections?.();
|
|
170
|
+
server.close(() => r());
|
|
171
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Set entries in .env, replacing ones of the same name. The file stays readable by its owner only. */
|
|
2
|
+
export declare function setEnv(home: string, entries: Record<string, string>): void;
|
|
3
|
+
/** Take entries out of .env again. */
|
|
4
|
+
export declare function unsetEnv(home: string, names: string[]): void;
|
package/src/env-file.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseEnv } from "node:util";
|
|
4
|
+
// pine's .env file holds the secrets the configuration refers to as ${NAME}. A secret that reaches
|
|
5
|
+
// pine through a tool call (a broker's password, say) is put here, not into pine.json or an
|
|
6
|
+
// agent.json, so that the configuration files never hold one.
|
|
7
|
+
const fileOf = (home) => path.join(home, ".env");
|
|
8
|
+
/** How a value is written so that it reads back exactly as it is. */
|
|
9
|
+
function quoted(value) {
|
|
10
|
+
if (/[\r\n]/.test(value))
|
|
11
|
+
throw new Error("a secret cannot contain a line break");
|
|
12
|
+
for (const form of [value, `'${value}'`, `"${value}"`, `\`${value}\``])
|
|
13
|
+
if (parseEnv(`X=${form}`).X === value)
|
|
14
|
+
return form;
|
|
15
|
+
throw new Error("this value cannot be stored in the .env file (it mixes every kind of quote)");
|
|
16
|
+
}
|
|
17
|
+
/** Set entries in .env, replacing ones of the same name. The file stays readable by its owner only. */
|
|
18
|
+
export function setEnv(home, entries) {
|
|
19
|
+
const names = Object.keys(entries);
|
|
20
|
+
if (!names.length)
|
|
21
|
+
return;
|
|
22
|
+
for (const name of names)
|
|
23
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name))
|
|
24
|
+
throw new Error(`"${name}" is not a usable name for an .env entry`);
|
|
25
|
+
const lines = Object.entries(entries).map(([name, value]) => `${name}=${quoted(value)}`);
|
|
26
|
+
const kept = fs.existsSync(fileOf(home)) ? fs.readFileSync(fileOf(home), "utf8").split("\n").filter((line) => !names.some((name) => line.startsWith(`${name}=`))) : [];
|
|
27
|
+
while (kept.length && !kept.at(-1).trim())
|
|
28
|
+
kept.pop();
|
|
29
|
+
fs.writeFileSync(fileOf(home), `${[...kept, ...lines].join("\n")}\n`, { mode: 0o600 });
|
|
30
|
+
fs.chmodSync(fileOf(home), 0o600);
|
|
31
|
+
}
|
|
32
|
+
/** Take entries out of .env again. */
|
|
33
|
+
export function unsetEnv(home, names) {
|
|
34
|
+
if (!names.length || !fs.existsSync(fileOf(home)))
|
|
35
|
+
return;
|
|
36
|
+
const lines = fs.readFileSync(fileOf(home), "utf8").split("\n");
|
|
37
|
+
const kept = lines.filter((line) => !names.some((name) => line.startsWith(`${name}=`)));
|
|
38
|
+
if (kept.length !== lines.length)
|
|
39
|
+
fs.writeFileSync(fileOf(home), kept.join("\n"), { mode: 0o600 });
|
|
40
|
+
}
|
package/src/http.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AgentManager } from "./agents.ts";
|
|
2
|
+
import { type PineConfig } from "./config.ts";
|
|
3
|
+
import { type Approvals } from "./security.ts";
|
|
4
|
+
import type { Bus, State, TaskStore } from "./store.ts";
|
|
5
|
+
import type { SourceHost } from "./sources.ts";
|
|
6
|
+
import { type Logger } from "./util.ts";
|
|
7
|
+
/**
|
|
8
|
+
* Control API and webhooks. Every request except /health and /hook/* needs the bearer token.
|
|
9
|
+
* Requests that come from a web page (an Origin header, a foreign Host) are refused, so a site
|
|
10
|
+
* open in a browser on this machine cannot drive the daemon.
|
|
11
|
+
*/
|
|
12
|
+
export declare class HttpServer {
|
|
13
|
+
private cfg;
|
|
14
|
+
private bus;
|
|
15
|
+
private state;
|
|
16
|
+
private tasks;
|
|
17
|
+
private manager;
|
|
18
|
+
private approvals;
|
|
19
|
+
private log;
|
|
20
|
+
private token;
|
|
21
|
+
private server?;
|
|
22
|
+
private hits;
|
|
23
|
+
port: number;
|
|
24
|
+
private sources?;
|
|
25
|
+
constructor(deps: {
|
|
26
|
+
cfg: PineConfig;
|
|
27
|
+
bus: Bus;
|
|
28
|
+
state: State;
|
|
29
|
+
tasks: TaskStore;
|
|
30
|
+
manager: AgentManager;
|
|
31
|
+
approvals: Approvals;
|
|
32
|
+
sources?: SourceHost;
|
|
33
|
+
log: Logger;
|
|
34
|
+
token: string;
|
|
35
|
+
});
|
|
36
|
+
start(): Promise<void>;
|
|
37
|
+
stop(): Promise<void>;
|
|
38
|
+
private handle;
|
|
39
|
+
/** DNS rebinding and cross-site requests: only accept a loopback (or configured) Host, never an Origin. */
|
|
40
|
+
private guardBrowser;
|
|
41
|
+
private raw;
|
|
42
|
+
private json;
|
|
43
|
+
/** A webhook: outside content by definition, so the event is untrusted whatever it says. */
|
|
44
|
+
private hook;
|
|
45
|
+
private stream;
|
|
46
|
+
}
|