@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/timers.js ADDED
@@ -0,0 +1,318 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadConfig } from "./config.js";
4
+ import { editJson } from "./mcp-config.js";
5
+ import { errText } from "./util.js";
6
+ function parseField(expr, min, max) {
7
+ if (expr === "*")
8
+ return null;
9
+ const out = new Set();
10
+ for (const part of expr.split(",")) {
11
+ const m = part.match(/^(\*|\d+)(?:-(\d+))?(?:\/(\d+))?$/);
12
+ if (!m)
13
+ throw new Error(`invalid cron field "${expr}"`);
14
+ const step = m[3] ? Number(m[3]) : 1;
15
+ const from = m[1] === "*" ? min : Number(m[1]);
16
+ const to = m[1] === "*" ? max : m[2] !== undefined ? Number(m[2]) : m[3] ? max : from;
17
+ if (from < min || to > max || from > to || step < 1)
18
+ throw new Error(`cron field "${expr}" out of range ${min}-${max}`);
19
+ for (let v = from; v <= to; v += step)
20
+ out.add(v);
21
+ }
22
+ return out;
23
+ }
24
+ /** Five fields: minute hour day-of-month month day-of-week (0 or 7 is Sunday). Local time. */
25
+ export function parseCron(expr) {
26
+ const f = expr.trim().split(/\s+/);
27
+ if (f.length !== 5)
28
+ throw new Error(`cron expression needs 5 fields: "${expr}"`);
29
+ const minute = parseField(f[0], 0, 59);
30
+ const hour = parseField(f[1], 0, 23);
31
+ const dom = parseField(f[2], 1, 31);
32
+ const month = parseField(f[3], 1, 12);
33
+ const dowRaw = parseField(f[4], 0, 7);
34
+ const dow = dowRaw && new Set([...dowRaw].map((d) => d % 7));
35
+ return (d) => {
36
+ if (minute && !minute.has(d.getMinutes()))
37
+ return false;
38
+ if (hour && !hour.has(d.getHours()))
39
+ return false;
40
+ if (month && !month.has(d.getMonth() + 1))
41
+ return false;
42
+ const domOk = !dom || dom.has(d.getDate());
43
+ const dowOk = !dow || dow.has(d.getDay());
44
+ // As in cron: when both day fields are restricted, either may match.
45
+ return dom && dow ? domOk || dowOk : domOk && dowOk;
46
+ };
47
+ }
48
+ const minuteOf = (ms) => Math.floor(ms / 60_000);
49
+ /** A moment as it is written into a file and shown to people: local time, to the minute or second. */
50
+ export const localTime = (ms) => {
51
+ const d = new Date(ms);
52
+ const two = (n) => String(n).padStart(2, "0");
53
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}T${two(d.getHours())}:${two(d.getMinutes())}${d.getSeconds() ? `:${two(d.getSeconds())}` : ""}`;
54
+ };
55
+ const span = (ms) => (ms % 3_600_000 === 0 ? `${ms / 3_600_000}h` : ms % 60_000 === 0 ? `${ms / 60_000}m` : ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`);
56
+ /** When a schedule fires, in words. */
57
+ export const whenText = (s) => (s.cron ? `at the times "${s.cron}" (minute hour day month weekday)` : s.every ? `every ${span(s.every)}` : `once, at ${localTime(s.at).replace("T", " ")}`);
58
+ /**
59
+ * The scheduler: everything in pine that happens because of the time.
60
+ *
61
+ * A schedule fires at set times (cron), every so often, or once. Firing publishes an event on
62
+ * `timer/<name>` (`timer/<name>@<agent>` for an agent's own). A schedule with a task also wakes
63
+ * whoever it is for with that task: one conversation of an agent, an agent's main conversation,
64
+ * or every agent. Schedules are made by the owner, in the files or by asking an agent, so what
65
+ * they start counts as the owner's wish.
66
+ */
67
+ export class Scheduler {
68
+ cfg;
69
+ bus;
70
+ kv;
71
+ router;
72
+ log;
73
+ handles = [];
74
+ routes = [];
75
+ crons = new Map();
76
+ constructor(cfg, bus, kv, log, router) {
77
+ this.cfg = cfg;
78
+ this.bus = bus;
79
+ this.kv = kv;
80
+ this.log = log;
81
+ this.router = router;
82
+ }
83
+ /** Every schedule with its key: those for all agents by name, an agent's own as name@agent. */
84
+ all() {
85
+ return [...this.cfg.schedules.map((s) => ({ key: s.name, name: s.name, scope: "global", s })), ...Object.values(this.cfg.agents).flatMap((a) => a.schedules.map((s) => ({ key: `${s.name}@${a.id}`, name: s.name, scope: a.id, s })))];
86
+ }
87
+ match(expr) {
88
+ let m = this.crons.get(expr);
89
+ if (!m)
90
+ this.crons.set(expr, (m = parseCron(expr)));
91
+ return m;
92
+ }
93
+ start() {
94
+ const now = minuteOf(Date.now());
95
+ for (const e of this.all()) {
96
+ const last = this.kv.get(`timer:${e.key}`);
97
+ if (!e.s.cron || !e.s.catchUp || last === undefined)
98
+ continue;
99
+ for (let m = Math.max(last + 1, now - 7 * 24 * 60); m < now; m++) {
100
+ if (!this.match(e.s.cron)(new Date(m * 60_000)))
101
+ continue;
102
+ this.log.info(`schedule ${e.key}: catching up a run missed at ${new Date(m * 60_000).toISOString()}`);
103
+ this.fire(e, m * 60_000, true);
104
+ break;
105
+ }
106
+ }
107
+ this.wire();
108
+ this.arm();
109
+ }
110
+ stop() {
111
+ for (const h of this.handles)
112
+ clearTimeout(h);
113
+ this.handles = [];
114
+ for (const name of this.routes)
115
+ this.router?.remove(name);
116
+ this.routes = [];
117
+ }
118
+ /** A schedule with a task wakes whoever it is for: the routes that do so, rebuilt whenever a schedule or the list of agents changes. */
119
+ wire() {
120
+ if (!this.router)
121
+ return;
122
+ for (const name of this.routes)
123
+ this.router.remove(name);
124
+ this.routes = [];
125
+ for (const e of this.all()) {
126
+ if (!e.s.task)
127
+ continue;
128
+ for (const agent of e.scope === "global" ? Object.keys(this.cfg.agents) : [e.scope]) {
129
+ const route = {
130
+ name: `schedule:${agent}:${e.key}`,
131
+ on: [e.s.topic],
132
+ agent,
133
+ session: e.s.session ?? "main",
134
+ // The task as it was written, and a word on it when it comes late. Not a template of its own: braces in a task stay braces.
135
+ prompt: "{{payload.task}}{{payload.note}}",
136
+ preface: `[It is time for your schedule "${e.name}" (${whenText(e.s)}). It was set up at your owner's wish, and this is what it says to do now. If there is nothing to do or to tell, say so in one line.]`,
137
+ label: `schedule: ${e.name}`,
138
+ trust: "owner",
139
+ };
140
+ this.router.add(route);
141
+ this.routes.push(route.name);
142
+ }
143
+ }
144
+ }
145
+ /** Set the clocks: one look every quarter minute for the cron times, an interval for each "every", a timeout for each "at". */
146
+ arm() {
147
+ for (const h of this.handles)
148
+ clearTimeout(h);
149
+ this.handles = [];
150
+ const entries = this.all();
151
+ const tick = () => {
152
+ const now = Date.now();
153
+ for (const e of this.all()) {
154
+ if (!e.s.cron || this.kv.get(`timer:${e.key}`) === minuteOf(now) || !this.match(e.s.cron)(new Date(now)))
155
+ continue;
156
+ this.fire(e, now, false);
157
+ }
158
+ };
159
+ if (entries.some((e) => e.s.cron)) {
160
+ tick();
161
+ this.handles.push(setInterval(tick, 15_000));
162
+ }
163
+ for (const e of entries.filter((x) => x.s.every))
164
+ this.handles.push(setInterval(() => this.fire(e, Date.now(), false), e.s.every));
165
+ for (const e of entries.filter((x) => x.s.at !== undefined)) {
166
+ const due = () => {
167
+ const wait = e.s.at - Date.now();
168
+ // A timeout cannot be longer than about 24 days; one further away is looked at again then.
169
+ if (wait > 2 ** 31 - 1)
170
+ return void this.handles.push(setTimeout(due, 2 ** 31 - 1).unref());
171
+ this.handles.push(setTimeout(() => {
172
+ if (!this.all().some((x) => x.key === e.key && x.s === e.s))
173
+ return;
174
+ // More than a minute after its time: pine was not running then.
175
+ this.fire(e, e.s.at, Date.now() - e.s.at > 60_000);
176
+ this.drop(e.scope === "global" ? undefined : e.scope, e.name);
177
+ }, Math.max(0, wait)).unref());
178
+ };
179
+ due();
180
+ }
181
+ for (const h of this.handles)
182
+ h.unref?.();
183
+ }
184
+ fire(e, at, late) {
185
+ const s = e.s;
186
+ this.kv.set(`timer:${e.key}`, minuteOf(Date.now()));
187
+ const extra = s.payload && typeof s.payload === "object" && !Array.isArray(s.payload) ? s.payload : s.payload !== undefined ? { value: s.payload } : {};
188
+ const note = late ? `\n\n(This was due at ${localTime(at).replace("T", " ")} and comes late: pine was not running then.)` : undefined;
189
+ this.bus.publish(s.topic, { name: s.name, scheduled: new Date(at).toISOString(), late: late || undefined, ...(s.task ? { task: s.task, note } : {}), ...extra }, { source: "timer", trust: "owner" });
190
+ }
191
+ /** The next time a schedule fires. For an interval, counted from now. */
192
+ next(e) {
193
+ if (e.s.at !== undefined)
194
+ return e.s.at;
195
+ if (e.s.every)
196
+ return Date.now() + e.s.every;
197
+ const match = this.match(e.s.cron);
198
+ const start = minuteOf(Date.now()) + 1;
199
+ // Far enough for anything a cron line can say, leap days aside.
200
+ for (let m = start; m < start + 366 * 24 * 60; m++)
201
+ if (match(new Date(m * 60_000)))
202
+ return m * 60_000;
203
+ return undefined;
204
+ }
205
+ info(e) {
206
+ return { name: e.name, scope: e.scope, session: e.scope === "global" ? undefined : (e.s.session ?? "main"), when: whenText(e.s), task: e.s.task, next: this.next(e), topic: e.s.topic };
207
+ }
208
+ list() {
209
+ return this.all().map((e) => this.info(e));
210
+ }
211
+ /** What one agent has: its own schedules, in whichever conversation, and those for all agents that carry a task. */
212
+ available(agentId) {
213
+ return this.all().filter((e) => e.scope === agentId || (e.scope === "global" && e.s.task)).map((e) => this.info(e));
214
+ }
215
+ /** Check a schedule that is to be added, before anybody is asked: its name, and that its times can be read. Returns it as it goes into the file. */
216
+ check(raw) {
217
+ const name = String(raw.name ?? "").trim();
218
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(name))
219
+ throw new Error(`"${name}" is not a usable name: lowercase letters, digits, - and _, starting with a letter (for example morning, dentist)`);
220
+ if (this.all().some((e) => e.name === name))
221
+ throw new Error(`a schedule named "${name}" exists already; pick another name, or remove that one first`);
222
+ const task = String(raw.task ?? "").trim();
223
+ if (!task)
224
+ throw new Error(`a schedule needs a "task": what you are to do when it is time, written so that it is enough on its own`);
225
+ if (task.length > 4000)
226
+ throw new Error("that task is too long for a schedule (4000 characters at most). Write the procedure down as a skill and name it in the task");
227
+ if (raw.cron)
228
+ parseCron(raw.cron);
229
+ return { name, ...(raw.cron ? { cron: raw.cron.trim() } : {}), ...(raw.every ? { every: raw.every } : {}), ...(raw.at !== undefined ? { at: typeof raw.at === "number" ? localTime(raw.at) : raw.at } : {}), task, ...(raw.session && raw.session !== "main" ? { session: raw.session } : {}) };
230
+ }
231
+ file = (agentId) => (agentId ? path.join(this.cfg.agents[agentId].dir, "agent.json") : path.join(this.cfg.home, "pine.json"));
232
+ /**
233
+ * Add a schedule while pine runs: to one agent's agent.json, or to pine.json for all agents.
234
+ * The file is written and read back through the normal loader, so it is checked like one that
235
+ * was there at the start; if it does not pass, the file is put back as it was.
236
+ */
237
+ add(agentId, entry) {
238
+ const agent = agentId ? this.cfg.agents[agentId] : undefined;
239
+ if (agentId && !agent)
240
+ throw new Error(`unknown agent "${agentId}"`);
241
+ const file = this.file(agentId);
242
+ const before = fs.readFileSync(file, "utf8");
243
+ editJson(file, (json) => {
244
+ json.schedules = [...(Array.isArray(json.schedules) ? json.schedules : []), entry];
245
+ });
246
+ let fresh;
247
+ try {
248
+ fresh = loadConfig(this.cfg.home);
249
+ }
250
+ catch (e) {
251
+ fs.writeFileSync(file, before);
252
+ throw new Error(errText(e).replace(/^[\s\S]*?schedules\[\d+\]: /, ""));
253
+ }
254
+ const list = agent ? fresh.agents[agent.id].schedules : fresh.schedules;
255
+ const made = list.find((s) => s.name === entry.name);
256
+ if (agent)
257
+ agent.schedules.push(made);
258
+ else
259
+ this.cfg.schedules.push(made);
260
+ this.wire();
261
+ this.arm();
262
+ this.log.info(`schedule ${made.name} added ${agent ? `for ${agent.id}${made.session ? `/${made.session}` : ""}` : "for all agents"} (${whenText(made)})`);
263
+ return this.info({ key: agent ? `${made.name}@${agent.id}` : made.name, name: made.name, scope: agent?.id ?? "global", s: made });
264
+ }
265
+ /** Take a schedule out of the configuration, leaving the file as it was before it was added. */
266
+ drop(agentId, name) {
267
+ const agent = agentId ? this.cfg.agents[agentId] : undefined;
268
+ const list = agent ? agent.schedules : this.cfg.schedules;
269
+ const at = list.findIndex((s) => s.name === name);
270
+ if (at < 0)
271
+ return false;
272
+ list.splice(at, 1);
273
+ editJson(this.file(agentId), (json) => {
274
+ json.schedules = (Array.isArray(json.schedules) ? json.schedules : []).filter((s) => s?.name !== name);
275
+ if (!json.schedules.length)
276
+ delete json.schedules;
277
+ });
278
+ this.kv.set(`timer:${agent ? `${name}@${agent.id}` : name}`, null);
279
+ this.wire();
280
+ return true;
281
+ }
282
+ remove(agentId, name) {
283
+ const done = this.drop(agentId, name);
284
+ if (done) {
285
+ this.arm();
286
+ this.log.info(`schedule ${name} removed ${agentId ? `from ${agentId}` : "for all agents"}`);
287
+ }
288
+ return done;
289
+ }
290
+ /** A conversation was removed: what was scheduled for it alone goes with it. */
291
+ closeSession(agentId, session) {
292
+ const names = (this.cfg.agents[agentId]?.schedules ?? []).filter((s) => s.session === session).map((s) => s.name);
293
+ for (const name of names)
294
+ this.drop(agentId, name);
295
+ if (names.length)
296
+ this.arm();
297
+ return names;
298
+ }
299
+ /** A conversation got another name: its schedules follow it. */
300
+ renameSession(agentId, from, to) {
301
+ const agent = this.cfg.agents[agentId];
302
+ if (!agent?.schedules.some((s) => s.session === from))
303
+ return;
304
+ editJson(this.file(agentId), (json) => {
305
+ for (const s of Array.isArray(json.schedules) ? json.schedules : [])
306
+ if (s?.session === from)
307
+ s.session = to;
308
+ });
309
+ for (const s of agent.schedules)
310
+ if (s.session === from)
311
+ s.session = to;
312
+ this.wire();
313
+ }
314
+ /** Agents were added or removed: whom a schedule for all agents wakes has changed. */
315
+ agentsChanged() {
316
+ this.wire();
317
+ }
318
+ }
package/src/tools.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type AgentConfig } from "./config.ts";
2
+ import type { AgentManager, Live, Profile } from "./agents.ts";
3
+ /** Topics only the daemon itself may publish on. */
4
+ export declare const RESERVED_TOPICS: string[];
5
+ /** The tools pine adds to a session, according to what this run's profile allows. */
6
+ export declare function pineTools(manager: AgentManager, agent: AgentConfig, live: Live, profile: Profile): any[];