@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
package/src/skills.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type AgentConfig, type PineConfig } from "./config.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Skills: what an agent knows how to do, written down. A skill is a folder with a SKILL.md (a
|
|
4
|
+
* name, a line on when to use it, and the instructions) and whatever files those instructions
|
|
5
|
+
* refer to. The agent sees name and line in its prompt and reads the rest when a task calls for it.
|
|
6
|
+
*
|
|
7
|
+
* Like event sources and MCP servers, a skill is for one conversation, for one agent or for all
|
|
8
|
+
* agents. Unlike them it needs no entry in a configuration file: where the folder lies says whom
|
|
9
|
+
* it is for.
|
|
10
|
+
*
|
|
11
|
+
* <home>/skills/<name>/ all agents
|
|
12
|
+
* <home>/agents/<id>/skills/<name>/ that agent, in all its conversations
|
|
13
|
+
* <home>/agents/<id>/conversations/<session>/skills/<name>/ that one conversation
|
|
14
|
+
*
|
|
15
|
+
* All three lie in pine's home, where an agent can read its own but write nothing: a skill is
|
|
16
|
+
* part of an agent's instructions, and is written by pine when a tool asks for it.
|
|
17
|
+
*/
|
|
18
|
+
export type SkillScope = "local" | "agent" | "global";
|
|
19
|
+
export interface SkillInfo {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
scope: SkillScope;
|
|
23
|
+
dir: string;
|
|
24
|
+
/** Put there by pine's tools, so the tools may also replace and remove it. The owner's own folders (`skills` in a configuration file) are not. */
|
|
25
|
+
managed: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare const localSkillsDir: (agent: AgentConfig, session: string) => string;
|
|
28
|
+
/** The folders an agent's session reads skills from, beyond what its configuration names. */
|
|
29
|
+
export declare function skillDirs(cfg: PineConfig, agent: AgentConfig, session: string): string[];
|
|
30
|
+
/** The skills one conversation of an agent has, the nearest first: its own, the agent's, those for all agents. */
|
|
31
|
+
export declare function listSkills(cfg: PineConfig, agent: AgentConfig, session: string): SkillInfo[];
|
|
32
|
+
/**
|
|
33
|
+
* Every skill that one of a scope would stand beside: for a conversation what it sees, for an
|
|
34
|
+
* agent also what its other conversations have, for all agents everything there is. A name is
|
|
35
|
+
* used once among those, so that no conversation ever has two skills of one name.
|
|
36
|
+
*/
|
|
37
|
+
export declare function skillsBeside(cfg: PineConfig, agent: AgentConfig, scope: SkillScope, session: string): SkillInfo[];
|
|
38
|
+
/** Every skill there is, with whom it is for: for whoever looks at the whole setup. */
|
|
39
|
+
export declare function allSkills(cfg: PineConfig): Array<SkillInfo & {
|
|
40
|
+
agent?: string;
|
|
41
|
+
session?: string;
|
|
42
|
+
}>;
|
|
43
|
+
export interface SkillSpec {
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
instructions: string;
|
|
47
|
+
/** A folder whose files go along with the skill: scripts, references, templates. */
|
|
48
|
+
files?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Check what a skill is to be before anybody is asked anything. Returns it tidied, with the files that would be copied. */
|
|
51
|
+
export declare function checkSkill(spec: SkillSpec, workspace: string): SkillSpec & {
|
|
52
|
+
copy: Array<{
|
|
53
|
+
from: string;
|
|
54
|
+
to: string;
|
|
55
|
+
}>;
|
|
56
|
+
workspace: string;
|
|
57
|
+
};
|
|
58
|
+
/** Where a skill of a scope lies. */
|
|
59
|
+
export declare function skillDir(cfg: PineConfig, agent: AgentConfig, scope: SkillScope, session: string, name: string): string;
|
|
60
|
+
/** Write a skill, in place of one of the same name in the same place. */
|
|
61
|
+
export declare function writeSkill(dir: string, skill: ReturnType<typeof checkSkill>): void;
|
|
62
|
+
/** Delete a skill's folder, and the folders above it that it leaves empty, up to the agent's or pine's own. */
|
|
63
|
+
export declare function deleteSkill(dir: string, stop: string): void;
|
|
64
|
+
/** A conversation is removed: the skills that were its own go with it. */
|
|
65
|
+
export declare function dropConversationSkills(agent: AgentConfig, session: string): string[];
|
|
66
|
+
/** A conversation got another name: its skills follow it. */
|
|
67
|
+
export declare function renameConversationSkills(agent: AgentConfig, from: string, to: string): void;
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadSkillsFromDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { agentSkillsDir, conversationsDir, globalSkillsDir } from "./config.js";
|
|
5
|
+
import { isInside, realish } from "./security.js";
|
|
6
|
+
import { safeName } from "./util.js";
|
|
7
|
+
const MAX_TEXT = 40_000;
|
|
8
|
+
const MAX_FILES = 200;
|
|
9
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
export const localSkillsDir = (agent, session) => path.join(conversationsDir(agent), safeName(session), "skills");
|
|
11
|
+
/** The folders an agent's session reads skills from, beyond what its configuration names. */
|
|
12
|
+
export function skillDirs(cfg, agent, session) {
|
|
13
|
+
return [globalSkillsDir(cfg), ...(session === "main" ? [] : [localSkillsDir(agent, session)])].filter((d) => fs.existsSync(d));
|
|
14
|
+
}
|
|
15
|
+
const read = (dir, scope, managed) => fs.existsSync(dir) ? loadSkillsFromDir({ dir, source: scope }).skills.map((s) => ({ name: s.name, description: s.description, scope, dir: s.baseDir, managed })) : [];
|
|
16
|
+
/** The skills one conversation of an agent has, the nearest first: its own, the agent's, those for all agents. */
|
|
17
|
+
export function listSkills(cfg, agent, session) {
|
|
18
|
+
const out = [
|
|
19
|
+
...(session === "main" ? [] : read(localSkillsDir(agent, session), "local", true)),
|
|
20
|
+
...read(agentSkillsDir(agent), "agent", true),
|
|
21
|
+
...agent.skills.flatMap((d) => read(d, "agent", false)),
|
|
22
|
+
...read(globalSkillsDir(cfg), "global", true),
|
|
23
|
+
...cfg.skills.filter((d) => realish(d) !== realish(globalSkillsDir(cfg))).flatMap((d) => read(d, "global", false)),
|
|
24
|
+
];
|
|
25
|
+
return out.filter((s, i) => out.findIndex((x) => x.name === s.name) === i);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Every skill that one of a scope would stand beside: for a conversation what it sees, for an
|
|
29
|
+
* agent also what its other conversations have, for all agents everything there is. A name is
|
|
30
|
+
* used once among those, so that no conversation ever has two skills of one name.
|
|
31
|
+
*/
|
|
32
|
+
export function skillsBeside(cfg, agent, scope, session) {
|
|
33
|
+
const locals = (a) => (fs.existsSync(conversationsDir(a)) ? fs.readdirSync(conversationsDir(a)).flatMap((s) => read(path.join(conversationsDir(a), s, "skills"), "local", true)) : []);
|
|
34
|
+
if (scope === "local")
|
|
35
|
+
return listSkills(cfg, agent, session);
|
|
36
|
+
if (scope === "agent")
|
|
37
|
+
return [...locals(agent), ...listSkills(cfg, agent, "main")];
|
|
38
|
+
return Object.values(cfg.agents).flatMap((a) => [...locals(a), ...listSkills(cfg, a, "main")]);
|
|
39
|
+
}
|
|
40
|
+
/** Every skill there is, with whom it is for: for whoever looks at the whole setup. */
|
|
41
|
+
export function allSkills(cfg) {
|
|
42
|
+
const global = [...read(globalSkillsDir(cfg), "global", true), ...cfg.skills.filter((d) => realish(d) !== realish(globalSkillsDir(cfg))).flatMap((d) => read(d, "global", false))];
|
|
43
|
+
const perAgent = Object.values(cfg.agents).flatMap((a) => [
|
|
44
|
+
...[...read(agentSkillsDir(a), "agent", true), ...a.skills.flatMap((d) => read(d, "agent", false))].map((s) => ({ ...s, agent: a.id })),
|
|
45
|
+
...(fs.existsSync(conversationsDir(a)) ? fs.readdirSync(conversationsDir(a)) : []).flatMap((session) => read(path.join(conversationsDir(a), session, "skills"), "local", true).map((s) => ({ ...s, agent: a.id, session }))),
|
|
46
|
+
]);
|
|
47
|
+
return [...global, ...perAgent];
|
|
48
|
+
}
|
|
49
|
+
/** Check what a skill is to be before anybody is asked anything. Returns it tidied, with the files that would be copied. */
|
|
50
|
+
export function checkSkill(spec, workspace) {
|
|
51
|
+
const name = String(spec.name ?? "").trim();
|
|
52
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) || name.length > 64)
|
|
53
|
+
throw new Error(`"${name}" is not a usable skill name: lowercase letters, digits and single hyphens, at most 64 characters (for example invoices, solar-yield)`);
|
|
54
|
+
if (name === "pine")
|
|
55
|
+
throw new Error('"pine" is the name of pine\'s own manual');
|
|
56
|
+
const description = String(spec.description ?? "").replace(/\s+/g, " ").trim();
|
|
57
|
+
if (!description)
|
|
58
|
+
throw new Error(`a skill needs a "description": one or two sentences on what it is for and when to use it. You decide by that line alone whether to read the skill`);
|
|
59
|
+
if (description.length > 1024)
|
|
60
|
+
throw new Error(`the description is too long (${description.length} characters, at most 1024). It says when to use the skill; how to do it goes into "instructions"`);
|
|
61
|
+
const instructions = String(spec.instructions ?? "").trim();
|
|
62
|
+
if (!instructions)
|
|
63
|
+
throw new Error(`a skill needs "instructions": what to do, step by step, written to yourself`);
|
|
64
|
+
if (instructions.length > MAX_TEXT)
|
|
65
|
+
throw new Error(`the instructions are too long (${instructions.length} characters, at most ${MAX_TEXT}). Put long material into files next to the skill ("files") and refer to them`);
|
|
66
|
+
const copy = [];
|
|
67
|
+
if (spec.files) {
|
|
68
|
+
const root = realish(path.resolve(workspace, spec.files));
|
|
69
|
+
if (!isInside(realish(workspace), root) || root === realish(workspace))
|
|
70
|
+
throw new Error(`"files" must be a folder inside your workspace`);
|
|
71
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory())
|
|
72
|
+
throw new Error(`${root} is not a folder that exists`);
|
|
73
|
+
let bytes = 0;
|
|
74
|
+
const walk = (dir) => {
|
|
75
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
76
|
+
const from = path.join(dir, entry.name);
|
|
77
|
+
// Only what is really there: a link could point at anything.
|
|
78
|
+
if (entry.isSymbolicLink())
|
|
79
|
+
throw new Error(`${from} is a link; a skill carries files, not links`);
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
if (entry.name === ".git" || entry.name === "node_modules")
|
|
82
|
+
continue;
|
|
83
|
+
walk(from);
|
|
84
|
+
}
|
|
85
|
+
else if (entry.isFile()) {
|
|
86
|
+
const to = path.relative(root, from);
|
|
87
|
+
if (to === "SKILL.md")
|
|
88
|
+
continue;
|
|
89
|
+
bytes += fs.statSync(from).size;
|
|
90
|
+
copy.push({ from, to });
|
|
91
|
+
if (copy.length > MAX_FILES || bytes > MAX_BYTES)
|
|
92
|
+
throw new Error(`${root} is too much for a skill (at most ${MAX_FILES} files and ${MAX_BYTES / 1024 / 1024} MB)`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(root);
|
|
97
|
+
}
|
|
98
|
+
return { name, description, instructions, copy, workspace: realish(workspace) };
|
|
99
|
+
}
|
|
100
|
+
/** Where a skill of a scope lies. */
|
|
101
|
+
export function skillDir(cfg, agent, scope, session, name) {
|
|
102
|
+
return path.join(scope === "global" ? globalSkillsDir(cfg) : scope === "agent" ? agentSkillsDir(agent) : localSkillsDir(agent, session), name);
|
|
103
|
+
}
|
|
104
|
+
/** Write a skill, in place of one of the same name in the same place. */
|
|
105
|
+
export function writeSkill(dir, skill) {
|
|
106
|
+
// Built beside it and swapped in, so that a half-written skill is never read.
|
|
107
|
+
const fresh = `${dir}.new-${process.pid}`;
|
|
108
|
+
fs.rmSync(fresh, { recursive: true, force: true });
|
|
109
|
+
fs.mkdirSync(fresh, { recursive: true, mode: 0o700 });
|
|
110
|
+
for (const f of skill.copy) {
|
|
111
|
+
fs.mkdirSync(path.dirname(path.join(fresh, f.to)), { recursive: true });
|
|
112
|
+
// Between the check and now the owner may have been asked: what is copied is looked at again,
|
|
113
|
+
// and a file that has since become a link, or leads out of the workspace, is not followed.
|
|
114
|
+
let fd;
|
|
115
|
+
try {
|
|
116
|
+
if (!isInside(skill.workspace, realish(f.from)))
|
|
117
|
+
throw new Error("it leads out of your workspace");
|
|
118
|
+
fd = fs.openSync(f.from, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
119
|
+
if (!fs.fstatSync(fd).isFile())
|
|
120
|
+
throw new Error("it is not a file");
|
|
121
|
+
fs.writeFileSync(path.join(fresh, f.to), fs.readFileSync(fd));
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
fs.rmSync(fresh, { recursive: true, force: true });
|
|
125
|
+
throw new Error(`${f.from} changed while the skill was being added and is not taken: ${e instanceof Error ? e.message : e}`);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (fd !== undefined)
|
|
129
|
+
fs.closeSync(fd);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// The description as a quoted string, which is the same in JSON and in YAML: a colon in it cannot break the header.
|
|
133
|
+
fs.writeFileSync(path.join(fresh, "SKILL.md"), `---\nname: ${skill.name}\ndescription: ${JSON.stringify(skill.description)}\n---\n\n${skill.instructions}\n`);
|
|
134
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
135
|
+
fs.renameSync(fresh, dir);
|
|
136
|
+
}
|
|
137
|
+
/** Delete a skill's folder, and the folders above it that it leaves empty, up to the agent's or pine's own. */
|
|
138
|
+
export function deleteSkill(dir, stop) {
|
|
139
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
140
|
+
for (let up = path.dirname(dir); isInside(stop, up) && up !== stop; up = path.dirname(up)) {
|
|
141
|
+
if (!fs.existsSync(up) || fs.readdirSync(up).length)
|
|
142
|
+
break;
|
|
143
|
+
fs.rmdirSync(up);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** A conversation is removed: the skills that were its own go with it. */
|
|
147
|
+
export function dropConversationSkills(agent, session) {
|
|
148
|
+
const dir = localSkillsDir(agent, session);
|
|
149
|
+
const names = read(dir, "local", true).map((s) => s.name);
|
|
150
|
+
fs.rmSync(path.dirname(dir), { recursive: true, force: true });
|
|
151
|
+
if (fs.existsSync(conversationsDir(agent)) && !fs.readdirSync(conversationsDir(agent)).length)
|
|
152
|
+
fs.rmdirSync(conversationsDir(agent));
|
|
153
|
+
return names;
|
|
154
|
+
}
|
|
155
|
+
/** A conversation got another name: its skills follow it. */
|
|
156
|
+
export function renameConversationSkills(agent, from, to) {
|
|
157
|
+
const a = path.dirname(localSkillsDir(agent, from));
|
|
158
|
+
const b = path.dirname(localSkillsDir(agent, to));
|
|
159
|
+
if (!fs.existsSync(a))
|
|
160
|
+
return;
|
|
161
|
+
fs.rmSync(b, { recursive: true, force: true });
|
|
162
|
+
fs.renameSync(a, b);
|
|
163
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
/** What setting a source up would mean, worked out before anything is written. */
|
|
3
|
+
export interface SourcePlan {
|
|
4
|
+
/** The options as they go into the configuration: paths resolved, defaults that matter filled in, secrets made. */
|
|
5
|
+
options: Record<string, unknown>;
|
|
6
|
+
/** What it will do, in plain words, for the owner's dialog. */
|
|
7
|
+
does: string[];
|
|
8
|
+
/** It reaches something new (a folder elsewhere, a network connection, a port), so the owner is asked first. */
|
|
9
|
+
opens: boolean;
|
|
10
|
+
/** A folder the agent gets read access to along with the source. */
|
|
11
|
+
read?: string;
|
|
12
|
+
/** What the agent should pass on once it is set up (where a device finds its password, for example). */
|
|
13
|
+
tell?: string;
|
|
14
|
+
/**
|
|
15
|
+
* What it reports is the owner speaking, not outside content: the source itself makes sure of
|
|
16
|
+
* who is on the other end (a Telegram account the owner paired, for example). The owner is told
|
|
17
|
+
* so before they confirm.
|
|
18
|
+
*/
|
|
19
|
+
owner?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface SourceSetup {
|
|
22
|
+
/** The name the source will have. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** What it is called in events and addresses: its name, or name@agent for an agent's own source. */
|
|
25
|
+
key: string;
|
|
26
|
+
/** The agent it is set up by, and that agent's workspace. */
|
|
27
|
+
agent: string;
|
|
28
|
+
workspace: string;
|
|
29
|
+
/** pine's home folder, which no source may point into. */
|
|
30
|
+
home: string;
|
|
31
|
+
/** An absolute path for one an agent wrote: ~ is the user's home, anything relative is meant from the workspace. */
|
|
32
|
+
resolve(p: string): string;
|
|
33
|
+
/** A fresh secret of `bytes` random bytes, as letters and digits. */
|
|
34
|
+
random(bytes: number): string;
|
|
35
|
+
/** The name a secret option gets in pine's .env file, to tell the owner where to find it. */
|
|
36
|
+
envName(option: string): string;
|
|
37
|
+
}
|
|
38
|
+
export interface SourceType {
|
|
39
|
+
/** One line: what a source of this type reports and what it is for. */
|
|
40
|
+
summary: string;
|
|
41
|
+
/**
|
|
42
|
+
* Its options as a JSON schema of an object (typebox's Type.Object). Give each property a
|
|
43
|
+
* description; mark one that is a password or token with `secret: true`, and pine keeps its value
|
|
44
|
+
* in .env and out of the configuration, the event log and what models see.
|
|
45
|
+
*/
|
|
46
|
+
options: TSchema;
|
|
47
|
+
/** Check the options and say what the source would do. Throw for what cannot work. Without it, a set-up is always put to the owner. */
|
|
48
|
+
plan?(options: Record<string, any>, setup: SourceSetup): SourcePlan;
|
|
49
|
+
/**
|
|
50
|
+
* Its "message" events are somebody writing to the agent: the event's `text` is what they
|
|
51
|
+
* wrote, it reaches the agent as a message rather than as a report, and the agent's answer goes
|
|
52
|
+
* back through the source.
|
|
53
|
+
*/
|
|
54
|
+
talks?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* It can also send. One line for the agent on what sending through it does and what to pass,
|
|
57
|
+
* for example 'a Telegram message to your owner: {"text": "..."}'. The agent's `send` tool
|
|
58
|
+
* offers every source that says this.
|
|
59
|
+
*/
|
|
60
|
+
sends?: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { parseDuration } from "../util.js";
|
|
3
|
+
import { clip, found, harmless, lineDiff, pattern, shown } from "../watching.js";
|
|
4
|
+
/**
|
|
5
|
+
* A command, run again and again: reports when what it prints or how it ends changes. For what
|
|
6
|
+
* only a command can tell: free disk space, a build, a branch that moved, a log, whether a
|
|
7
|
+
* machine answers.
|
|
8
|
+
*
|
|
9
|
+
* options: { run, every?, match?, timeout? }
|
|
10
|
+
* run the command line, run by bash from the workspace of the agent the source belongs to
|
|
11
|
+
* every how often ("1m" by default)
|
|
12
|
+
* match watch only what this regular expression finds in what it prints
|
|
13
|
+
* timeout how long one run may take ("30s" by default)
|
|
14
|
+
*
|
|
15
|
+
* event "changed": { command, code, previousCode?, value, previous } with match
|
|
16
|
+
* { command, code, previousCode?, added, removed, more? } without
|
|
17
|
+
*
|
|
18
|
+
* The command runs in the sandbox of that agent's shell: it reaches what the agent reaches and
|
|
19
|
+
* nothing else. The first run is the starting point and reports nothing.
|
|
20
|
+
*/
|
|
21
|
+
const MIN = 5000;
|
|
22
|
+
export const type = {
|
|
23
|
+
summary: "a command run again and again: reports when what it prints, or how it ends (its exit code), changes. For what only a command can tell: free disk space, a build, a branch that moved, a log, whether a machine answers. It runs like a command in your own shell, with the same limits",
|
|
24
|
+
options: Type.Object({
|
|
25
|
+
run: Type.String({ description: 'the command line, run by bash from your workspace, e.g. "df -h /" or "git -C ~/dev/app rev-parse origin/main". One that only looks: it runs unattended, again and again' }),
|
|
26
|
+
every: Type.Optional(Type.String({ description: 'how often to run it, e.g. "1m", "1h". Default "1m", at least "5s"' })),
|
|
27
|
+
match: Type.Optional(Type.String({ description: 'watch only what this regular expression finds in what it prints (its first group, if it has one), e.g. "([0-9]+)% used". Without it everything it prints counts' })),
|
|
28
|
+
timeout: Type.Optional(Type.String({ description: 'how long one run may take before it is stopped. Default "30s"' })),
|
|
29
|
+
}, { additionalProperties: false }),
|
|
30
|
+
plan(o, setup) {
|
|
31
|
+
const run = String(o.run ?? "").trim();
|
|
32
|
+
if (!run)
|
|
33
|
+
throw new Error(`needs "run": the command line`);
|
|
34
|
+
if (run.length > 2000)
|
|
35
|
+
throw new Error("that command line is too long: put it into a script in your workspace and run that");
|
|
36
|
+
const every = o.every ?? "1m";
|
|
37
|
+
if (parseDuration(every) < MIN)
|
|
38
|
+
throw new Error('"every" must be at least "5s"');
|
|
39
|
+
if (o.timeout && parseDuration(o.timeout) < 1000)
|
|
40
|
+
throw new Error('"timeout" must be at least "1s"');
|
|
41
|
+
if (o.match)
|
|
42
|
+
pattern(o.match);
|
|
43
|
+
return {
|
|
44
|
+
options: { ...o, run },
|
|
45
|
+
does: [`run this command every ${every}, unattended, with the rights of ${setup.agent}'s shell (its sandbox): ${run}`, `report when ${o.match ? `what /${o.match}/ finds in what it prints` : "what it prints"} or its exit code changes`],
|
|
46
|
+
opens: true,
|
|
47
|
+
tell: "Its first run is the starting point and reports nothing; from then on only a change does. The sources tool shows how the last run went.",
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
export default function command(source) {
|
|
52
|
+
const o = source.options;
|
|
53
|
+
const run = typeof o.run === "string" ? o.run.trim() : "";
|
|
54
|
+
if (!run)
|
|
55
|
+
throw new Error(`needs "run": the command line`);
|
|
56
|
+
const re = o.match ? pattern(String(o.match)) : undefined;
|
|
57
|
+
const timeoutMs = parseDuration(o.timeout ?? "30s");
|
|
58
|
+
source.poll(o.every ?? "1m", async (signal) => {
|
|
59
|
+
const { code, output } = await source.exec(run, { timeoutMs, signal });
|
|
60
|
+
const text = output.replace(/\r\n/g, "\n").trimEnd().slice(0, 200_000);
|
|
61
|
+
if (re) {
|
|
62
|
+
const value = found(re, text);
|
|
63
|
+
source.status(`exit ${code}, ${value === null ? "found nothing with the pattern" : `now: ${harmless(value)}`}`);
|
|
64
|
+
const was = source.changed({ code, value });
|
|
65
|
+
if (was)
|
|
66
|
+
source.emit("changed", { command: clip(run, 300), code, value, previous: shown(was.previous.value), ...(was.previous.code !== code ? { previousCode: was.previous.code } : {}) });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
source.status(`exit ${code}, ${text ? text.split("\n").length : 0} lines`);
|
|
70
|
+
const was = source.changed({ code, text });
|
|
71
|
+
if (!was)
|
|
72
|
+
return;
|
|
73
|
+
const diff = lineDiff(String(was.previous.text ?? ""), text);
|
|
74
|
+
if (diff.added.length || diff.removed.length || was.previous.code !== code)
|
|
75
|
+
source.emit("changed", { command: clip(run, 300), code, ...(was.previous.code !== code ? { previousCode: was.previous.code } : {}), ...diff });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SourceType } from "../source-types.ts";
|
|
2
|
+
import type { SourceApi } from "../sources.ts";
|
|
3
|
+
/** A file name mask ("*.pdf", "*.{png,jpg}", "a?.txt, b*.md") as a test for a file's name. Capitals do not matter. */
|
|
4
|
+
export declare function maskTest(mask: string): RegExp;
|
|
5
|
+
export declare const type: SourceType;
|
|
6
|
+
export default function folder(source: SourceApi): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { isInside, realish } from "../security.js";
|
|
5
|
+
import { parseDuration, resolvePath } from "../util.js";
|
|
6
|
+
/**
|
|
7
|
+
* A folder: reports every new or changed file once it has stopped growing. For things that drop
|
|
8
|
+
* files: a scanner, a download folder, an export from another program.
|
|
9
|
+
*
|
|
10
|
+
* options: { dir, mask?, pattern?, ignore?, settle?, recursive? }
|
|
11
|
+
* dir the folder to watch (~ and paths relative to the configuration file work)
|
|
12
|
+
* mask only files whose name matches, as in a shell: "*.pdf", "*.{png,jpg}", "report-??.csv";
|
|
13
|
+
* several separated by commas
|
|
14
|
+
* pattern only files whose full path matches this regular expression, e.g. "/src/.*\\.ts$"
|
|
15
|
+
* ignore never files whose path matches this one. Default: version control, dependencies and
|
|
16
|
+
* editor leftovers (.git, node_modules, .DS_Store, files ending in ~ or .swp, and such)
|
|
17
|
+
* settle how long a file must stay unchanged before it counts as complete (default "1.5s")
|
|
18
|
+
* recursive also watch folders inside it (default true)
|
|
19
|
+
*
|
|
20
|
+
* event "added": { path, file, size, images? } images is set for jpg, png, webp and gif under 10 MB
|
|
21
|
+
*/
|
|
22
|
+
const TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif" };
|
|
23
|
+
/** A file name mask ("*.pdf", "*.{png,jpg}", "a?.txt, b*.md") as a test for a file's name. Capitals do not matter. */
|
|
24
|
+
export function maskTest(mask) {
|
|
25
|
+
const one = (glob) => glob
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/[.+^$()|[\]\\]/g, "\\$&")
|
|
28
|
+
.replace(/\*/g, "[^/]*")
|
|
29
|
+
.replace(/\?/g, "[^/]")
|
|
30
|
+
.replace(/\{([^{}]*)\}/g, (_m, list) => `(?:${list.split(",").map((x) => x.trim()).join("|")})`);
|
|
31
|
+
// Commas separate masks, except inside braces.
|
|
32
|
+
const parts = mask.split(/,(?![^{]*\})/).map(one).filter(Boolean);
|
|
33
|
+
return new RegExp(`^(?:${parts.join("|")})$`, "i");
|
|
34
|
+
}
|
|
35
|
+
export const type = {
|
|
36
|
+
summary: "a folder: reports every new or changed file in it. For a project folder, what a scanner saves, downloads, an export from another program",
|
|
37
|
+
options: Type.Object({
|
|
38
|
+
dir: Type.String({ description: "the folder to watch, absolute or starting with ~" }),
|
|
39
|
+
mask: Type.Optional(Type.String({ description: 'only files whose name matches, as in a shell: "*.pdf", "*.{png,jpg}"; several separated by commas' })),
|
|
40
|
+
pattern: Type.Optional(Type.String({ description: 'only files whose full path matches this regular expression, for what a mask cannot say, e.g. "/src/.*\\.ts$"' })),
|
|
41
|
+
ignore: Type.Optional(Type.String({ description: "never files whose path matches this regular expression. Default: version control, dependencies and editor leftovers (.git, node_modules, .DS_Store and such)" })),
|
|
42
|
+
recursive: Type.Optional(Type.Boolean({ description: "also watch the folders inside it. Default true" })),
|
|
43
|
+
settle: Type.Optional(Type.String({ description: 'how long a file must stay unchanged before it counts as complete. Default "1.5s"' })),
|
|
44
|
+
}, { additionalProperties: false }),
|
|
45
|
+
plan(o, setup) {
|
|
46
|
+
const dir = setup.resolve(o.dir);
|
|
47
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
48
|
+
throw new Error(`${dir} is not a folder that exists`);
|
|
49
|
+
// Its own workspace is nothing new; a folder elsewhere is, and reading its files comes with watching it.
|
|
50
|
+
const own = isInside(realish(setup.workspace), dir);
|
|
51
|
+
// The workspace usually lies inside pine's home; everything else in there is pine's own.
|
|
52
|
+
if (!own && (isInside(realish(setup.home), dir) || isInside(dir, realish(setup.home))))
|
|
53
|
+
throw new Error("pine's own folder cannot be watched");
|
|
54
|
+
for (const key of ["pattern", "ignore"])
|
|
55
|
+
if (o[key])
|
|
56
|
+
new RegExp(o[key]);
|
|
57
|
+
if (o.mask)
|
|
58
|
+
maskTest(o.mask);
|
|
59
|
+
if (o.settle)
|
|
60
|
+
parseDuration(o.settle);
|
|
61
|
+
const only = [o.mask ? `files named ${o.mask}` : "", o.pattern ? `paths matching ${o.pattern}` : ""].filter(Boolean).join(", ");
|
|
62
|
+
return { options: { ...o, dir }, does: [`watch the folder ${dir}${only ? ` (${only})` : ""} for new and changed files`, ...(own ? [] : [`let ${setup.agent} read the files in that folder`])], opens: !own, read: own ? undefined : dir };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
export default function folder(source) {
|
|
66
|
+
const o = source.options;
|
|
67
|
+
if (typeof o.dir !== "string" || !o.dir)
|
|
68
|
+
throw new Error(`needs "dir": the folder to watch`);
|
|
69
|
+
const dir = fs.realpathSync(resolvePath(o.dir, source.base));
|
|
70
|
+
if (!fs.statSync(dir).isDirectory())
|
|
71
|
+
throw new Error(`${dir} is not a folder`);
|
|
72
|
+
const pattern = o.pattern ? new RegExp(String(o.pattern), "i") : undefined;
|
|
73
|
+
const mask = typeof o.mask === "string" && o.mask.trim() ? maskTest(o.mask) : undefined;
|
|
74
|
+
// What changes all the time in a project folder without anyone having done anything worth a turn.
|
|
75
|
+
const ignore = new RegExp(typeof o.ignore === "string" ? o.ignore : "(^|/)(\\.git|\\.hg|\\.svn|node_modules|\\.build|DerivedData|__pycache__|\\.venv|\\.DS_Store)(/|$)|(~|\\.swp|\\.tmp|\\.part|\\.crdownload)$", "i");
|
|
76
|
+
const settleMs = parseDuration(o.settle ?? "1.5s");
|
|
77
|
+
const pending = new Map();
|
|
78
|
+
const seen = new Map();
|
|
79
|
+
source.media(dir);
|
|
80
|
+
const check = (file, lastSize) => {
|
|
81
|
+
pending.delete(file);
|
|
82
|
+
let stat;
|
|
83
|
+
try {
|
|
84
|
+
stat = fs.statSync(file);
|
|
85
|
+
// A link that leads out of the folder is not a file of this folder.
|
|
86
|
+
if (!stat.isFile() || !fs.realpathSync(file).startsWith(dir + path.sep))
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (stat.size !== lastSize || stat.size === 0)
|
|
93
|
+
return void pending.set(file, setTimeout(() => check(file, stat.size), settleMs));
|
|
94
|
+
if (seen.get(file) === stat.mtimeMs)
|
|
95
|
+
return;
|
|
96
|
+
seen.set(file, stat.mtimeMs);
|
|
97
|
+
if (seen.size > 5000)
|
|
98
|
+
for (const k of [...seen.keys()].slice(0, 2500))
|
|
99
|
+
seen.delete(k);
|
|
100
|
+
const mimeType = TYPES[path.extname(file).toLowerCase()];
|
|
101
|
+
source.emit("added", { path: file, file: path.relative(dir, file), size: stat.size, images: mimeType && stat.size < 10 * 1024 * 1024 ? [{ path: file, mimeType }] : undefined });
|
|
102
|
+
};
|
|
103
|
+
const watcher = fs.watch(dir, { recursive: o.recursive !== false }, (_type, name) => {
|
|
104
|
+
if (!name)
|
|
105
|
+
return;
|
|
106
|
+
const file = path.join(dir, String(name));
|
|
107
|
+
if ((pattern && !pattern.test(file)) || (mask && !mask.test(path.basename(file))) || ignore.test(String(name)) || pending.has(file))
|
|
108
|
+
return;
|
|
109
|
+
pending.set(file, setTimeout(() => check(file, -1), 200));
|
|
110
|
+
});
|
|
111
|
+
source.onStop(() => {
|
|
112
|
+
watcher.close();
|
|
113
|
+
pending.forEach((t) => clearTimeout(t));
|
|
114
|
+
});
|
|
115
|
+
source.log.info(`watching ${dir}`);
|
|
116
|
+
}
|