@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,269 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { errText, sleep, truncate } from "../util.js";
|
|
6
|
+
/**
|
|
7
|
+
* A Telegram bot: the owner writes to it from wherever they are, and the agent answers there.
|
|
8
|
+
*
|
|
9
|
+
* options: { token, allow?, approvals?, apiBase? }
|
|
10
|
+
* token the bot's token from @BotFather
|
|
11
|
+
* allow Telegram user ids that may talk to it. Without it the bot is paired: it sends a
|
|
12
|
+
* code to the account that writes to it, and the owner types that code into pine
|
|
13
|
+
* approvals also send approval requests to that chat, with buttons (default true)
|
|
14
|
+
* apiBase a Bot API server of your own instead of api.telegram.org
|
|
15
|
+
*
|
|
16
|
+
* event "message": { text, name, username, chatId, messageId, images? }
|
|
17
|
+
* It can send: { text, chatId? } goes to the owner's chat.
|
|
18
|
+
*
|
|
19
|
+
* Messages are fetched by long polling: one request that Telegram answers the moment something
|
|
20
|
+
* arrives. Nothing listens on a port, so it works behind any router. Only accounts on `allow`, or
|
|
21
|
+
* the one that was paired, are heard. What anyone else writes reaches no agent; once an account
|
|
22
|
+
* is paired they get no answer at all, and nothing is ever sent to them.
|
|
23
|
+
*/
|
|
24
|
+
export const type = {
|
|
25
|
+
summary: "a Telegram bot: your owner writes to it from anywhere and you answer there; you can also message them through it. Needs the bot's token from @BotFather",
|
|
26
|
+
talks: true,
|
|
27
|
+
sends: 'a Telegram message to your owner: {"text": "..."}',
|
|
28
|
+
options: Type.Object({
|
|
29
|
+
token: Type.String({ description: "the bot's token, which @BotFather gives when a bot is made with /newbot (123456789:AA...)", secret: true }),
|
|
30
|
+
allow: Type.Optional(Type.Array(Type.Number(), { description: "Telegram user ids that may talk to the bot. Leave out unless your owner gave you their id: the bot is then paired, which means it sends a code to the account that writes to it and your owner types that code into pine" })),
|
|
31
|
+
approvals: Type.Optional(Type.Boolean({ description: "also send approval requests to the owner there, with buttons to answer them. Default true" })),
|
|
32
|
+
apiBase: Type.Optional(Type.String({ description: "address of a Bot API server of your own, instead of https://api.telegram.org" })),
|
|
33
|
+
}, { additionalProperties: false }),
|
|
34
|
+
plan(o) {
|
|
35
|
+
// Without a token yet (the owner is about to be asked for it) there is nothing to check.
|
|
36
|
+
if (o.token !== undefined && !/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(String(o.token)))
|
|
37
|
+
throw new Error("that is not a bot token. @BotFather gives one that looks like 123456789:AAH... when a bot is created with /newbot");
|
|
38
|
+
if (o.apiBase !== undefined) {
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(o.apiBase);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`"${o.apiBase}" is not an address`);
|
|
45
|
+
}
|
|
46
|
+
if (url.protocol !== "https:" && !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
|
47
|
+
throw new Error("a Bot API server of your own must be reached over https, or run on this machine");
|
|
48
|
+
}
|
|
49
|
+
const ids = o.allow ?? [];
|
|
50
|
+
return {
|
|
51
|
+
options: o,
|
|
52
|
+
opens: true,
|
|
53
|
+
owner: true,
|
|
54
|
+
does: [
|
|
55
|
+
"connect to Telegram as your bot and wait for messages",
|
|
56
|
+
ids.length ? `listen only to the Telegram user${ids.length === 1 ? "" : "s"} ${ids.join(", ")}` : "listen to nobody until you have paired your own account: the bot sends a code to the account that writes to it, and you type that code into pine",
|
|
57
|
+
"take what that account writes as coming from you, with everything your agents may do for you",
|
|
58
|
+
"send answers back to that chat, and nowhere else",
|
|
59
|
+
...(o.approvals === false ? [] : ["send approval requests there too, with buttons"]),
|
|
60
|
+
],
|
|
61
|
+
tell: ids.length ? "Your owner can write to the bot now." : "Tell your owner to open the bot in Telegram and write anything to it. The bot answers with a six-digit code, and pine asks for that code here. Once they have typed it, that account is the only one the bot listens to.",
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const MAX_PHOTO = 10 * 1024 * 1024;
|
|
66
|
+
export default async function telegram(source) {
|
|
67
|
+
const o = source.options;
|
|
68
|
+
const token = String(o.token ?? "");
|
|
69
|
+
if (!token)
|
|
70
|
+
throw new Error("the bot token is missing");
|
|
71
|
+
source.secret(token);
|
|
72
|
+
const base = String(o.apiBase ?? "https://api.telegram.org").replace(/\/+$/, "");
|
|
73
|
+
const stop = new AbortController();
|
|
74
|
+
source.onStop(() => stop.abort());
|
|
75
|
+
const api = async (method, body = {}, timeoutMs = 20_000) => {
|
|
76
|
+
const res = await fetch(`${base}/bot${token}/${method}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.any([stop.signal, AbortSignal.timeout(timeoutMs)]) });
|
|
77
|
+
const json = await res.json().catch(() => ({}));
|
|
78
|
+
if (!json.ok)
|
|
79
|
+
throw new Error(`Telegram refused ${method}: ${json.description ?? res.status}`);
|
|
80
|
+
return json.result;
|
|
81
|
+
};
|
|
82
|
+
// A wrong token is found out here, and shows as the reason the source is not running.
|
|
83
|
+
const me = await api("getMe");
|
|
84
|
+
const mediaDir = path.join(source.dataDir, "media");
|
|
85
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
86
|
+
source.media(mediaDir);
|
|
87
|
+
// ---- who is heard ------------------------------------------------------------------------------
|
|
88
|
+
const fixed = (Array.isArray(o.allow) ? o.allow : []).map(Number).filter(Number.isFinite);
|
|
89
|
+
const paired = () => source.kv.get("owner");
|
|
90
|
+
const heard = (userId) => fixed.includes(userId) || paired()?.id === userId;
|
|
91
|
+
/** The chat everything unasked goes to. A private chat has the id of its user. */
|
|
92
|
+
const ownerChat = () => fixed[0] ?? paired()?.id;
|
|
93
|
+
/** Who may talk to the bot, said where the sources are listed. */
|
|
94
|
+
const say = () => source.status(fixed.length
|
|
95
|
+
? `@${me.username} listens only to the Telegram user${fixed.length === 1 ? "" : "s"} ${fixed.join(", ")}`
|
|
96
|
+
: paired()
|
|
97
|
+
? `@${me.username} listens only to ${paired().name || "the paired account"} (Telegram id ${paired().id}), which you confirmed`
|
|
98
|
+
: `@${me.username} listens to nobody yet: write to it from your Telegram account, then type the code it sends you into pine`);
|
|
99
|
+
say();
|
|
100
|
+
const send = async (chatId, text, extra = {}) => {
|
|
101
|
+
if (!heard(chatId))
|
|
102
|
+
throw new Error("that chat is not your owner's");
|
|
103
|
+
const body = text.trim() || "(empty message)";
|
|
104
|
+
let last;
|
|
105
|
+
for (let i = 0; i < body.length; i += 4000)
|
|
106
|
+
last = await api("sendMessage", { chat_id: chatId, text: body.slice(i, i + 4000), ...(i + 4000 >= body.length ? extra : {}) });
|
|
107
|
+
return last;
|
|
108
|
+
};
|
|
109
|
+
// ---- out: answers, and what an agent sends on its own -------------------------------------------
|
|
110
|
+
source.onSend(async (message) => {
|
|
111
|
+
const chatId = Number(message.chatId ?? ownerChat());
|
|
112
|
+
if (!Number.isFinite(chatId))
|
|
113
|
+
throw new Error("nobody is connected yet: your owner has to write to the bot once, and confirm it in pine");
|
|
114
|
+
await send(chatId, String(message.text ?? ""));
|
|
115
|
+
return "sent to your owner on Telegram";
|
|
116
|
+
});
|
|
117
|
+
// "typing…" while an agent works on something that will be answered here.
|
|
118
|
+
const typing = new Map();
|
|
119
|
+
source.on("agent/+/started", (e) => {
|
|
120
|
+
const p = e.payload;
|
|
121
|
+
const chatId = Number(p?.reply?.payload?.chatId);
|
|
122
|
+
if (p?.reply?.topic !== `out/source/${source.key}` || !heard(chatId))
|
|
123
|
+
return;
|
|
124
|
+
const tick = () => void api("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => { });
|
|
125
|
+
tick();
|
|
126
|
+
const h = setInterval(tick, 4500);
|
|
127
|
+
h.unref?.();
|
|
128
|
+
typing.set(p.task, h);
|
|
129
|
+
});
|
|
130
|
+
source.on(["agent/+/done", "agent/+/failed"], (e) => {
|
|
131
|
+
const id = e.payload?.task;
|
|
132
|
+
clearInterval(typing.get(id));
|
|
133
|
+
typing.delete(id);
|
|
134
|
+
});
|
|
135
|
+
source.onStop(() => typing.forEach((h) => clearInterval(h)));
|
|
136
|
+
// ---- approval requests, to answer from the phone ---------------------------------------------------
|
|
137
|
+
if (o.approvals !== false) {
|
|
138
|
+
const asked = new Map();
|
|
139
|
+
source.on("approval/requested", async (e) => {
|
|
140
|
+
const r = e.payload;
|
|
141
|
+
const chat = ownerChat();
|
|
142
|
+
// A question the owner answers by typing a secret belongs on their own machine, not in a chat.
|
|
143
|
+
if (chat === undefined || r.inputs?.length)
|
|
144
|
+
return;
|
|
145
|
+
const always = r.remember ? [{ text: `Always: ${truncate(String(r.remember), 30)}`, callback_data: `ap:${r.id}:2` }] : [];
|
|
146
|
+
const msg = await send(chat, `Approval needed\nAgent: ${r.agent}${r.task ? ` (task #${r.task})` : ""}\nAction: ${truncate(String(r.summary), 1200)}\nWhy you are asked: ${r.why}`, { reply_markup: { inline_keyboard: [[{ text: "Allow", callback_data: `ap:${r.id}:1` }, ...always, { text: "Deny", callback_data: `ap:${r.id}:0` }]] } });
|
|
147
|
+
if (msg?.message_id)
|
|
148
|
+
asked.set(r.id, msg.message_id);
|
|
149
|
+
});
|
|
150
|
+
source.on("approval/decided", async (e) => {
|
|
151
|
+
const d = e.payload;
|
|
152
|
+
const messageId = asked.get(d.id);
|
|
153
|
+
asked.delete(d.id);
|
|
154
|
+
if (messageId)
|
|
155
|
+
await api("editMessageReplyMarkup", { chat_id: ownerChat(), message_id: messageId, reply_markup: { inline_keyboard: [[{ text: d.allow ? `Allowed (${d.by})` : `Denied (${d.by})`, callback_data: "noop" }]] } }).catch(() => { });
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// ---- in ----------------------------------------------------------------------------------------
|
|
159
|
+
const photo = async (sizes) => {
|
|
160
|
+
const best = sizes.filter((s) => (s.file_size ?? 0) <= MAX_PHOTO).at(-1);
|
|
161
|
+
if (!best)
|
|
162
|
+
return undefined;
|
|
163
|
+
const file = await api("getFile", { file_id: best.file_id });
|
|
164
|
+
const res = await fetch(`${base}/file/bot${token}/${file.file_path}`, { signal: AbortSignal.timeout(30_000) });
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
return undefined;
|
|
167
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
168
|
+
if (buf.length > MAX_PHOTO)
|
|
169
|
+
return undefined;
|
|
170
|
+
const target = path.join(mediaDir, `${String(best.file_unique_id).replace(/[^A-Za-z0-9_-]/g, "")}.jpg`);
|
|
171
|
+
fs.writeFileSync(target, buf, { mode: 0o600 });
|
|
172
|
+
return { path: target, mimeType: "image/jpeg" };
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* An account nobody knows yet writes to a bot that has no owner. Whoever sits at pine has to
|
|
176
|
+
* show that this account is theirs: the bot sends the account a code, and pine asks for it.
|
|
177
|
+
* A press of a key cannot say yes to a stranger, and a stranger does not know what to type.
|
|
178
|
+
* One question at a time; an account the owner turned down is not asked about again.
|
|
179
|
+
*/
|
|
180
|
+
let pairing = false;
|
|
181
|
+
const pair = async (m, userId, name) => {
|
|
182
|
+
const denied = source.kv.get("denied") ?? [];
|
|
183
|
+
if (fixed.length || paired() || pairing || denied.includes(userId))
|
|
184
|
+
return false;
|
|
185
|
+
pairing = true;
|
|
186
|
+
try {
|
|
187
|
+
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
|
|
188
|
+
const shown = `${code.slice(0, 3)} ${code.slice(3)}`;
|
|
189
|
+
await api("sendMessage", { chat_id: userId, text: `To connect this Telegram account to pine, type this code into the question pine is showing on your computer:\n\n${shown}\n\nIf you did not expect this message, ignore it.` });
|
|
190
|
+
const who = `${name || "somebody"} (${m.from?.username ? `@${m.from.username}, ` : ""}Telegram id ${userId})`;
|
|
191
|
+
const answer = await source.approvals.request(`let the Telegram account of ${who} talk to your agents as you through the bot @${me.username}\nIt just wrote: "${truncate(String(m.text ?? m.caption ?? "(a picture)"), 200)}"\nThe bot sent that account a code. If the account is yours, the code is on your phone now.`, "what this account writes will count as your own words, with everything your agents may do for you. Only the code proves that the account is yours; deny it if you did not just write to the bot", [{ name: "code", label: "code: the six digits the bot just sent to that account in Telegram", secret: false }]);
|
|
192
|
+
if (!answer.allow) {
|
|
193
|
+
// Turned down by the owner (not merely unanswered): that account is not put to them again.
|
|
194
|
+
if (answer.by !== "timeout" && answer.by !== "aborted")
|
|
195
|
+
source.kv.set("denied", [...denied, userId].slice(-200));
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (String(answer.values?.code ?? "").replace(/\D/g, "") !== code) {
|
|
199
|
+
await api("sendMessage", { chat_id: userId, text: "That was not the code. Write again to get a new one." }).catch(() => { });
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (!paired())
|
|
203
|
+
source.kv.set("owner", { id: userId, name: `${name}${m.from?.username ? ` (@${m.from.username})` : ""}` });
|
|
204
|
+
say();
|
|
205
|
+
return paired()?.id === userId;
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
pairing = false;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const onMessage = async (m) => {
|
|
212
|
+
const userId = Number(m.from?.id);
|
|
213
|
+
const chatId = Number(m.chat?.id);
|
|
214
|
+
const name = [m.from?.first_name, m.from?.last_name].filter(Boolean).join(" ");
|
|
215
|
+
if (m.chat?.type !== "private")
|
|
216
|
+
return;
|
|
217
|
+
// Unknown senders get no answer at all: nothing to probe, nothing to flood the log with.
|
|
218
|
+
if (!heard(userId) && !(await pair(m, userId, name)))
|
|
219
|
+
return source.log.debug(`ignored a message from user ${userId}`);
|
|
220
|
+
const text = String(m.text || m.caption || "").trim();
|
|
221
|
+
const images = m.photo ? [await photo(m.photo).catch(() => undefined)].filter(Boolean) : [];
|
|
222
|
+
if (!text && !images.length)
|
|
223
|
+
return;
|
|
224
|
+
const verdict = text.match(/^\/(allow|deny)(?:\s+(\w+))?/);
|
|
225
|
+
if (verdict && o.approvals !== false) {
|
|
226
|
+
const pending = source.approvals.pending();
|
|
227
|
+
const id = verdict[2] ?? (pending.length === 1 ? pending[0].id : undefined);
|
|
228
|
+
const ok = id ? source.approvals.decide(id, verdict[1] === "allow", `telegram:${userId}`) : false;
|
|
229
|
+
return void send(chatId, ok ? "Noted." : "No such open request.").catch(() => { });
|
|
230
|
+
}
|
|
231
|
+
source.emit("message", { text: text || "(a picture)", name, username: m.from?.username, chatId, messageId: m.message_id, images: images.length ? images : undefined }, { reply: { chatId } });
|
|
232
|
+
};
|
|
233
|
+
const onCallback = async (c) => {
|
|
234
|
+
const userId = Number(c.from?.id);
|
|
235
|
+
const m = String(c.data ?? "").match(/^ap:(\w+):([012])$/);
|
|
236
|
+
if (!heard(userId) || !m)
|
|
237
|
+
return void api("answerCallbackQuery", { callback_query_id: c.id }).catch(() => { });
|
|
238
|
+
const ok = source.approvals.decide(m[1], m[2] !== "0", `telegram:${userId}`, m[2] === "2");
|
|
239
|
+
await api("answerCallbackQuery", { callback_query_id: c.id, text: ok ? (m[2] === "0" ? "Denied" : m[2] === "2" ? "Allowed for this conversation" : "Allowed") : "Already answered or expired" }).catch(() => { });
|
|
240
|
+
};
|
|
241
|
+
const poll = async () => {
|
|
242
|
+
const pollSec = 25;
|
|
243
|
+
let offset = source.kv.get("offset") ?? 0;
|
|
244
|
+
let backoff = 1000;
|
|
245
|
+
while (!stop.signal.aborted) {
|
|
246
|
+
try {
|
|
247
|
+
const updates = await api("getUpdates", { offset, timeout: pollSec, allowed_updates: ["message", "callback_query"] }, (pollSec + 10) * 1000);
|
|
248
|
+
backoff = 1000;
|
|
249
|
+
for (const u of updates) {
|
|
250
|
+
offset = u.update_id + 1;
|
|
251
|
+
source.kv.set("offset", offset);
|
|
252
|
+
// Not awaited: a pairing question stays open until the owner answers, and messages keep coming meanwhile.
|
|
253
|
+
if (u.message)
|
|
254
|
+
void onMessage(u.message).catch((e) => source.log.warn("a message could not be handled", errText(e)));
|
|
255
|
+
else if (u.callback_query)
|
|
256
|
+
void onCallback(u.callback_query).catch((e) => source.log.warn("a button press could not be handled", errText(e)));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
if (stop.signal.aborted)
|
|
261
|
+
break;
|
|
262
|
+
source.log.warn(`asking Telegram for messages failed, trying again in ${backoff} ms`, errText(e));
|
|
263
|
+
await sleep(backoff);
|
|
264
|
+
backoff = Math.min(backoff * 2, 60_000);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
void poll();
|
|
269
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { timingSafeEqual } from "../security.js";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { refuse } from "../sources.js";
|
|
5
|
+
/**
|
|
6
|
+
* A web address other services can call: POST http://<pine's control address>/hook/<name>.
|
|
7
|
+
* For GitHub, Home Assistant, a doorbell, a shortcut on your phone, anything that can send a request.
|
|
8
|
+
*
|
|
9
|
+
* options: { secret, open? }
|
|
10
|
+
* secret the caller proves itself with it, either as "Authorization: Bearer <secret>" or by
|
|
11
|
+
* signing the body (HMAC SHA-256 in X-Hub-Signature-256 or X-Signature, as GitHub does)
|
|
12
|
+
* open true to accept anyone who can reach the port; only for a port nobody else can reach
|
|
13
|
+
*
|
|
14
|
+
* event "received": { body, headers } body is parsed JSON, or { text } for anything else
|
|
15
|
+
*/
|
|
16
|
+
const KEPT = ["content-type", "user-agent", "x-github-event", "x-github-delivery", "x-event-type", "x-event-key", "x-request-id"];
|
|
17
|
+
export const type = {
|
|
18
|
+
summary: "a web address other services can call (POST /hook/<name> on pine's control port; /hook/<name>@<agent> for an agent's own). For GitHub, Home Assistant, a doorbell, a shortcut on a phone",
|
|
19
|
+
options: Type.Object({
|
|
20
|
+
secret: Type.Optional(Type.String({ description: "what the caller proves itself with. Leave it out and pine makes one", secret: true })),
|
|
21
|
+
open: Type.Optional(Type.Boolean({ description: "true to accept anyone who can reach the port, without a secret. Only for a port nobody else can reach" })),
|
|
22
|
+
}, { additionalProperties: false }),
|
|
23
|
+
plan(o, setup) {
|
|
24
|
+
if (o.open === true)
|
|
25
|
+
return { options: { open: true }, does: [`accept web requests at /hook/${setup.key} on pine's control port from ANYONE who can reach it`], opens: true, tell: `Callers use POST /hook/${setup.key} on pine's control address.` };
|
|
26
|
+
const made = !o.secret;
|
|
27
|
+
return { options: { secret: o.secret ?? setup.random(24) }, does: [`accept web requests at /hook/${setup.key} on pine's control port from callers that present a secret${made ? " (made now)" : ""}`], opens: true, tell: `Callers use POST /hook/${setup.key} on pine's control address and present the secret, which your owner finds in pine's .env file as ${setup.envName("secret")}.` };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export default function webhook(source) {
|
|
31
|
+
const secret = typeof source.options.secret === "string" ? source.options.secret : "";
|
|
32
|
+
if (!secret && source.options.open !== true)
|
|
33
|
+
throw new Error(`needs a "secret" the caller has to present (or "open": true to accept anyone who can reach the port)`);
|
|
34
|
+
source.secret(secret || undefined);
|
|
35
|
+
source.http(({ headers, body }) => {
|
|
36
|
+
if (secret) {
|
|
37
|
+
const bearer = String(headers.authorization ?? "").replace(/^Bearer /, "");
|
|
38
|
+
const sig = String(headers["x-hub-signature-256"] ?? headers["x-signature"] ?? "").replace(/^sha256=/, "");
|
|
39
|
+
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
40
|
+
if (!(bearer && timingSafeEqual(bearer, secret)) && !(sig && timingSafeEqual(sig, expected)))
|
|
41
|
+
throw refuse(401, "bad signature");
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
parsed = { text: body.toString("utf8") };
|
|
49
|
+
}
|
|
50
|
+
source.emit("received", { body: parsed, headers: Object.fromEntries(KEPT.filter((k) => headers[k] !== undefined).map((k) => [k, String(headers[k])])) });
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/sources.d.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { type PineConfig, type SourceConfig, type SubscriptionConfig } from "./config.ts";
|
|
2
|
+
import type { SourcePlan } from "./source-types.ts";
|
|
3
|
+
import type { Router } from "./router.ts";
|
|
4
|
+
import { type Approvals, type Redactor } from "./security.ts";
|
|
5
|
+
import type { Bus, Kv } from "./store.ts";
|
|
6
|
+
import type { PineEvent } from "./types.ts";
|
|
7
|
+
import { type Logger } from "./util.ts";
|
|
8
|
+
/**
|
|
9
|
+
* Event sources and the subscriptions to them.
|
|
10
|
+
*
|
|
11
|
+
* A source is to events what an MCP server is to tools: a named thing in the configuration, global
|
|
12
|
+
* (pine.json) or one agent's own (agent.json), that can report what happens somewhere else: a file
|
|
13
|
+
* arrives, a device says something, a web service calls, a clock strikes. Being configured does
|
|
14
|
+
* nothing. A source starts when the first conversation subscribes to it. A subscription belongs to
|
|
15
|
+
* one conversation of one agent: each event arrives there as a message and wakes the agent into a
|
|
16
|
+
* turn, and what it does with it is in its instructions and in the note it left when it subscribed.
|
|
17
|
+
* Another conversation of the same agent is not subscribed until it subscribes itself. When the
|
|
18
|
+
* last subscriber leaves, the source stops.
|
|
19
|
+
*
|
|
20
|
+
* Events are published as source/<name>/<event> (source/<name>@<agent>/<event> for an agent's own
|
|
21
|
+
* source), so the event log, routes and the UI see them like any other event.
|
|
22
|
+
*/
|
|
23
|
+
/** What a source module gets. A source is a module whose default export is `(source) => void | Promise<void>`. */
|
|
24
|
+
export interface SourceApi {
|
|
25
|
+
/** The name it has in the configuration. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** What it is called in events and addresses: its name, or name@agent for an agent's own source. */
|
|
28
|
+
key: string;
|
|
29
|
+
options: Record<string, any>;
|
|
30
|
+
/** Folder that relative paths in the options are meant from. */
|
|
31
|
+
base: string;
|
|
32
|
+
/** A folder of its own for whatever it has to keep (received files, for example). */
|
|
33
|
+
dataDir: string;
|
|
34
|
+
log: Logger;
|
|
35
|
+
/**
|
|
36
|
+
* Report that something happened. `event` is a short word such as "added" or "message".
|
|
37
|
+
* With `reply`, the answer of an agent that is woken by this event comes back to `onSend`, with
|
|
38
|
+
* the fields given here (a chat to answer in, for example) next to its `text`.
|
|
39
|
+
*/
|
|
40
|
+
emit(event: string, payload?: Record<string, unknown>, opts?: {
|
|
41
|
+
persist?: boolean;
|
|
42
|
+
reply?: Record<string, unknown>;
|
|
43
|
+
}): PineEvent;
|
|
44
|
+
/**
|
|
45
|
+
* For a source that can also send: called with an agent's answer to one of its events, and with
|
|
46
|
+
* what an agent sends through it on its own. Return a short line on what was done; throw to
|
|
47
|
+
* refuse. The source decides where things may go: it never sends anywhere it was not set up for.
|
|
48
|
+
*/
|
|
49
|
+
onSend(handler: (message: Record<string, any>) => void | string | Promise<void | string>): void;
|
|
50
|
+
/**
|
|
51
|
+
* Say in one line how things stand, for whoever lists the sources: who it is connected to,
|
|
52
|
+
* what it is waiting for. Call it again when that changes.
|
|
53
|
+
*/
|
|
54
|
+
status(line: string): void;
|
|
55
|
+
/** Look at events of the system (read only): "agent/+/started", "approval/requested", ... */
|
|
56
|
+
on(pattern: string | string[], handler: (e: PineEvent) => void | Promise<void>): void;
|
|
57
|
+
/** Approval requests: put a question of its own to the owner, see the open ones, pass on the owner's answer. */
|
|
58
|
+
approvals: {
|
|
59
|
+
/** Ask the owner in pine's UI. Resolves with their answer; no answer in time counts as no. */
|
|
60
|
+
ask(summary: string, why: string): Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* The same, with fields the owner has to fill in (`secret: false` for one that may be shown
|
|
63
|
+
* while it is typed). `by` says who answered: "timeout" when nobody did.
|
|
64
|
+
*/
|
|
65
|
+
request(summary: string, why: string, inputs: Array<{
|
|
66
|
+
name: string;
|
|
67
|
+
label: string;
|
|
68
|
+
secret?: boolean;
|
|
69
|
+
}>): Promise<{
|
|
70
|
+
allow: boolean;
|
|
71
|
+
by: string;
|
|
72
|
+
values?: Record<string, string>;
|
|
73
|
+
}>;
|
|
74
|
+
pending(): Array<{
|
|
75
|
+
id: string;
|
|
76
|
+
agent: string;
|
|
77
|
+
tool: string;
|
|
78
|
+
summary: string;
|
|
79
|
+
why: string;
|
|
80
|
+
remember?: string;
|
|
81
|
+
task: number;
|
|
82
|
+
}>;
|
|
83
|
+
decide(id: string, allow: boolean, by: string, always?: boolean): boolean;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Look at something again and again, for what cannot report by itself: a web address, a
|
|
87
|
+
* command, a mailbox. `look` runs once right away and then every `every` ("30s", "5m"), never
|
|
88
|
+
* twice at the same time, a little earlier or later each round so that sources do not all fire
|
|
89
|
+
* together. A look that throws is a failed look: the pause grows with each one in a row, and
|
|
90
|
+
* whoever lists the sources sees since when it fails and why. One poll per source; it stops
|
|
91
|
+
* with the source, and `signal` is aborted then.
|
|
92
|
+
*
|
|
93
|
+
* A look that finds nothing new does nothing at all: no event, nothing stored, nobody woken.
|
|
94
|
+
*/
|
|
95
|
+
poll(every: string | number, look: (signal: AbortSignal) => void | Promise<void>): void;
|
|
96
|
+
/**
|
|
97
|
+
* Compare a value with what was there at the last look, and remember it. Returns what it was
|
|
98
|
+
* before when it differs. The first look returns nothing: it is the starting point, not a
|
|
99
|
+
* change. `slot` keeps several values apart.
|
|
100
|
+
*/
|
|
101
|
+
changed<T = unknown>(value: T, slot?: string): {
|
|
102
|
+
previous: T;
|
|
103
|
+
} | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Run a shell command with the rights of the agent this source belongs to (the main agent for
|
|
106
|
+
* a source of all agents): in that agent's sandbox, from its workspace, without its owner's
|
|
107
|
+
* environment. It can do nothing that agent's own shell could not. `output` is what it printed,
|
|
108
|
+
* errors included. Throws when it cannot be started or takes longer than `timeoutMs` (30 s).
|
|
109
|
+
*/
|
|
110
|
+
exec(command: string, opts?: {
|
|
111
|
+
timeoutMs?: number;
|
|
112
|
+
maxBytes?: number;
|
|
113
|
+
signal?: AbortSignal;
|
|
114
|
+
}): Promise<{
|
|
115
|
+
code: number;
|
|
116
|
+
output: string;
|
|
117
|
+
truncated: boolean;
|
|
118
|
+
}>;
|
|
119
|
+
/** Private storage that survives restarts. */
|
|
120
|
+
kv: {
|
|
121
|
+
get<T = unknown>(key: string): T | undefined;
|
|
122
|
+
set(key: string, value: unknown): void;
|
|
123
|
+
};
|
|
124
|
+
/** Keep this value out of events and messages. */
|
|
125
|
+
secret(value: string | undefined): void;
|
|
126
|
+
/** Image files in this folder may be shown to a model when an event names them in `images`. */
|
|
127
|
+
media(dir: string): void;
|
|
128
|
+
/** Receive POST /hook/<name> on the control API's port. Throw `refuse(status, message)` to turn a request away. */
|
|
129
|
+
http(handler: (req: {
|
|
130
|
+
headers: Record<string, string | string[] | undefined>;
|
|
131
|
+
body: Buffer;
|
|
132
|
+
}) => void | Promise<void>): void;
|
|
133
|
+
onStop(fn: () => void | Promise<void>): void;
|
|
134
|
+
}
|
|
135
|
+
export type PineSource = (source: SourceApi) => void | Promise<void>;
|
|
136
|
+
/** An error a webhook handler throws to answer with that HTTP status. */
|
|
137
|
+
export declare function refuse(status: number, message: string): Error;
|
|
138
|
+
export interface SourceInfo {
|
|
139
|
+
name: string;
|
|
140
|
+
/** "global", or the agent it belongs to. */
|
|
141
|
+
scope: string;
|
|
142
|
+
type: string;
|
|
143
|
+
description?: string;
|
|
144
|
+
trust: string;
|
|
145
|
+
running: boolean;
|
|
146
|
+
error?: string;
|
|
147
|
+
/** What the source says about itself while it runs: who it is connected to, what it waits for. */
|
|
148
|
+
status?: string;
|
|
149
|
+
/** Who listens: "agent" for its main conversation, "agent/conversation" for another. */
|
|
150
|
+
subscribers: string[];
|
|
151
|
+
/** Topic prefix of its events. */
|
|
152
|
+
topic: string;
|
|
153
|
+
last?: {
|
|
154
|
+
ts: number;
|
|
155
|
+
topic: string;
|
|
156
|
+
};
|
|
157
|
+
events: number;
|
|
158
|
+
}
|
|
159
|
+
export declare class SourceHost {
|
|
160
|
+
private cfg;
|
|
161
|
+
private bus;
|
|
162
|
+
private kv;
|
|
163
|
+
private redactor;
|
|
164
|
+
private router;
|
|
165
|
+
private log;
|
|
166
|
+
private httpEnabled;
|
|
167
|
+
private running;
|
|
168
|
+
private starting;
|
|
169
|
+
private errors;
|
|
170
|
+
private statuses;
|
|
171
|
+
private polls;
|
|
172
|
+
private seen;
|
|
173
|
+
private off?;
|
|
174
|
+
private offOut?;
|
|
175
|
+
private approvals?;
|
|
176
|
+
private globalRoutes;
|
|
177
|
+
mediaRoots: string[];
|
|
178
|
+
/** Every source type there is: pine's own and the ones pine.json registers. `type` is what the module says about itself. */
|
|
179
|
+
private types;
|
|
180
|
+
constructor(deps: {
|
|
181
|
+
cfg: PineConfig;
|
|
182
|
+
bus: Bus;
|
|
183
|
+
kv: Kv;
|
|
184
|
+
redactor: Redactor;
|
|
185
|
+
router: Router;
|
|
186
|
+
log: Logger;
|
|
187
|
+
http: boolean;
|
|
188
|
+
approvals?: Approvals;
|
|
189
|
+
});
|
|
190
|
+
/** Every configured source with its key: global ones by name, an agent's own as name@agent. */
|
|
191
|
+
private all;
|
|
192
|
+
private subscribersOf;
|
|
193
|
+
/** The agents a subscription for everyone reaches: those that can use the source and have no entry of their own for their main conversation. */
|
|
194
|
+
private reached;
|
|
195
|
+
/** (Re)build the routes of the subscriptions for everyone. Called whenever any subscription changes, since an agent's own entry takes the place of the general one. */
|
|
196
|
+
private wireGlobal;
|
|
197
|
+
/** Read what each source type says about itself. A type whose module does not load is left out, with a line in the log. */
|
|
198
|
+
private loadTypes;
|
|
199
|
+
/** The source types an agent can set up by tool call: name, what it is for, and the schema of its options. */
|
|
200
|
+
typeList(): Array<{
|
|
201
|
+
name: string;
|
|
202
|
+
summary: string;
|
|
203
|
+
options: any;
|
|
204
|
+
}>;
|
|
205
|
+
/** Where the source types that were registered while pine ran are kept: in pine's home, which no agent can write. */
|
|
206
|
+
private typeDir;
|
|
207
|
+
/** The registered source types that are not pine's own, with what uses each. */
|
|
208
|
+
ownTypes(): Array<{
|
|
209
|
+
name: string;
|
|
210
|
+
file: string;
|
|
211
|
+
summary?: string;
|
|
212
|
+
usedBy: string[];
|
|
213
|
+
}>;
|
|
214
|
+
/**
|
|
215
|
+
* Register a new source type from the text of its module. The text is copied into pine's home
|
|
216
|
+
* first, and it is that copy that is loaded and runs from then on: what an agent wrote and the
|
|
217
|
+
* owner confirmed cannot be changed afterwards. A module that does not load or does not describe
|
|
218
|
+
* itself is not registered, and nothing of it stays.
|
|
219
|
+
*
|
|
220
|
+
* This runs the module inside the daemon. Whoever calls it has the owner's yes for exactly this text.
|
|
221
|
+
*/
|
|
222
|
+
addType(name: string, code: string): Promise<{
|
|
223
|
+
name: string;
|
|
224
|
+
summary: string;
|
|
225
|
+
options: string;
|
|
226
|
+
file: string;
|
|
227
|
+
}>;
|
|
228
|
+
/** Take a registered source type out again. Not while a source of that type exists. */
|
|
229
|
+
removeType(name: string): void;
|
|
230
|
+
/** The name a secret option of a source gets in .env. */
|
|
231
|
+
private envName;
|
|
232
|
+
/**
|
|
233
|
+
* Work out what setting up a source of a type with these options would mean. The options are
|
|
234
|
+
* checked against the schema the type exposes, then the type itself says what it would do.
|
|
235
|
+
* Nothing is written.
|
|
236
|
+
*/
|
|
237
|
+
propose(agentId: string, name: string, typeName: string, raw: unknown, forAll?: boolean, ask?: string[]): SourcePlan & {
|
|
238
|
+
secrets: string[];
|
|
239
|
+
needs: Array<{
|
|
240
|
+
name: string;
|
|
241
|
+
label: string;
|
|
242
|
+
}>;
|
|
243
|
+
};
|
|
244
|
+
start(): Promise<void>;
|
|
245
|
+
stop(): Promise<void>;
|
|
246
|
+
private routeName;
|
|
247
|
+
/** What a subscription amounts to: this source's events become turns of that agent in that conversation. */
|
|
248
|
+
private route;
|
|
249
|
+
private ensure;
|
|
250
|
+
private launch;
|
|
251
|
+
/** A source's shell command, run the way the shell of the agent it belongs to would run it. */
|
|
252
|
+
private exec;
|
|
253
|
+
private halt;
|
|
254
|
+
/**
|
|
255
|
+
* The sources an agent can send through, with what sending does. A global one or its own;
|
|
256
|
+
* one that was set up for a single conversation only from there.
|
|
257
|
+
*/
|
|
258
|
+
senders(agentId: string, session?: string): Array<{
|
|
259
|
+
name: string;
|
|
260
|
+
type: string;
|
|
261
|
+
sends: string;
|
|
262
|
+
}>;
|
|
263
|
+
/** Send something out through a source, started if it is not running. Throws with the source's own reason if it refuses. */
|
|
264
|
+
send(agentId: string, name: string, message: Record<string, any>, session?: string): Promise<string>;
|
|
265
|
+
/** The handler for POST /hook/<key>, if a running source takes web requests under that name. */
|
|
266
|
+
hookFor(key: string): ((req: {
|
|
267
|
+
headers: Record<string, string | string[] | undefined>;
|
|
268
|
+
body: Buffer;
|
|
269
|
+
}) => void | Promise<void>) | undefined;
|
|
270
|
+
/** What a running source says about itself; for one that polls, with when it last looked or since when that fails. */
|
|
271
|
+
private statusOf;
|
|
272
|
+
list(): SourceInfo[];
|
|
273
|
+
/**
|
|
274
|
+
* The sources an agent can subscribe to, as seen from one of its conversations, with everything
|
|
275
|
+
* that would wake it: `local` this conversation, `agent` its main conversation, `global` a
|
|
276
|
+
* subscription for all agents, `elsewhere` its other conversations.
|
|
277
|
+
*/
|
|
278
|
+
available(agentId: string, session?: string): Array<{
|
|
279
|
+
name: string;
|
|
280
|
+
type: string;
|
|
281
|
+
description?: string;
|
|
282
|
+
local?: SubscriptionConfig;
|
|
283
|
+
agent?: SubscriptionConfig;
|
|
284
|
+
global?: SubscriptionConfig;
|
|
285
|
+
elsewhere: string[];
|
|
286
|
+
}>;
|
|
287
|
+
/**
|
|
288
|
+
* Add a source to the configuration while the daemon runs: to one agent's agent.json, or to
|
|
289
|
+
* pine.json for everyone. `entry` is what goes into the file. The file is written first and then
|
|
290
|
+
* read back through the normal loader, so a new source is checked exactly like one that was
|
|
291
|
+
* there at start; if it does not pass, the file is put back as it was.
|
|
292
|
+
* `read` also lets that agent read the folder (for a folder source outside its workspace).
|
|
293
|
+
*/
|
|
294
|
+
add(agentId: string | undefined, name: string, entry: Record<string, unknown>, read?: string, secrets?: string[]): SourceConfig;
|
|
295
|
+
/**
|
|
296
|
+
* Take a source out of the configuration while the daemon runs: it stops, whoever was subscribed
|
|
297
|
+
* to it is unsubscribed, and its entry is deleted. For a watched folder outside the agent's
|
|
298
|
+
* workspace, the read access that came with it goes too, unless another source still needs it.
|
|
299
|
+
*/
|
|
300
|
+
remove(agentId: string | undefined, name: string): Promise<{
|
|
301
|
+
removed: boolean;
|
|
302
|
+
unsubscribed: string[];
|
|
303
|
+
}>;
|
|
304
|
+
/** The sources that were set up for one conversation of an agent. */
|
|
305
|
+
boundTo(agentId: string, session: string): string[];
|
|
306
|
+
/** Agents were added or removed: who a subscription for everyone reaches has changed. */
|
|
307
|
+
agentsChanged(): void;
|
|
308
|
+
/** The keys of a subscription that are written to a file. */
|
|
309
|
+
private clean;
|
|
310
|
+
private same;
|
|
311
|
+
/**
|
|
312
|
+
* Subscribe one conversation of an agent to a source and keep that in its agent.json, so it
|
|
313
|
+
* holds across restarts. `raw` is what goes into the file: session (default "main", which is the
|
|
314
|
+
* agent as such), note, when, debounce, throttle, separate, prompt. Subscribing the same
|
|
315
|
+
* conversation again replaces its entry.
|
|
316
|
+
*/
|
|
317
|
+
subscribe(agentId: string, name: string, raw?: Record<string, unknown>): Promise<SourceInfo & {
|
|
318
|
+
session: string;
|
|
319
|
+
}>;
|
|
320
|
+
/** Subscribe every agent that can use a source of pine.json: each is woken in its main conversation. Kept in pine.json. */
|
|
321
|
+
subscribeAll(name: string, raw?: Record<string, unknown>): Promise<SourceInfo & {
|
|
322
|
+
agents: string[];
|
|
323
|
+
}>;
|
|
324
|
+
private started;
|
|
325
|
+
/** End the subscription of one conversation, or with no `session` of all of that agent's conversations. Returns the conversations that were subscribed. */
|
|
326
|
+
unsubscribe(agentId: string, name: string, session?: string): Promise<string[]>;
|
|
327
|
+
unsubscribeAll(name: string): Promise<boolean>;
|
|
328
|
+
/**
|
|
329
|
+
* A conversation was closed or reset: what it was subscribed to on its own goes with it. An
|
|
330
|
+
* agent's main conversation is the agent as such and keeps its subscriptions.
|
|
331
|
+
*/
|
|
332
|
+
closeSession(agentId: string, session: string): Promise<string[]>;
|
|
333
|
+
/** A conversation got another name: what it was subscribed to on its own follows it. */
|
|
334
|
+
renameSession(agentId: string, from: string, to: string): Promise<void>;
|
|
335
|
+
/** Stop a source that nobody listens to any more. */
|
|
336
|
+
private settle;
|
|
337
|
+
}
|
|
338
|
+
/** A type's options in one line, for a tool description or an error: name, whether it is needed, what it is. */
|
|
339
|
+
export declare function describeOptions(schema: any): string;
|