@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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -2
  3. package/bin/pine.js +27 -0
  4. package/docs/SKILL.md +189 -0
  5. package/docs/references/agents.md +368 -0
  6. package/docs/references/coding.md +126 -0
  7. package/docs/references/configuration.md +229 -0
  8. package/docs/references/events.md +263 -0
  9. package/docs/references/mcp.md +257 -0
  10. package/docs/references/operations.md +280 -0
  11. package/docs/references/overview.md +186 -0
  12. package/docs/references/security.md +489 -0
  13. package/docs/references/setup.md +181 -0
  14. package/docs/references/sources.md +768 -0
  15. package/docs/references/stack.md +105 -0
  16. package/docs/references/tui.md +348 -0
  17. package/examples/home/.env.example +5 -0
  18. package/examples/home/USER.md +4 -0
  19. package/examples/home/agents/main/agent.json +4 -0
  20. package/examples/home/agents/main/workspace/AGENTS.md +14 -0
  21. package/examples/home/agents/main/workspace/MEMORY.md +4 -0
  22. package/examples/home/agents/main/workspace/SOUL.md +4 -0
  23. package/examples/home/models.json +62 -0
  24. package/examples/home/pine.json +30 -0
  25. package/examples/home/skills/README.txt +7 -0
  26. package/examples/launchd/com.pine.daemon.plist +32 -0
  27. package/mcp/browser.d.ts +34 -0
  28. package/mcp/browser.js +228 -0
  29. package/mcp/commands.d.ts +20 -0
  30. package/mcp/commands.js +88 -0
  31. package/mcp/lib.d.ts +13 -0
  32. package/mcp/lib.js +50 -0
  33. package/mcp/web.d.ts +6 -0
  34. package/mcp/web.js +140 -0
  35. package/package.json +32 -4
  36. package/src/agent-admin.d.ts +24 -0
  37. package/src/agent-admin.js +55 -0
  38. package/src/agents.d.ts +343 -0
  39. package/src/agents.js +1587 -0
  40. package/src/cli.d.ts +2 -0
  41. package/src/cli.js +526 -0
  42. package/src/config.d.ts +351 -0
  43. package/src/config.js +514 -0
  44. package/src/dev/claude-model.d.ts +28 -0
  45. package/src/dev/claude-model.js +174 -0
  46. package/src/env-file.d.ts +4 -0
  47. package/src/env-file.js +40 -0
  48. package/src/http.d.ts +46 -0
  49. package/src/http.js +407 -0
  50. package/src/limiter.d.ts +32 -0
  51. package/src/limiter.js +84 -0
  52. package/src/login.d.ts +21 -0
  53. package/src/login.js +126 -0
  54. package/src/manual.d.ts +61 -0
  55. package/src/manual.js +425 -0
  56. package/src/mcp-config.d.ts +40 -0
  57. package/src/mcp-config.js +132 -0
  58. package/src/net.d.ts +36 -0
  59. package/src/net.js +122 -0
  60. package/src/pine.d.ts +32 -0
  61. package/src/pine.js +142 -0
  62. package/src/router.d.ts +56 -0
  63. package/src/router.js +164 -0
  64. package/src/sandbox-fs.d.ts +27 -0
  65. package/src/sandbox-fs.js +172 -0
  66. package/src/security.d.ts +121 -0
  67. package/src/security.js +363 -0
  68. package/src/skills.d.ts +67 -0
  69. package/src/skills.js +163 -0
  70. package/src/source-types.d.ts +61 -0
  71. package/src/source-types.js +1 -0
  72. package/src/sources/command.d.ts +4 -0
  73. package/src/sources/command.js +77 -0
  74. package/src/sources/folder.d.ts +6 -0
  75. package/src/sources/folder.js +116 -0
  76. package/src/sources/ftp.d.ts +4 -0
  77. package/src/sources/ftp.js +307 -0
  78. package/src/sources/http.d.ts +4 -0
  79. package/src/sources/http.js +201 -0
  80. package/src/sources/mqtt.d.ts +18 -0
  81. package/src/sources/mqtt.js +97 -0
  82. package/src/sources/telegram.d.ts +22 -0
  83. package/src/sources/telegram.js +269 -0
  84. package/src/sources/webhook.d.ts +4 -0
  85. package/src/sources/webhook.js +52 -0
  86. package/src/sources.d.ts +339 -0
  87. package/src/sources.js +955 -0
  88. package/src/store.d.ts +89 -0
  89. package/src/store.js +251 -0
  90. package/src/timers.d.ts +83 -0
  91. package/src/timers.js +318 -0
  92. package/src/tools.d.ts +6 -0
  93. package/src/tools.js +777 -0
  94. package/src/tui/app.d.ts +209 -0
  95. package/src/tui/app.js +2223 -0
  96. package/src/tui/client.d.ts +178 -0
  97. package/src/tui/client.js +116 -0
  98. package/src/tui/code-pane.d.ts +51 -0
  99. package/src/tui/code-pane.js +398 -0
  100. package/src/tui/highlight.d.ts +8 -0
  101. package/src/tui/highlight.js +86 -0
  102. package/src/tui/picture.d.ts +141 -0
  103. package/src/tui/picture.js +571 -0
  104. package/src/tui/run.d.ts +8 -0
  105. package/src/tui/run.js +97 -0
  106. package/src/tui/theme.d.ts +32 -0
  107. package/src/tui/theme.js +128 -0
  108. package/src/types.d.ts +124 -0
  109. package/src/types.js +1 -0
  110. package/src/update.d.ts +25 -0
  111. package/src/update.js +110 -0
  112. package/src/util.d.ts +32 -0
  113. package/src/util.js +116 -0
  114. package/src/watching.d.ts +20 -0
  115. package/src/watching.js +68 -0
  116. package/src/workspace-view.d.ts +38 -0
  117. package/src/workspace-view.js +160 -0
package/src/sources.js ADDED
@@ -0,0 +1,955 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { spawn } from "node:child_process";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { EXT, loadConfig, mainAgentId, rawSubscriptions, sourceFor, SOURCE_TYPES, subscriptionFrom } from "./config.js";
14
+ import crypto from "node:crypto";
15
+ import os from "node:os";
16
+ import { Value } from "typebox/value";
17
+ import { parseEnv } from "node:util";
18
+ import { setEnv, unsetEnv } from "./env-file.js";
19
+ import { editJson } from "./mcp-config.js";
20
+ import { realish, rootsFor, sandboxAvailable, sandboxProfile, shellEnv } from "./security.js";
21
+ import { errText, parseDuration, resolvePath } from "./util.js";
22
+ /** What pine's .env holds, read when asked: an entry the owner just added counts at once. */
23
+ function envFile(home) {
24
+ try {
25
+ return parseEnv(fs.readFileSync(path.join(home, ".env"), "utf8"));
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ /** An error a webhook handler throws to answer with that HTTP status. */
32
+ export function refuse(status, message) {
33
+ return Object.assign(new Error(message), { status });
34
+ }
35
+ /** The settings every source has, whatever its type. A type's options cannot have these names. */
36
+ const RESERVED_OPTIONS = ["type", "use", "description", "agents", "trust", "start", "enabled", "session"];
37
+ /** The longest pause between two looks of a source whose looks keep failing (or its own interval, if that is longer). */
38
+ const POLL_BACKOFF_MAX = 15 * 60_000;
39
+ const clock = (ts) => new Date(ts).toTimeString().slice(0, 8);
40
+ /** Where the answers to a source's events are published; the source host passes them to the source. */
41
+ const outTopic = (key) => `out/source/${key}`;
42
+ export class SourceHost {
43
+ cfg;
44
+ bus;
45
+ kv;
46
+ redactor;
47
+ router;
48
+ log;
49
+ httpEnabled;
50
+ running = new Map();
51
+ starting = new Map();
52
+ errors = new Map();
53
+ statuses = new Map();
54
+ polls = new Map();
55
+ seen = new Map();
56
+ off;
57
+ offOut;
58
+ approvals;
59
+ globalRoutes = [];
60
+ mediaRoots = [];
61
+ /** Every source type there is: pine's own and the ones pine.json registers. `type` is what the module says about itself. */
62
+ types = new Map();
63
+ constructor(deps) {
64
+ this.approvals = deps.approvals;
65
+ this.cfg = deps.cfg;
66
+ this.bus = deps.bus;
67
+ this.kv = deps.kv;
68
+ this.redactor = deps.redactor;
69
+ this.router = deps.router;
70
+ this.log = deps.log;
71
+ this.httpEnabled = deps.http;
72
+ }
73
+ /** Every configured source with its key: global ones by name, an agent's own as name@agent. */
74
+ all() {
75
+ return [
76
+ ...Object.entries(this.cfg.sources).map(([name, source]) => ({ key: name, name, scope: "global", source })),
77
+ ...Object.values(this.cfg.agents).flatMap((a) => Object.entries(a.sources).map(([name, source]) => ({ key: `${name}@${a.id}`, name, scope: a.id, source }))),
78
+ ];
79
+ }
80
+ subscribersOf(key) {
81
+ const own = Object.values(this.cfg.agents).flatMap((a) => a.subscribe.filter((sub) => sourceFor(this.cfg, a.id, sub.source)?.key === key).map((sub) => (sub.session === "main" ? a.id : `${a.id}/${sub.session}`)));
82
+ return this.cfg.subscribe.some((sub) => sub.source === key) ? ["all agents", ...own] : own;
83
+ }
84
+ /** The agents a subscription for everyone reaches: those that can use the source and have no entry of their own for their main conversation. */
85
+ reached(sub) {
86
+ return Object.values(this.cfg.agents)
87
+ .filter((a) => sourceFor(this.cfg, a.id, sub.source)?.scope === "global" && !a.subscribe.some((x) => x.source === sub.source && x.session === sub.session))
88
+ .map((a) => a.id);
89
+ }
90
+ /** (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. */
91
+ wireGlobal() {
92
+ for (const name of this.globalRoutes)
93
+ this.router.remove(name);
94
+ this.globalRoutes = [];
95
+ for (const sub of this.cfg.subscribe) {
96
+ const source = this.cfg.sources[sub.source];
97
+ if (!source)
98
+ continue;
99
+ for (const agentId of this.reached(sub)) {
100
+ const route = { ...this.route(agentId, sub.source, source, sub), name: `sub:*:${agentId}:${sub.source}` };
101
+ this.router.add(route);
102
+ this.globalRoutes.push(route.name);
103
+ }
104
+ }
105
+ }
106
+ /** Read what each source type says about itself. A type whose module does not load is left out, with a line in the log. */
107
+ async loadTypes() {
108
+ const files = { ...Object.fromEntries(SOURCE_TYPES.map((t) => [t, path.join(import.meta.dirname, "sources", `${t}${EXT}`)])), ...this.cfg.sourceTypes };
109
+ for (const [name, file] of Object.entries(files)) {
110
+ try {
111
+ const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
112
+ const type = mod.type && typeof mod.type === "object" && mod.type.options ? mod.type : undefined;
113
+ this.types.set(name, { file, type });
114
+ if (!type && !SOURCE_TYPES.includes(name))
115
+ this.log.warn(`source type ${name}: ${file} exports no "type" with a schema of its options, so agents cannot set one up by tool call`);
116
+ }
117
+ catch (e) {
118
+ this.log.error(`source type ${name} could not be loaded from ${file}: ${errText(e)}`);
119
+ }
120
+ }
121
+ }
122
+ /** The source types an agent can set up by tool call: name, what it is for, and the schema of its options. */
123
+ typeList() {
124
+ return [...this.types].flatMap(([name, t]) => (t.type ? [{ name, summary: t.type.summary, options: t.type.options }] : []));
125
+ }
126
+ /** Where the source types that were registered while pine ran are kept: in pine's home, which no agent can write. */
127
+ typeDir = () => path.join(this.cfg.home, "sources");
128
+ /** The registered source types that are not pine's own, with what uses each. */
129
+ ownTypes() {
130
+ return Object.entries(this.cfg.sourceTypes).map(([name, file]) => ({ name, file, summary: this.types.get(name)?.type?.summary, usedBy: this.all().filter((s) => s.source.type === name).map((s) => s.key) }));
131
+ }
132
+ /**
133
+ * Register a new source type from the text of its module. The text is copied into pine's home
134
+ * first, and it is that copy that is loaded and runs from then on: what an agent wrote and the
135
+ * owner confirmed cannot be changed afterwards. A module that does not load or does not describe
136
+ * itself is not registered, and nothing of it stays.
137
+ *
138
+ * This runs the module inside the daemon. Whoever calls it has the owner's yes for exactly this text.
139
+ */
140
+ async addType(name, code) {
141
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(name))
142
+ throw new Error(`"${name}" is not a usable type name: lowercase letters, digits, - and _, starting with a letter`);
143
+ if (SOURCE_TYPES.includes(name))
144
+ throw new Error(`"${name}" is one of pine's own types`);
145
+ if (this.cfg.sourceTypes[name])
146
+ throw new Error(`a source type "${name}" is registered already. Remove it first (remove_source_type) to put another in its place`);
147
+ fs.mkdirSync(this.typeDir(), { recursive: true, mode: 0o700 });
148
+ // The name carries what is in it: Node loads a file once, so a later version must be another file.
149
+ const file = path.join(this.typeDir(), `${name}.${crypto.createHash("sha256").update(code).digest("hex").slice(0, 10)}.ts`);
150
+ fs.writeFileSync(file, code, { mode: 0o600 });
151
+ let type;
152
+ try {
153
+ const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
154
+ if (typeof mod.default !== "function")
155
+ throw new Error("it has no default export function, which is what starts a source");
156
+ type = mod.type;
157
+ if (!type || typeof type !== "object" || typeof type.summary !== "string" || !type.summary.trim())
158
+ throw new Error('it exports no "type" with a "summary": one line on what such a source reports');
159
+ const schema = type.options;
160
+ if (!schema || schema.type !== "object" || typeof schema.properties !== "object")
161
+ throw new Error('its "type.options" must be a JSON schema of an object: { type: "object", properties: { ... } }');
162
+ const taken = Object.keys(schema.properties).filter((k) => RESERVED_OPTIONS.includes(k));
163
+ if (taken.length)
164
+ throw new Error(`an option cannot be called ${taken.join(", ")}: every source has a setting of that name already`);
165
+ if (type.plan !== undefined && typeof type.plan !== "function")
166
+ throw new Error('"type.plan" must be a function');
167
+ describeOptions(schema);
168
+ }
169
+ catch (e) {
170
+ fs.rmSync(file, { force: true });
171
+ throw new Error(`the module is not a source type: ${errText(e)}`);
172
+ }
173
+ editJson(path.join(this.cfg.home, "pine.json"), (json) => {
174
+ json.sourceTypes = { ...(json.sourceTypes ?? {}), [name]: `./sources/${path.basename(file)}` };
175
+ });
176
+ this.cfg.sourceTypes[name] = file;
177
+ this.types.set(name, { file, type });
178
+ this.log.info(`source type ${name} registered (${file})`);
179
+ return { name, summary: type.summary, options: describeOptions(type.options), file };
180
+ }
181
+ /** Take a registered source type out again. Not while a source of that type exists. */
182
+ removeType(name) {
183
+ if (SOURCE_TYPES.includes(name))
184
+ throw new Error(`"${name}" is one of pine's own types and stays`);
185
+ const file = this.cfg.sourceTypes[name];
186
+ if (!file)
187
+ throw new Error(`there is no registered source type "${name}". Registered: ${Object.keys(this.cfg.sourceTypes).join(", ") || "none"}`);
188
+ const used = this.all().filter((s) => s.source.type === name).map((s) => s.name);
189
+ if (used.length)
190
+ throw new Error(`the source${used.length === 1 ? "" : "s"} ${used.join(", ")} ${used.length === 1 ? "is" : "are"} of that type. Remove ${used.length === 1 ? "it" : "them"} first`);
191
+ editJson(path.join(this.cfg.home, "pine.json"), (json) => {
192
+ delete json.sourceTypes?.[name];
193
+ if (json.sourceTypes && !Object.keys(json.sourceTypes).length)
194
+ delete json.sourceTypes;
195
+ });
196
+ // Only the copy pine made itself, which carries the text's hash in its name. A module the owner
197
+ // registered by hand is theirs to delete, also when it lies in the same folder.
198
+ if (path.dirname(file) === this.typeDir() && new RegExp(`^${name}\\.[0-9a-f]{10}\\.ts$`).test(path.basename(file)))
199
+ fs.rmSync(file, { force: true });
200
+ delete this.cfg.sourceTypes[name];
201
+ this.types.delete(name);
202
+ this.log.info(`source type ${name} removed`);
203
+ }
204
+ /** The name a secret option of a source gets in .env. */
205
+ envName(name, agentId, option) {
206
+ return `PINE_SOURCE_${[name, ...(agentId ? [agentId] : []), option].join("_").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`;
207
+ }
208
+ /**
209
+ * Work out what setting up a source of a type with these options would mean. The options are
210
+ * checked against the schema the type exposes, then the type itself says what it would do.
211
+ * Nothing is written.
212
+ */
213
+ propose(agentId, name, typeName, raw, forAll = false, ask = []) {
214
+ const agent = this.cfg.agents[agentId];
215
+ if (!agent)
216
+ throw new Error(`unknown agent "${agentId}"`);
217
+ const known = this.typeList();
218
+ const type = this.types.get(typeName)?.type;
219
+ if (!type)
220
+ throw new Error(`unknown type "${typeName}". Types: ${known.map((t) => t.name).join(", ")}`);
221
+ let options = raw ?? {};
222
+ if (typeof options === "string") {
223
+ try {
224
+ options = JSON.parse(options);
225
+ }
226
+ catch {
227
+ throw new Error(`"options" must be an object, for example ${JSON.stringify(example(type.options))}`);
228
+ }
229
+ }
230
+ if (!options || typeof options !== "object" || Array.isArray(options))
231
+ throw new Error(`"options" must be an object, for example ${JSON.stringify(example(type.options))}`);
232
+ // A secret is not the agent's to give: one that is required and missing, or that the agent
233
+ // names in `ask`, the owner types into pine's own dialog. Until then it is simply not there.
234
+ const props = type.options.properties ?? {};
235
+ const secretKeys = Object.keys(props).filter((k) => props[k]?.secret === true);
236
+ const unknownAsk = ask.filter((k) => !secretKeys.includes(k));
237
+ if (unknownAsk.length)
238
+ throw new Error(`"ask" is for the secret options of a ${typeName} source (${secretKeys.join(", ") || "it has none"}), not for ${unknownAsk.join(", ")}`);
239
+ options = { ...options };
240
+ for (const k of secretKeys)
241
+ if (options[k] === "" || options[k] === null)
242
+ delete options[k];
243
+ const needs = secretKeys.filter((k) => options[k] === undefined && (ask.includes(k) || (type.options.required ?? []).includes(k))).map((k) => ({ name: k, label: `${k}: ${props[k].description ?? "a secret"}` }));
244
+ // One that is in pine's .env already is given by name; its value is looked up here, for the type to check, and never shown.
245
+ const refs = {};
246
+ for (const k of secretKeys) {
247
+ const m = typeof options[k] === "string" ? /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(options[k]) : null;
248
+ if (!m)
249
+ continue;
250
+ const value = envFile(this.cfg.home)[m[1]];
251
+ if (!value)
252
+ throw new Error(`there is no ${m[1]} in pine's .env file. Your owner adds a line ${m[1]}=... to ${path.join(this.cfg.home, ".env")}, or you leave "${k}" out and pine asks them for it`);
253
+ refs[k] = options[k];
254
+ options[k] = value;
255
+ }
256
+ const wrong = [...Value.Errors(type.options, options)]
257
+ .flatMap((e) => {
258
+ if (e.keyword === "boolean")
259
+ return [];
260
+ if (e.keyword === "additionalProperties")
261
+ return [`there is no option ${(e.params?.additionalProperties ?? []).join(", ")}`];
262
+ if (e.keyword === "required") {
263
+ const absent = (e.params?.requiredProperties ?? []).filter((k) => !needs.some((n) => n.name === k));
264
+ return absent.length ? [`${absent.join(", ")} is missing`] : [];
265
+ }
266
+ return [`${String(e.instancePath ?? "").replace(/^\//, "") || "options"} ${e.message}`];
267
+ })
268
+ .slice(0, 6);
269
+ if (wrong.length)
270
+ throw new Error(`these options do not fit a ${typeName} source: ${wrong.join("; ")}. It takes: ${describeOptions(type.options)}`);
271
+ const setup = {
272
+ name,
273
+ key: forAll ? name : `${name}@${agent.id}`,
274
+ agent: agent.id,
275
+ workspace: agent.workspace,
276
+ home: this.cfg.home,
277
+ resolve: (p) => realish(p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : path.isAbsolute(p) ? p : path.join(agent.workspace, p)),
278
+ random: (bytes) => crypto.randomBytes(bytes).toString("base64url"),
279
+ envName: (option) => this.envName(name, forAll ? undefined : agent.id, option),
280
+ };
281
+ const plan = type.plan ? type.plan(options, setup) : { options, does: [`start a ${typeName} source with ${JSON.stringify(redact(type.options, options))}`], opens: true };
282
+ // What came from .env stays there: the entry keeps the name, not the value.
283
+ plan.options = { ...plan.options, ...refs };
284
+ const secrets = Object.entries(props).filter(([key, schema]) => schema?.secret === true && typeof plan.options[key] === "string" && plan.options[key] && !refs[key]).map(([key]) => key);
285
+ return { ...plan, secrets, needs };
286
+ }
287
+ async start() {
288
+ await this.loadTypes();
289
+ this.off = this.bus.subscribe("source/#", (e) => {
290
+ const key = e.topic.split("/")[1];
291
+ const s = this.seen.get(key);
292
+ this.seen.set(key, { ts: e.ts, topic: e.topic, count: (s?.count ?? 0) + 1 });
293
+ }, "sources");
294
+ // An agent's answer to a source's event, on its way back out through that source.
295
+ this.offOut = this.bus.subscribe("out/source/+", async (e) => {
296
+ const key = e.topic.split("/")[2];
297
+ const send = this.running.get(key)?.send;
298
+ if (!send)
299
+ return;
300
+ try {
301
+ await send((e.payload ?? {}));
302
+ }
303
+ catch (err) {
304
+ this.log.warn(`source ${key} could not send`, errText(err));
305
+ }
306
+ }, "sources-out");
307
+ for (const agent of Object.values(this.cfg.agents)) {
308
+ for (const sub of agent.subscribe) {
309
+ const found = sourceFor(this.cfg, agent.id, sub.source);
310
+ if (found)
311
+ this.router.add(this.route(agent.id, found.key, found.source, sub));
312
+ }
313
+ }
314
+ this.wireGlobal();
315
+ for (const { key, source } of this.all()) {
316
+ if (source.enabled && (source.start === "always" || this.subscribersOf(key).length))
317
+ await this.ensure(key);
318
+ }
319
+ }
320
+ async stop() {
321
+ this.off?.();
322
+ this.offOut?.();
323
+ for (const key of [...this.running.keys()])
324
+ await this.halt(key);
325
+ }
326
+ routeName = (agentId, sub) => `sub:${agentId}:${sub.source}:${sub.session}`;
327
+ /** What a subscription amounts to: this source's events become turns of that agent in that conversation. */
328
+ route(agentId, key, source, sub) {
329
+ const name = sub.source;
330
+ const what = `${source.type ?? "custom"}${source.description ? `: ${source.description}` : ""}`;
331
+ const type = source.type ? this.types.get(source.type)?.type : undefined;
332
+ // Somebody writing to the agent through the source: their words are the message, not a report about one.
333
+ const talk = type?.talks
334
+ ? {
335
+ prompt: sub.prompt ?? "{{payload.text}}",
336
+ label: `${name}: {{payload.name}}`,
337
+ preface: `[${source.trust === "owner" ? "Your owner wrote" : "Somebody wrote"} this to you through "${name}" (${what}). Your answer is sent back there, so write it for that reader.${sub.note ? ` Your note for this subscription: ${sub.note}` : ""}]`,
338
+ }
339
+ : {};
340
+ return {
341
+ name: this.routeName(agentId, sub),
342
+ on: [`source/${key}/#`],
343
+ when: sub.when,
344
+ debounceMs: sub.debounceMs,
345
+ throttleMs: type?.talks && sub.throttleDefault ? undefined : sub.throttleMs || undefined,
346
+ // One bucket per subscription, whatever the event is called.
347
+ key: "subscription",
348
+ agent: agentId,
349
+ session: sub.session,
350
+ prompt: sub.prompt,
351
+ preface: `[You are subscribed to the event source "${name}" (${what}), and it has just reported the event below.${sub.note ? ` Your note for this subscription: ${sub.note}` : " Act on it as your instructions say."} If nothing needs doing, say so in one line.]`,
352
+ label: `${name}: {{topic}}`,
353
+ ...talk,
354
+ // Where an answer goes is the event's to say: a source that can send asks for it, the others do not.
355
+ // The agent subscribed in a conversation with its owner, and that is where it is woken, with
356
+ // the tools it has there. What a source that is not the owner's reports is outside content
357
+ // in that conversation, like a page the agent read. `separate` keeps such events out of it:
358
+ // then the event's own trust decides, which means a conversation and a tool set apart.
359
+ ...(sub.separate ? {} : { trust: "owner", taint: source.trust === "owner" ? undefined : `an event from the source "${name}"` }),
360
+ };
361
+ }
362
+ async ensure(key) {
363
+ if (this.running.has(key))
364
+ return;
365
+ const pending = this.starting.get(key);
366
+ if (pending)
367
+ return pending;
368
+ const entry = this.all().find((s) => s.key === key);
369
+ if (!entry || !entry.source.enabled)
370
+ return;
371
+ const job = this.launch(entry.key, entry.name, entry.source)
372
+ .then(() => void this.errors.delete(key))
373
+ .catch((e) => {
374
+ // A source that cannot start must not take the daemon down: the rest keeps working, and the reason is shown.
375
+ this.errors.set(key, errText(e));
376
+ this.log.warn(`source ${key} could not start`, errText(e));
377
+ })
378
+ .finally(() => this.starting.delete(key));
379
+ this.starting.set(key, job);
380
+ return job;
381
+ }
382
+ async launch(key, name, source) {
383
+ const known = source.type ? (this.types.get(source.type)?.file ?? (SOURCE_TYPES.includes(source.type) ? path.join(import.meta.dirname, "sources", `${source.type}${EXT}`) : this.cfg.sourceTypes[source.type])) : undefined;
384
+ if (source.type && !known)
385
+ throw new Error(`there is no source type "${source.type}"`);
386
+ const file = known ?? resolvePath(String(source.use), source.base);
387
+ const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
388
+ const setup = typeof mod.default === "function" ? mod.default : undefined;
389
+ if (!setup)
390
+ throw new Error(`${file} has no default export function`);
391
+ const dataDir = path.join(this.cfg.dataDir, "sources", key.replace(/[^A-Za-z0-9_@.-]/g, "_"));
392
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
393
+ const run = { stops: [] };
394
+ const prefix = `source:${key}:`;
395
+ const api = {
396
+ name,
397
+ key,
398
+ options: source.options,
399
+ base: source.base,
400
+ dataDir,
401
+ log: this.log.child(key),
402
+ emit: (event, payload, opts) => {
403
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(event))
404
+ throw new Error(`source ${key}: "${event}" is not a usable event name`);
405
+ return this.bus.publish(`source/${key}/${event}`, payload ?? {}, { source: `source:${key}`, trust: source.trust, persist: opts?.persist, reply: opts?.reply ? { topic: outTopic(key), payload: opts.reply } : undefined });
406
+ },
407
+ onSend: (handler) => void (run.send = handler),
408
+ status: (line) => void this.statuses.set(key, String(line).slice(0, 300)),
409
+ on: (pattern, handler) => void run.stops.push(this.bus.subscribe(pattern, async (e) => {
410
+ try {
411
+ await handler(e);
412
+ }
413
+ catch (err) {
414
+ this.log.warn(`source ${key} failed on ${e.topic}`, errText(err));
415
+ }
416
+ }, `source:${key}`)),
417
+ approvals: {
418
+ ask: async (summary, why) => (this.approvals ? (await this.approvals.request({ agent: `${name} (event source)`, task: 0, tool: source.type ?? name, summary, why })).allow : false),
419
+ request: async (summary, why, inputs) => (this.approvals ? this.approvals.request({ agent: `${name} (event source)`, task: 0, tool: source.type ?? name, summary, why, inputs }) : { allow: false, by: "nobody" }),
420
+ pending: () => this.approvals?.pending() ?? [],
421
+ decide: (id, allow, by, always) => this.approvals?.decide(id, allow, by, always) ?? false,
422
+ },
423
+ poll: (every, look) => {
424
+ const ms = parseDuration(every);
425
+ if (!(ms > 0))
426
+ throw new Error(`source ${key}: "${every}" is not an interval to look at`);
427
+ if (this.polls.has(key))
428
+ throw new Error(`source ${key}: a source polls one thing`);
429
+ const state = { fails: 0 };
430
+ this.polls.set(key, state);
431
+ const abort = new AbortController();
432
+ let timer;
433
+ const round = async () => {
434
+ try {
435
+ await look(abort.signal);
436
+ if (abort.signal.aborted)
437
+ return;
438
+ if (state.fails)
439
+ api.log.info(`works again after ${state.fails} failed ${state.fails === 1 ? "look" : "looks"}`);
440
+ Object.assign(state, { ok: Date.now(), fails: 0, since: undefined, error: undefined });
441
+ }
442
+ catch (e) {
443
+ if (abort.signal.aborted)
444
+ return;
445
+ // Said once, not at every round: the status line carries it from here.
446
+ if (!state.fails)
447
+ api.log.warn("could not look", errText(e));
448
+ Object.assign(state, { fails: state.fails + 1, since: state.since ?? Date.now(), error: errText(e).slice(0, 200) });
449
+ }
450
+ const wait = state.fails ? Math.min(ms * 2 ** Math.min(state.fails, 12), Math.max(ms, POLL_BACKOFF_MAX)) : ms;
451
+ timer = setTimeout(round, wait * (0.9 + Math.random() * 0.2));
452
+ timer.unref?.();
453
+ };
454
+ run.stops.push(() => {
455
+ abort.abort();
456
+ clearTimeout(timer);
457
+ this.polls.delete(key);
458
+ });
459
+ // Not awaited: a slow first look must not hold up the start of everything else.
460
+ timer = setTimeout(round, 0);
461
+ },
462
+ changed: (value, slot = "") => {
463
+ const k = `${prefix}seen:${slot}`;
464
+ const before = this.kv.get(k);
465
+ if (before && JSON.stringify(before.value) === JSON.stringify(value))
466
+ return undefined;
467
+ this.kv.set(k, { value });
468
+ return before ? { previous: before.value } : undefined;
469
+ },
470
+ exec: (command, opts) => this.exec(key, command, opts),
471
+ kv: { get: (k) => this.kv.get(prefix + k), set: (k, v) => this.kv.set(prefix + k, v) },
472
+ secret: (value) => this.redactor.add([value]),
473
+ media: (dir) => void this.mediaRoots.push(realish(dir)),
474
+ http: (handler) => {
475
+ if (!this.httpEnabled)
476
+ throw new Error("this source receives web requests, which needs the control API (\"http\" in pine.json) to be on");
477
+ run.hook = handler;
478
+ },
479
+ onStop: (fn) => void run.stops.push(fn),
480
+ };
481
+ try {
482
+ await setup(api);
483
+ }
484
+ catch (e) {
485
+ for (const fn of run.stops.reverse())
486
+ await Promise.resolve(fn()).catch(() => { });
487
+ throw e;
488
+ }
489
+ this.running.set(key, run);
490
+ this.log.info(`source ${key} started`);
491
+ }
492
+ /** A source's shell command, run the way the shell of the agent it belongs to would run it. */
493
+ exec(key, command, opts = {}) {
494
+ const agent = this.cfg.agents[key.includes("@") ? key.slice(key.lastIndexOf("@") + 1) : mainAgentId(this.cfg)];
495
+ if (!agent)
496
+ return Promise.reject(new Error("the agent this source belongs to is gone"));
497
+ const roots = rootsFor(this.cfg, agent);
498
+ // Where the agent's own shell is withheld because nothing could confine it, a source's command is not run either.
499
+ if (agent.sandbox.enabled && !sandboxAvailable())
500
+ return Promise.reject(new Error("there is no sandbox on this platform to run the command in, so it is not run"));
501
+ // The variant of a run that has read outside content: what is read into the agent's prompt stays as it is.
502
+ const argv = agent.sandbox.enabled ? ["/usr/bin/sandbox-exec", "-p", sandboxProfile(this.cfg, agent, roots, "restricted"), "/bin/bash", "-c", command] : ["/bin/bash", "-c", command];
503
+ const max = opts.maxBytes ?? 256 * 1024;
504
+ const timeoutMs = opts.timeoutMs ?? 30_000;
505
+ return new Promise((resolve, reject) => {
506
+ if (opts.signal?.aborted)
507
+ return reject(new Error("stopped"));
508
+ const child = spawn(argv[0], argv.slice(1), { cwd: roots.workspace, env: shellEnv(agent, roots, {}), stdio: ["ignore", "pipe", "pipe"], detached: true });
509
+ const chunks = [];
510
+ let size = 0;
511
+ let why = "";
512
+ // The whole group: a command that started others must not leave them behind.
513
+ const kill = (reason) => {
514
+ why ||= reason;
515
+ try {
516
+ process.kill(-child.pid, "SIGKILL");
517
+ }
518
+ catch {
519
+ child.kill("SIGKILL");
520
+ }
521
+ };
522
+ const take = (d) => {
523
+ if (size < max)
524
+ chunks.push(d.subarray(0, max - size));
525
+ size += d.length;
526
+ if (size > max * 4)
527
+ kill("");
528
+ };
529
+ child.stdout.on("data", take);
530
+ child.stderr.on("data", take);
531
+ const timer = setTimeout(() => kill(`it took longer than ${timeoutMs / 1000} s`), timeoutMs);
532
+ const stop = () => kill("stopped");
533
+ opts.signal?.addEventListener("abort", stop, { once: true });
534
+ const done = () => {
535
+ clearTimeout(timer);
536
+ opts.signal?.removeEventListener("abort", stop);
537
+ };
538
+ child.on("error", (e) => (done(), reject(new Error(`could not be started: ${errText(e)}`))));
539
+ child.on("close", (code) => {
540
+ done();
541
+ if (why)
542
+ return reject(new Error(why));
543
+ resolve({ code: code ?? 1, output: Buffer.concat(chunks).toString("utf8"), truncated: size > max });
544
+ });
545
+ });
546
+ }
547
+ async halt(key) {
548
+ const run = this.running.get(key);
549
+ if (!run)
550
+ return;
551
+ this.running.delete(key);
552
+ this.statuses.delete(key);
553
+ for (const fn of run.stops.reverse()) {
554
+ try {
555
+ await fn();
556
+ }
557
+ catch (e) {
558
+ this.log.warn(`source ${key} failed to stop`, errText(e));
559
+ }
560
+ }
561
+ this.log.info(`source ${key} stopped`);
562
+ }
563
+ /**
564
+ * The sources an agent can send through, with what sending does. A global one or its own;
565
+ * one that was set up for a single conversation only from there.
566
+ */
567
+ senders(agentId, session = "main") {
568
+ return this.available(agentId, session).flatMap((s) => {
569
+ const sends = this.types.get(s.type)?.type?.sends;
570
+ return sends ? [{ name: s.name, type: s.type, sends }] : [];
571
+ });
572
+ }
573
+ /** Send something out through a source, started if it is not running. Throws with the source's own reason if it refuses. */
574
+ async send(agentId, name, message, session = "main") {
575
+ if (!this.senders(agentId, session).some((s) => s.name === name))
576
+ throw new Error(`"${name}" is not a source you can send through. ${this.senders(agentId, session).map((s) => `${s.name}: ${s.sends}`).join("; ") || "There is none."}`);
577
+ const found = sourceFor(this.cfg, agentId, name);
578
+ await this.ensure(found.key);
579
+ const send = this.running.get(found.key)?.send;
580
+ if (!send)
581
+ throw new Error(`${name} is not running${this.errors.get(found.key) ? `: ${this.errors.get(found.key)}` : ""}`);
582
+ return String((await send(message)) ?? "sent");
583
+ }
584
+ /** The handler for POST /hook/<key>, if a running source takes web requests under that name. */
585
+ hookFor(key) {
586
+ return this.running.get(key)?.hook;
587
+ }
588
+ /** What a running source says about itself; for one that polls, with when it last looked or since when that fails. */
589
+ statusOf(key) {
590
+ const poll = this.polls.get(key);
591
+ const looked = !poll ? "" : poll.fails ? `failing since ${clock(poll.since)} (${poll.fails} ${poll.fails === 1 ? "look" : "looks"}): ${poll.error}` : poll.ok ? `last looked ${clock(poll.ok)}` : "looking for the first time";
592
+ return [looked, this.statuses.get(key)].filter(Boolean).join(". ") || undefined;
593
+ }
594
+ list() {
595
+ return this.all().map(({ key, name, scope, source }) => {
596
+ const seen = this.seen.get(key);
597
+ return {
598
+ name,
599
+ scope,
600
+ type: source.type ?? "custom",
601
+ description: source.description,
602
+ trust: source.trust,
603
+ running: this.running.has(key),
604
+ error: this.errors.get(key) ?? (source.enabled ? undefined : "disabled"),
605
+ status: this.running.has(key) ? this.statusOf(key) : undefined,
606
+ subscribers: this.subscribersOf(key),
607
+ topic: `source/${key}`,
608
+ last: seen ? { ts: seen.ts, topic: seen.topic } : undefined,
609
+ events: seen?.count ?? 0,
610
+ };
611
+ });
612
+ }
613
+ /**
614
+ * The sources an agent can subscribe to, as seen from one of its conversations, with everything
615
+ * that would wake it: `local` this conversation, `agent` its main conversation, `global` a
616
+ * subscription for all agents, `elsewhere` its other conversations.
617
+ */
618
+ available(agentId, session = "main") {
619
+ const agent = this.cfg.agents[agentId];
620
+ if (!agent)
621
+ return [];
622
+ const names = new Set([...Object.keys(agent.sources), ...Object.keys(this.cfg.sources)]);
623
+ return [...names].flatMap((name) => {
624
+ const found = sourceFor(this.cfg, agentId, name);
625
+ if (!found || !found.source.enabled)
626
+ return [];
627
+ // A source set up for one conversation is that conversation's.
628
+ if (found.source.session && found.source.session !== session)
629
+ return [];
630
+ const subs = agent.subscribe.filter((x) => x.source === name);
631
+ return [
632
+ {
633
+ name,
634
+ type: found.source.type ?? "custom",
635
+ description: found.source.description,
636
+ local: session === "main" ? undefined : subs.find((x) => x.session === session),
637
+ agent: subs.find((x) => x.session === "main"),
638
+ global: found.scope === "global" ? this.cfg.subscribe.find((x) => x.source === name) : undefined,
639
+ elsewhere: subs.filter((x) => x.session !== session && x.session !== "main").map((x) => x.session),
640
+ },
641
+ ];
642
+ });
643
+ }
644
+ /**
645
+ * Add a source to the configuration while the daemon runs: to one agent's agent.json, or to
646
+ * pine.json for everyone. `entry` is what goes into the file. The file is written first and then
647
+ * read back through the normal loader, so a new source is checked exactly like one that was
648
+ * there at start; if it does not pass, the file is put back as it was.
649
+ * `read` also lets that agent read the folder (for a folder source outside its workspace).
650
+ */
651
+ add(agentId, name, entry, read, secrets = []) {
652
+ const agent = agentId ? this.cfg.agents[agentId] : undefined;
653
+ if (agentId && !agent)
654
+ throw new Error(`unknown agent "${agentId}"`);
655
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(name))
656
+ throw new Error(`"${name}" is not a usable source name: lowercase letters, digits, - and _, starting with a letter`);
657
+ if (this.cfg.sources[name] || Object.values(this.cfg.agents).some((a) => a.sources[name]))
658
+ throw new Error(`a source named "${name}" exists already; pick another name, or subscribe to that one`);
659
+ const file = agent ? path.join(agent.dir, "agent.json") : path.join(this.cfg.home, "pine.json");
660
+ const before = fs.readFileSync(file, "utf8");
661
+ // Read access to a watched folder is an agent's: for a source of all agents, the main agent's, which set it up.
662
+ const reader = read ? (agent ?? this.cfg.agents[mainAgentId(this.cfg)]) : undefined;
663
+ const readerFile = reader ? path.join(reader.dir, "agent.json") : undefined;
664
+ const readerBefore = readerFile && readerFile !== file ? fs.readFileSync(readerFile, "utf8") : undefined;
665
+ const grant = (json) => {
666
+ json.sandbox = { ...(json.sandbox ?? {}) };
667
+ json.sandbox.read = [...new Set([...(json.sandbox.read ?? []), read])];
668
+ };
669
+ // A secret goes into .env; the configuration only refers to it by name.
670
+ const kept = {};
671
+ entry = { ...entry };
672
+ for (const key of secrets) {
673
+ const env = this.envName(name, agentId, key);
674
+ kept[env] = String(entry[key]);
675
+ entry[key] = `\${${env}}`;
676
+ // From here on it is a secret: kept out of what follows, and taken out of what was stored when it was said.
677
+ this.redactor.add([kept[env]]);
678
+ this.bus.scrub(kept[env]);
679
+ }
680
+ setEnv(this.cfg.home, kept);
681
+ editJson(file, (json) => {
682
+ json.sources = { ...(json.sources ?? {}), [name]: entry };
683
+ if (read && readerFile === file)
684
+ grant(json);
685
+ });
686
+ if (readerFile && readerFile !== file)
687
+ editJson(readerFile, grant);
688
+ let fresh;
689
+ try {
690
+ fresh = loadConfig(this.cfg.home);
691
+ }
692
+ catch (e) {
693
+ fs.writeFileSync(file, before);
694
+ if (readerFile && readerBefore !== undefined)
695
+ fs.writeFileSync(readerFile, readerBefore);
696
+ unsetEnv(this.cfg.home, Object.keys(kept));
697
+ throw new Error(errText(e).replace(/^[\s\S]*?sources/, "sources"));
698
+ }
699
+ const parsed = agent ? fresh.agents[agent.id].sources[name] : fresh.sources[name];
700
+ if (agent)
701
+ agent.sources[name] = parsed;
702
+ else
703
+ this.cfg.sources[name] = parsed;
704
+ if (reader)
705
+ reader.sandbox.read = fresh.agents[reader.id].sandbox.read;
706
+ this.log.info(`source ${name} added ${agent ? `for ${agent.id}` : "for all agents"} (${parsed.type ?? "custom"})`);
707
+ return parsed;
708
+ }
709
+ /**
710
+ * Take a source out of the configuration while the daemon runs: it stops, whoever was subscribed
711
+ * to it is unsubscribed, and its entry is deleted. For a watched folder outside the agent's
712
+ * workspace, the read access that came with it goes too, unless another source still needs it.
713
+ */
714
+ async remove(agentId, name) {
715
+ const agent = agentId ? this.cfg.agents[agentId] : undefined;
716
+ if (agentId && !agent)
717
+ throw new Error(`unknown agent "${agentId}"`);
718
+ const source = agent ? agent.sources[name] : this.cfg.sources[name];
719
+ if (!source)
720
+ return { removed: false, unsubscribed: [] };
721
+ const key = agent ? `${name}@${agent.id}` : name;
722
+ const unsubscribed = this.subscribersOf(key);
723
+ if (agent)
724
+ await this.unsubscribe(agent.id, name);
725
+ else {
726
+ await this.unsubscribeAll(name);
727
+ for (const a of Object.values(this.cfg.agents))
728
+ if (sourceFor(this.cfg, a.id, name)?.key === key)
729
+ await this.unsubscribe(a.id, name);
730
+ }
731
+ await this.halt(key);
732
+ // What it kept for itself goes with it: a source of the same name set up later starts from nothing (nobody paired, no place in a queue).
733
+ this.kv.forget(`source:${key}:`);
734
+ const dir = source.type === "folder" && typeof source.options.dir === "string" ? resolvePath(source.options.dir, source.base) : undefined;
735
+ // Whose read access came with it: the agent's own, or the main agent's for a source of all agents.
736
+ const reader = agent ?? this.cfg.agents[mainAgentId(this.cfg)];
737
+ const stillWatched = (a) => [...Object.entries(a?.sources ?? {}), ...Object.entries(this.cfg.sources)].some(([n, x]) => n !== name && x.type === "folder" && typeof x.options.dir === "string" && resolvePath(x.options.dir, x.base) === dir);
738
+ const revoke = (json) => {
739
+ if (!reader || !dir || stillWatched(reader) || !Array.isArray(json.sandbox?.read))
740
+ return;
741
+ json.sandbox.read = json.sandbox.read.filter((x) => resolvePath(x, reader.dir) !== dir);
742
+ if (!json.sandbox.read.length)
743
+ delete json.sandbox.read;
744
+ if (!Object.keys(json.sandbox).length)
745
+ delete json.sandbox;
746
+ };
747
+ const names = new Set();
748
+ editJson(agent ? path.join(agent.dir, "agent.json") : path.join(this.cfg.home, "pine.json"), (json) => {
749
+ // The secrets pine put into .env for it go too.
750
+ for (const value of Object.values(json.sources?.[name] ?? {})) {
751
+ const m = typeof value === "string" ? /^\$\{(PINE_SOURCE_[A-Z0-9_]+)\}$/.exec(value) : null;
752
+ if (m)
753
+ names.add(m[1]);
754
+ }
755
+ delete json.sources?.[name];
756
+ if (json.sources && !Object.keys(json.sources).length)
757
+ delete json.sources;
758
+ if (agent)
759
+ revoke(json);
760
+ });
761
+ if (!agent && reader && dir)
762
+ editJson(path.join(reader.dir, "agent.json"), revoke);
763
+ unsetEnv(this.cfg.home, [...names]);
764
+ if (agent)
765
+ delete agent.sources[name];
766
+ else
767
+ delete this.cfg.sources[name];
768
+ if (reader && dir && !stillWatched(reader))
769
+ reader.sandbox.read = reader.sandbox.read.filter((x) => x !== dir);
770
+ this.log.info(`source ${name} removed ${agent ? `from ${agent.id}` : "for all agents"}`);
771
+ return { removed: true, unsubscribed };
772
+ }
773
+ /** The sources that were set up for one conversation of an agent. */
774
+ boundTo(agentId, session) {
775
+ return Object.entries(this.cfg.agents[agentId]?.sources ?? {}).filter(([, s]) => s.session === session).map(([name]) => name);
776
+ }
777
+ /** Agents were added or removed: who a subscription for everyone reaches has changed. */
778
+ agentsChanged() {
779
+ this.wireGlobal();
780
+ }
781
+ /** The keys of a subscription that are written to a file. */
782
+ clean(name, raw) {
783
+ return { source: name, ...Object.fromEntries(Object.entries(raw).filter(([k, v]) => ["note", "when", "debounce", "throttle", "session", "separate", "prompt"].includes(k) && v !== undefined && v !== "" && v !== false && !(k === "session" && v === "main"))) };
784
+ }
785
+ same(sub) {
786
+ return (x) => {
787
+ try {
788
+ const other = subscriptionFrom(x);
789
+ return other.source === sub.source && other.session === sub.session;
790
+ }
791
+ catch {
792
+ return false;
793
+ }
794
+ };
795
+ }
796
+ /**
797
+ * Subscribe one conversation of an agent to a source and keep that in its agent.json, so it
798
+ * holds across restarts. `raw` is what goes into the file: session (default "main", which is the
799
+ * agent as such), note, when, debounce, throttle, separate, prompt. Subscribing the same
800
+ * conversation again replaces its entry.
801
+ */
802
+ async subscribe(agentId, name, raw = {}) {
803
+ const agent = this.cfg.agents[agentId];
804
+ if (!agent)
805
+ throw new Error(`unknown agent "${agentId}"`);
806
+ const found = sourceFor(this.cfg, agentId, name);
807
+ if (!found)
808
+ throw new Error(`"${name}" is not an event source ${agentId} can use. Available: ${this.available(agentId).map((s) => s.name).join(", ") || "none"}`);
809
+ if (!found.source.enabled)
810
+ throw new Error(`the source "${name}" is disabled in the configuration`);
811
+ const clean = this.clean(name, raw);
812
+ const sub = subscriptionFrom(clean);
813
+ if (found.source.session && found.source.session !== sub.session)
814
+ throw new Error(`the source "${name}" was set up for the conversation "${found.source.session}" of ${agentId} alone`);
815
+ editJson(path.join(agent.dir, "agent.json"), (json) => {
816
+ json.subscribe = [...rawSubscriptions(json.subscribe).filter((x) => !this.same(sub)(x)), clean];
817
+ });
818
+ agent.subscribe = [...agent.subscribe.filter((x) => !(x.source === sub.source && x.session === sub.session)), sub];
819
+ this.router.add(this.route(agentId, found.key, found.source, sub));
820
+ this.wireGlobal();
821
+ await this.ensure(found.key);
822
+ this.log.info(`${agentId} subscribed its conversation "${sub.session}" to ${found.key}`);
823
+ return { ...this.started(found.key), session: sub.session };
824
+ }
825
+ /** Subscribe every agent that can use a source of pine.json: each is woken in its main conversation. Kept in pine.json. */
826
+ async subscribeAll(name, raw = {}) {
827
+ const source = this.cfg.sources[name];
828
+ if (!source)
829
+ throw new Error(`"${name}" is not a source in pine.json. A subscription for all agents needs a source that all agents can use`);
830
+ if (!source.enabled)
831
+ throw new Error(`the source "${name}" is disabled in the configuration`);
832
+ const { session: _session, ...rest } = raw;
833
+ const clean = this.clean(name, rest);
834
+ const sub = subscriptionFrom(clean);
835
+ editJson(path.join(this.cfg.home, "pine.json"), (json) => {
836
+ json.subscribe = [...rawSubscriptions(json.subscribe).filter((x) => x.source !== name), clean];
837
+ });
838
+ this.cfg.subscribe = [...this.cfg.subscribe.filter((x) => x.source !== name), sub];
839
+ this.wireGlobal();
840
+ await this.ensure(name);
841
+ this.log.info(`all agents are subscribed to ${name}`);
842
+ return { ...this.started(name), agents: this.reached(sub) };
843
+ }
844
+ started(key) {
845
+ const info = this.list().find((s) => s.topic === `source/${key}`);
846
+ if (info.error)
847
+ throw new Error(`subscribed, but the source could not start: ${info.error}`);
848
+ return info;
849
+ }
850
+ /** End the subscription of one conversation, or with no `session` of all of that agent's conversations. Returns the conversations that were subscribed. */
851
+ async unsubscribe(agentId, name, session) {
852
+ const agent = this.cfg.agents[agentId];
853
+ if (!agent)
854
+ return [];
855
+ const gone = agent.subscribe.filter((x) => x.source === name && (session === undefined || x.session === session));
856
+ if (!gone.length)
857
+ return [];
858
+ const found = sourceFor(this.cfg, agentId, name);
859
+ editJson(path.join(agent.dir, "agent.json"), (json) => {
860
+ json.subscribe = rawSubscriptions(json.subscribe).filter((x) => !gone.some((g) => this.same(g)(x)));
861
+ if (!json.subscribe.length)
862
+ delete json.subscribe;
863
+ });
864
+ agent.subscribe = agent.subscribe.filter((x) => !gone.includes(x));
865
+ for (const g of gone)
866
+ this.router.remove(this.routeName(agentId, g));
867
+ this.wireGlobal();
868
+ if (found)
869
+ await this.settle(found.key);
870
+ this.log.info(`${agentId} unsubscribed ${gone.map((g) => `"${g.session}"`).join(", ")} from ${found?.key ?? name}`);
871
+ return gone.map((g) => g.session);
872
+ }
873
+ async unsubscribeAll(name) {
874
+ if (!this.cfg.subscribe.some((x) => x.source === name))
875
+ return false;
876
+ editJson(path.join(this.cfg.home, "pine.json"), (json) => {
877
+ json.subscribe = rawSubscriptions(json.subscribe).filter((x) => x.source !== name);
878
+ if (!json.subscribe.length)
879
+ delete json.subscribe;
880
+ });
881
+ this.cfg.subscribe = this.cfg.subscribe.filter((x) => x.source !== name);
882
+ this.wireGlobal();
883
+ await this.settle(name);
884
+ this.log.info(`the subscription of all agents to ${name} ended`);
885
+ return true;
886
+ }
887
+ /**
888
+ * A conversation was closed or reset: what it was subscribed to on its own goes with it. An
889
+ * agent's main conversation is the agent as such and keeps its subscriptions.
890
+ */
891
+ async closeSession(agentId, session) {
892
+ const agent = this.cfg.agents[agentId];
893
+ if (!agent || session === "main")
894
+ return [];
895
+ const names = [...new Set(agent.subscribe.filter((x) => x.session === session).map((x) => x.source))];
896
+ for (const name of names)
897
+ await this.unsubscribe(agentId, name, session);
898
+ return names;
899
+ }
900
+ /** A conversation got another name: what it was subscribed to on its own follows it. */
901
+ async renameSession(agentId, from, to) {
902
+ const agent = this.cfg.agents[agentId];
903
+ if (!agent)
904
+ return;
905
+ const file = path.join(agent.dir, "agent.json");
906
+ // Sources that were set up for it are now set up for the new name.
907
+ const bound = this.boundTo(agentId, from);
908
+ if (bound.length) {
909
+ editJson(file, (json) => {
910
+ for (const name of bound)
911
+ if (json.sources?.[name])
912
+ json.sources[name].session = to;
913
+ });
914
+ for (const name of bound)
915
+ agent.sources[name].session = to;
916
+ }
917
+ if (!agent.subscribe.some((x) => x.session === from))
918
+ return;
919
+ const written = rawSubscriptions(JSON.parse(fs.readFileSync(file, "utf8")).subscribe).filter((x) => {
920
+ try {
921
+ return subscriptionFrom(x).session === from;
922
+ }
923
+ catch {
924
+ return false;
925
+ }
926
+ });
927
+ for (const raw of written) {
928
+ await this.unsubscribe(agentId, raw.source, from);
929
+ await this.subscribe(agentId, raw.source, { ...raw, session: to });
930
+ }
931
+ }
932
+ /** Stop a source that nobody listens to any more. */
933
+ async settle(key) {
934
+ const source = this.all().find((x) => x.key === key)?.source;
935
+ if (source && source.start !== "always" && !this.subscribersOf(key).length)
936
+ await this.halt(key);
937
+ }
938
+ }
939
+ /** A type's options in one line, for a tool description or an error: name, whether it is needed, what it is. */
940
+ export function describeOptions(schema) {
941
+ const required = schema?.required ?? [];
942
+ const props = Object.entries(schema?.properties ?? {});
943
+ if (!props.length)
944
+ return "no options";
945
+ // A secret is never the agent's to ask for or to pass on.
946
+ return props.map(([key, p]) => (p.secret === true ? `${key} (a secret${required.includes(key) ? ", required: leave it out and pine asks your owner for it" : `: name it in "ask" if one is needed`}): ${p.description ?? "a secret"}` : `${key}${required.includes(key) ? " (required)" : ""}: ${p.description ?? p.type ?? "a value"}`)).join("; ");
947
+ }
948
+ /** The required options with placeholder values, to show what a call looks like. */
949
+ function example(schema) {
950
+ return Object.fromEntries((schema?.required ?? []).map((key) => [key, schema.properties?.[key]?.type === "number" ? 0 : "..."]));
951
+ }
952
+ /** The options without the values of secret ones, for a line a person reads. */
953
+ function redact(schema, options) {
954
+ return Object.fromEntries(Object.entries(options).map(([key, value]) => [key, schema?.properties?.[key]?.secret === true ? "***" : value]));
955
+ }