@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
@@ -0,0 +1,61 @@
1
+ export interface Part {
2
+ /** What it is read by: "sources#telegram", or "configuration#agent-json/globalmcp" for a row. */
3
+ id: string;
4
+ kind: "section" | "row";
5
+ /** The file it is from, without ".md". */
6
+ file: string;
7
+ /** The headings it stands under, its own last. */
8
+ path: string[];
9
+ title: string;
10
+ text: string;
11
+ /** For a section: the sections directly under it. */
12
+ children: string[];
13
+ /** Counts for more than its words alone: what this installation really has, against what the manual gives as an example. */
14
+ boost?: number;
15
+ /** What it is, for a part that is not from the manual's files ("registered in this pine"). */
16
+ note?: string;
17
+ }
18
+ /** A word without its ending, so that subscribe, subscribes, subscribed and subscribing are one word. Rough, and the same for both sides. */
19
+ export declare function stem(word: string): string;
20
+ /** Cut one Markdown file into its parts: a section per heading (three levels), a row per table line that describes a named thing. */
21
+ export declare function partsOf(file: string, source: string): Part[];
22
+ /** Read the manual from pine's docs folder: SKILL.md and everything in references/. */
23
+ export declare function loadManual(docsDir: string): Part[];
24
+ export interface Hit {
25
+ id: string;
26
+ where: string;
27
+ kind: Part["kind"];
28
+ chars: number;
29
+ note?: string;
30
+ /** The line of the part that says most about the query. */
31
+ line: string;
32
+ score: number;
33
+ }
34
+ export interface Manual {
35
+ parts: Part[];
36
+ search(query: string, limit?: number): {
37
+ query: string;
38
+ total: number;
39
+ hits: Hit[];
40
+ };
41
+ read(id: string): {
42
+ part: Part;
43
+ page: number;
44
+ pages: number;
45
+ text: string;
46
+ } | {
47
+ unknown: string;
48
+ near: string[];
49
+ };
50
+ contents(): string;
51
+ }
52
+ export declare function createManual(parts: Part[]): Manual;
53
+ /**
54
+ * pine's manual, read once and again only when a file of it has changed. `extra` are parts that
55
+ * are not in the files: what this installation has beyond the manual, such as source types of its own.
56
+ */
57
+ export declare function pineManual(docsDir: string, extra?: Part[]): Manual;
58
+ /** What a search answers with, as text for a model or a shell. */
59
+ export declare function showSearch(manual: Manual, queries: string[], limit?: number): string;
60
+ /** One or several parts in full, within what fits into one answer. */
61
+ export declare function showRead(manual: Manual, ids: string[], budget?: number): string;
package/src/manual.js ADDED
@@ -0,0 +1,425 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /** Words that say the same thing in pine. Each line is one group; a word of a group finds the others, at half weight. */
4
+ const SYNONYMS = [
5
+ ["schedule", "cron", "timer", "reminder", "remind", "alarm", "recurring", "periodic", "daily", "hourly", "interval", "heartbeat", "clock", "time"],
6
+ ["source", "watch", "monitor", "observe", "listen", "trigger", "event", "sensor", "feed"],
7
+ ["subscribe", "subscription", "listen", "follow", "wake", "notify", "notification"],
8
+ ["unsubscribe", "stop", "mute", "silence"],
9
+ ["mcp", "server", "connector", "integration", "plugin", "extension", "tool"],
10
+ ["skill", "procedure", "howto", "recipe", "playbook", "instruction", "knowhow"],
11
+ ["secret", "token", "password", "key", "credential", "apikey", "env"],
12
+ ["approve", "approval", "permission", "confirm", "consent", "ask", "allow", "deny", "dialog", "prompt", "question"],
13
+ ["sandbox", "isolation", "confine", "restrict", "jail", "permission", "access"],
14
+ ["conversation", "session", "tab", "chat", "thread"],
15
+ ["taint", "tainted", "outside", "untrusted", "injection", "external", "hostile"],
16
+ ["trust", "owner", "trusted", "yours"],
17
+ ["scope", "local", "global"],
18
+ ["global", "everyone", "all", "shared", "every"],
19
+ ["agent", "assistant", "bot", "persona"],
20
+ ["subagent", "delegate", "delegation", "handoff", "child", "spawn"],
21
+ ["model", "llm", "provider", "inference", "endpoint"],
22
+ ["telegram", "messenger", "phone", "chat", "bot"],
23
+ ["pair", "pairing", "link", "connect", "bind"],
24
+ ["webhook", "hook", "callback", "http", "post"],
25
+ ["http", "url", "website", "web", "page", "site", "address", "poll", "scrape"],
26
+ ["command", "shell", "bash", "script", "terminal", "cli"],
27
+ ["folder", "directory", "dir", "path", "file"],
28
+ ["mqtt", "broker", "iot", "device", "topic", "publish"],
29
+ ["send", "message", "notify", "reply", "answer", "push", "output"],
30
+ ["memory", "remember", "note", "persist", "recall"],
31
+ ["instruction", "persona", "soul", "prompt", "system", "identity", "rule"],
32
+ ["config", "configuration", "setting", "option", "json", "setup", "configure"],
33
+ ["install", "setup", "init", "start", "launch", "run", "boot", "service", "daemon"],
34
+ ["remove", "delete", "uninstall", "drop", "clear"],
35
+ ["add", "create", "new", "register", "make", "define"],
36
+ ["list", "show", "status", "overview", "inspect"],
37
+ ["mode", "auto", "manual", "bypass", "autonomy"],
38
+ ["error", "problem", "fail", "broken", "troubleshoot", "debug", "wrong", "stuck"],
39
+ ["log", "history", "audit", "record", "trace"],
40
+ ["image", "picture", "photo", "screenshot", "video", "media"],
41
+ ["browser", "web", "playwright", "page", "search"],
42
+ ["code", "repository", "repo", "git", "diff", "project"],
43
+ ["route", "rule", "pipeline", "automation"],
44
+ ["state", "variable", "store", "value"],
45
+ ["limit", "quota", "budget", "timeout", "throttle", "debounce", "rate"],
46
+ ["network", "internet", "online", "egress"],
47
+ ["read", "write", "edit", "access", "permission"],
48
+ ["ui", "tui", "interface", "terminal", "screen", "keyboard", "shortcut", "key"],
49
+ ["type", "kind", "module", "custom", "own"],
50
+ ];
51
+ const SYNONYM_MAP = new Map();
52
+ const STOP = new Set("a an the and or of to in on for with by is are be how do does i my me it its that this from as at into onto via can could should would will what which when where who why you your we our there here if then than so not no yes get got use using make want need about have has mean means meaning explain tell please exactly actually just set up one thing things happen happens some any each also".split(" "));
53
+ /** Words of a text: camelCase, snake_case and punctuation split, lowercased. */
54
+ function words(text) {
55
+ return String(text ?? "")
56
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
57
+ .toLowerCase()
58
+ .split(/[^a-z0-9]+/)
59
+ .filter(Boolean);
60
+ }
61
+ /** A word without its ending, so that subscribe, subscribes, subscribed and subscribing are one word. Rough, and the same for both sides. */
62
+ export function stem(word) {
63
+ let w = word;
64
+ if (w.length > 4 && w.endsWith("ies"))
65
+ w = `${w.slice(0, -3)}y`;
66
+ else if (w.length > 4 && /(x|ch|sh|ss)es$/.test(w))
67
+ w = w.slice(0, -2);
68
+ else if (w.length > 3 && w.endsWith("s") && !w.endsWith("ss") && !w.endsWith("us"))
69
+ w = w.slice(0, -1);
70
+ if (w.length > 5 && w.endsWith("ing"))
71
+ w = w.slice(0, -3);
72
+ else if (w.length > 4 && w.endsWith("ed"))
73
+ w = w.slice(0, -2);
74
+ if (w.length > 3 && /([bdfglmnprt])\1$/.test(w))
75
+ w = w.slice(0, -1);
76
+ if (w.length > 4 && w.endsWith("e"))
77
+ w = w.slice(0, -1);
78
+ return w;
79
+ }
80
+ for (const group of SYNONYMS) {
81
+ const stems = group.map(stem);
82
+ for (const s of stems) {
83
+ const set = SYNONYM_MAP.get(s) ?? new Set();
84
+ for (const other of stems)
85
+ if (other !== s)
86
+ set.add(other);
87
+ SYNONYM_MAP.set(s, set);
88
+ }
89
+ }
90
+ const slug = (s) => s
91
+ .toLowerCase()
92
+ .replace(/`/g, "")
93
+ .replace(/[^a-z0-9]+/g, "-")
94
+ .replace(/^-+|-+$/g, "") || "x";
95
+ /** The first code term of a table cell, or the cell as it reads. */
96
+ const cellKey = (cell) => (/`([^`]+)`/.exec(cell)?.[1] ?? cell).trim();
97
+ /** Cut one Markdown file into its parts: a section per heading (three levels), a row per table line that describes a named thing. */
98
+ export function partsOf(file, source) {
99
+ const out = [];
100
+ const taken = new Set();
101
+ const unique = (id) => {
102
+ let n = id;
103
+ for (let i = 2; taken.has(n); i++)
104
+ n = `${id}-${i}`;
105
+ taken.add(n);
106
+ return n;
107
+ };
108
+ const lines = source.replace(/^---\n[\s\S]*?\n---\n/, "").split("\n");
109
+ const stack = [];
110
+ let current;
111
+ let fenced = false;
112
+ let table;
113
+ const open = (level, title) => {
114
+ while (stack.length >= level)
115
+ stack.pop();
116
+ const parent = stack.at(-1);
117
+ const part = { id: unique(level === 1 ? file : `${file}#${slug(title)}`), kind: "section", file, path: [...(parent?.path ?? []), title], title, text: "", children: [] };
118
+ parent?.children.push(part.id);
119
+ out.push(part);
120
+ stack.push(part);
121
+ current = part;
122
+ };
123
+ for (const line of lines) {
124
+ if (/^\s*```/.test(line))
125
+ fenced = !fenced;
126
+ const heading = fenced ? null : /^(#{1,3})\s+(.+?)\s*$/.exec(line);
127
+ if (heading) {
128
+ table = undefined;
129
+ open(heading[1].length, heading[2].replace(/`/g, ""));
130
+ continue;
131
+ }
132
+ if (!current) {
133
+ // What stands before the first heading, if anything does.
134
+ if (!line.trim())
135
+ continue;
136
+ open(1, file);
137
+ }
138
+ current.text += `${line}\n`;
139
+ // A table row about a named thing (a key, a tool, a command) is also a part of its own.
140
+ const cells = !fenced && /^\s*\|.*\|\s*$/.test(line) ? line.trim().slice(1, -1).split(/(?<!\\)\|/).map((c) => c.trim()) : undefined;
141
+ if (!cells) {
142
+ table = undefined;
143
+ continue;
144
+ }
145
+ if (!table) {
146
+ table = { header: cells };
147
+ continue;
148
+ }
149
+ if (cells.every((c) => /^:?-+:?$/.test(c) || c === ""))
150
+ continue;
151
+ if (!/`[^`]+`/.test(cells[0]) || cells.length < 2)
152
+ continue;
153
+ const key = cellKey(cells[0]);
154
+ const told = cells
155
+ .slice(1)
156
+ .map((c, i) => (c ? `${table.header[i + 1] && !/^\s*$/.test(table.header[i + 1]) ? `${table.header[i + 1]}: ` : ""}${c}` : ""))
157
+ .filter(Boolean)
158
+ .join("\n");
159
+ out.push({ id: unique(`${current.id}${current.id.includes("#") ? "/" : "#"}${slug(key)}`), kind: "row", file, path: [...current.path, cells[0].replace(/`/g, "")], title: cells[0].replace(/`/g, ""), text: `${cells[0]}\n${told}\n`, children: [] });
160
+ }
161
+ for (const p of out)
162
+ p.text = p.text.replace(/^\n+|\n+$/g, "");
163
+ return out;
164
+ }
165
+ /** Read the manual from pine's docs folder: SKILL.md and everything in references/. */
166
+ export function loadManual(docsDir) {
167
+ const refs = path.join(docsDir, "references");
168
+ const files = [
169
+ ...(fs.existsSync(path.join(docsDir, "SKILL.md")) ? [["pine", path.join(docsDir, "SKILL.md")]] : []),
170
+ ...(fs.existsSync(refs) ? fs.readdirSync(refs).filter((f) => f.endsWith(".md")).sort().map((f) => [f.slice(0, -3), path.join(refs, f)]) : []),
171
+ ];
172
+ return files.flatMap(([name, file]) => partsOf(name, fs.readFileSync(file, "utf8")));
173
+ }
174
+ /** The names of the tools pine gives its agents, as the manual writes them. */
175
+ const TOOL = /^(?:(?:add|remove)_[a-z_]+|subscribe|unsubscribe|send|schedules|sources|manual|create_agent|delegate|subagent|request_access|publish_event|state_[gs]et)$/;
176
+ /** title, the headings above, code terms, text: how much a hit in each counts, and how strongly its length is held against it. */
177
+ const FIELDS = [
178
+ { weight: 3, b: 0.4 },
179
+ { weight: 1.5, b: 0.4 },
180
+ { weight: 2, b: 0.6 },
181
+ { weight: 1, b: 0.75 },
182
+ ];
183
+ const K1 = 1.2;
184
+ const PAGE = 6000;
185
+ export function createManual(parts) {
186
+ const tf = (list) => list.reduce((m, w) => m.set(w, (m.get(w) ?? 0) + 1), new Map());
187
+ // A name written in one piece ("bashTimeoutSec", "add_source") is also found when typed in one piece.
188
+ const withJoined = (text) => [...words(text), ...(text.match(/[A-Za-z][A-Za-z0-9]*(?:[_-][A-Za-z0-9]+|[A-Z][a-z0-9]+)+/g) ?? []).map((t) => t.replace(/[^A-Za-z0-9]/g, "").toLowerCase())];
189
+ const df = new Map();
190
+ const sums = FIELDS.map(() => 0);
191
+ const index = parts.map((part) => {
192
+ const code = (part.text.match(/`[^`\n]{1,48}`/g) ?? []).join(" ");
193
+ const texts = [withJoined(part.title), words(part.path.slice(0, -1).join(" ") + " " + part.file), withJoined(code), words(part.text)].map((list) => list.map(stem));
194
+ const fields = texts.map((list) => ({ tf: tf(list), len: list.length }));
195
+ for (const w of new Set(texts.flat()))
196
+ df.set(w, (df.get(w) ?? 0) + 1);
197
+ fields.forEach((f, i) => (sums[i] += f.len));
198
+ const running = [...texts[0], ...texts[3]].filter((w) => !STOP.has(w));
199
+ const pairs = new Set(running.slice(1).map((w, i) => `${running[i]} ${w}`));
200
+ const heading = texts[0].filter((w) => !STOP.has(w));
201
+ const titlePairs = new Set(heading.slice(1).map((w, i) => `${heading[i]} ${w}`));
202
+ const tool = (part.text.match(/`[a-z_]+`/g) ?? []).some((c) => TOOL.test(c.slice(1, -1)));
203
+ return { part, fields, pairs, titlePairs, tool };
204
+ });
205
+ const n = index.length || 1;
206
+ const avg = sums.map((s) => s / n || 1);
207
+ const idf = (w) => Math.log(1 + (n - (df.get(w) ?? 0) + 0.5) / ((df.get(w) ?? 0) + 0.5));
208
+ const byId = new Map(parts.map((p) => [p.id, p]));
209
+ const where = (p) => `${p.note ? p.file : `${p.file}.md`} › ${p.path.slice(p.path.length > 1 ? 1 : 0).join(" › ")}`;
210
+ const score = (entry, word) => {
211
+ let combined = 0;
212
+ entry.fields.forEach((f, i) => {
213
+ const count = f.tf.get(word);
214
+ if (count)
215
+ combined += (FIELDS[i].weight * count) / (1 - FIELDS[i].b + (FIELDS[i].b * f.len) / avg[i]);
216
+ });
217
+ return combined ? (idf(word) * combined * (K1 + 1)) / (combined + K1) : 0;
218
+ };
219
+ /** What a part says about the query, in a line: for a row all of it, for a section the sentence in which most of the query's words stand. */
220
+ const bestLine = (part, own, all) => {
221
+ const flat = (t) => t.replace(/\s+/g, " ").replace(/^\|\s*|\s*\|$/g, "").trim();
222
+ const cut = (t) => (t.length > 300 ? `${t.slice(0, 298)}…` : t);
223
+ if (part.kind === "row")
224
+ return cut(flat(part.text.split("\n").join(" · ")));
225
+ // The manual's lines are broken for reading; a sentence runs across them.
226
+ const pieces = [];
227
+ for (const block of part.text.split(/\n\s*\n/)) {
228
+ const lines = block.split("\n").filter((l) => l.trim() && !/^\s*[|:\- ]+$/.test(l) && !l.trim().startsWith("```"));
229
+ if (!lines.length)
230
+ continue;
231
+ // A table, a list or code: every line stands for itself. Running text: sentence by sentence.
232
+ if (lines.some((l) => /^\s*(\||[-*] |\d+\. |>|[{}"\[\]]|\/\/)/.test(l)) || block.includes("```"))
233
+ pieces.push(...lines.map(flat));
234
+ else
235
+ pieces.push(...flat(lines.join(" ")).split(/(?<=[.!?])\s+(?=[A-Z"`(\[])/));
236
+ }
237
+ let at = 0;
238
+ let most = -1;
239
+ pieces.forEach((piece, i) => {
240
+ const seen = new Set(words(piece).map(stem));
241
+ const hits = [...seen].reduce((n, w) => n + (own.has(w) ? 2 : all.has(w) ? 1 : 0), 0);
242
+ if (hits > most) {
243
+ most = hits;
244
+ at = i;
245
+ }
246
+ });
247
+ let line = pieces[at] ?? "";
248
+ // One sentence is seldom the whole answer: what follows it comes along while there is room.
249
+ for (let i = at + 1; i < pieces.length && line.length + pieces[i].length < 380; i++)
250
+ line += ` ${pieces[i]}`;
251
+ return line.length > 420 ? `${line.slice(0, 418)}…` : line;
252
+ };
253
+ return {
254
+ parts,
255
+ search(query, limit = 6) {
256
+ const direct = [...new Set(words(query).filter((w) => !STOP.has(w)).map(stem))];
257
+ if (!direct.length)
258
+ return { query, total: 0, hits: [] };
259
+ // [word, weight, the query word it stands for]
260
+ const weighted = direct.map((w, i) => [w, 1, i]);
261
+ // A name typed in one piece.
262
+ const joined = query.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
263
+ direct.forEach((w, i) => {
264
+ for (const syn of SYNONYM_MAP.get(w) ?? [])
265
+ if (!direct.includes(syn))
266
+ weighted.push([syn, 0.5, i]);
267
+ });
268
+ const terms = new Set(weighted.map(([w]) => w));
269
+ const own = new Set(direct);
270
+ const hits = index
271
+ .map((entry) => {
272
+ let total = 0;
273
+ const own = new Set();
274
+ const viaSynonym = new Set();
275
+ for (const [w, weight, origin] of weighted) {
276
+ const s = score(entry, w);
277
+ if (!s)
278
+ continue;
279
+ total += weight * s;
280
+ (weight === 1 ? own : viaSynonym).add(origin);
281
+ }
282
+ if (!total)
283
+ return undefined;
284
+ const covered = own.size + [...viaSynonym].filter((o) => !own.has(o)).length * 0.5;
285
+ // Soft "and": a part that has half of the words keeps half of its score.
286
+ total *= covered / direct.length;
287
+ // A title that says what was asked, in the query's words or in the manual's own for them.
288
+ const title = entry.fields[0].tf;
289
+ const named = direct.filter((w) => title.has(w) || [...(SYNONYM_MAP.get(w) ?? [])].some((syn) => title.has(syn))).length;
290
+ if (named === direct.length)
291
+ total += direct.length === entry.fields[0].len ? 4 : 2.5;
292
+ else
293
+ total += (named / direct.length) * 1.5;
294
+ if (joined.length > 5 && title.has(stem(joined)))
295
+ total += 4;
296
+ // A question in several words asks how something is; the section explains it, a row only names it.
297
+ if (direct.length >= 3 && entry.part.kind === "section")
298
+ total *= 1.25;
299
+ // Two words asked for together and standing together ("all agents", "outside content") say more than each alone.
300
+ // In a title they name the thing itself: "pine schedules", "code pane".
301
+ for (let i = 1; i < direct.length; i++) {
302
+ const pair = `${direct[i - 1]} ${direct[i]}`;
303
+ if (entry.titlePairs.has(pair))
304
+ total += 4;
305
+ else if (entry.pairs.has(pair))
306
+ total += 1.2;
307
+ }
308
+ // Whoever searches here is an agent: what it can do with a tool of its own comes before what it can only pass on.
309
+ if (entry.tool)
310
+ total *= 1.15;
311
+ total *= entry.part.boost ?? 1;
312
+ return { entry, total };
313
+ })
314
+ .filter((x) => Boolean(x))
315
+ .sort((a, b) => b.total - a.total || a.entry.part.id.length - b.entry.part.id.length || a.entry.part.id.localeCompare(b.entry.part.id));
316
+ // A row and the section it stands in say the same thing twice: the better one of the two is enough.
317
+ const shown = [];
318
+ for (const h of hits) {
319
+ const p = h.entry.part;
320
+ const sectionOf = (x) => (x.kind === "row" ? x.id.slice(0, x.id.lastIndexOf(x.id.includes("/") ? "/" : "#")) : x.id);
321
+ if (shown.some((s) => s.entry.part.kind !== p.kind && sectionOf(s.entry.part) === sectionOf(p)))
322
+ continue;
323
+ // What is far behind the best is noise: a part that only shares one common word with the query.
324
+ if (shown.length && h.total < hits[0].total * 0.3)
325
+ break;
326
+ shown.push(h);
327
+ if (shown.length >= limit)
328
+ break;
329
+ }
330
+ return { query, total: hits.length, hits: shown.map(({ entry, total }) => ({ id: entry.part.id, where: where(entry.part), kind: entry.part.kind, chars: entry.part.text.length, note: entry.part.note, line: bestLine(entry.part, own, terms), score: Math.round(total * 100) / 100 })) };
331
+ },
332
+ read(wanted) {
333
+ const m = /^(.*?)(?:@(\d+))?$/.exec(String(wanted ?? "").trim());
334
+ const raw = m[1].replace(/\.md(?=#|$)/, "");
335
+ const lower = raw.toLowerCase();
336
+ const part = byId.get(raw) ?? byId.get(lower) ?? (() => {
337
+ // Only the last piece given ("telegram"): fine when one part ends that way.
338
+ const tail = parts.filter((p) => p.id.split(/[#/]/).at(-1) === slug(lower));
339
+ const sections = tail.filter((p) => p.kind === "section");
340
+ return sections.length === 1 ? sections[0] : tail.length === 1 ? tail[0] : undefined;
341
+ })();
342
+ if (!part) {
343
+ const want = slug(lower.split(/[#/]/).at(-1) ?? lower);
344
+ const near = parts.filter((p) => p.id.includes(want) || (p.id.split(/[#/]/).at(-1).length > 3 && want.includes(p.id.split(/[#/]/).at(-1)))).slice(0, 8).map((p) => p.id);
345
+ return { unknown: wanted, near };
346
+ }
347
+ const pages = paginate(part.text, PAGE);
348
+ const page = Math.min(pages.length, Math.max(1, Number(m[2] ?? 1)));
349
+ return { part, page, pages: pages.length, text: pages[page - 1] };
350
+ },
351
+ contents() {
352
+ const tops = parts.filter((p) => p.kind === "section" && p.path.length === 1);
353
+ return tops.map((t) => `${t.id}: ${t.title}\n${t.children.map((c) => ` ${c}`).join("\n")}`).join("\n");
354
+ },
355
+ };
356
+ }
357
+ let cached;
358
+ /**
359
+ * pine's manual, read once and again only when a file of it has changed. `extra` are parts that
360
+ * are not in the files: what this installation has beyond the manual, such as source types of its own.
361
+ */
362
+ export function pineManual(docsDir, extra = []) {
363
+ const refs = path.join(docsDir, "references");
364
+ const stamp = [path.join(docsDir, "SKILL.md"), ...(fs.existsSync(refs) ? fs.readdirSync(refs).map((f) => path.join(refs, f)) : [])].map((f) => `${f}:${fs.statSync(f, { throwIfNoEntry: false })?.mtimeMs ?? 0}`).join("|");
365
+ if (!cached || cached.stamp !== stamp)
366
+ cached = { stamp, manual: createManual(loadManual(docsDir)) };
367
+ return extra.length ? createManual([...cached.manual.parts, ...extra]) : cached.manual;
368
+ }
369
+ /** Cut a long text into pages at a blank line, so that a paragraph or a table row stays whole where it can. */
370
+ function paginate(text, size) {
371
+ const pages = [];
372
+ let rest = text;
373
+ while (rest.length > size) {
374
+ const window = rest.slice(0, size);
375
+ let cut = window.lastIndexOf("\n\n");
376
+ if (cut < size * 0.4)
377
+ cut = window.lastIndexOf("\n");
378
+ if (cut <= 0)
379
+ cut = size;
380
+ pages.push(rest.slice(0, cut));
381
+ rest = rest.slice(cut).replace(/^\n+/, "");
382
+ }
383
+ pages.push(rest);
384
+ return pages;
385
+ }
386
+ /** What a search answers with, as text for a model or a shell. */
387
+ export function showSearch(manual, queries, limit = 6) {
388
+ const out = [];
389
+ for (const q of queries) {
390
+ const r = manual.search(q, queries.length > 1 ? Math.min(limit, 4) : limit);
391
+ if (!r.hits.length) {
392
+ out.push(`"${q}": nothing in the manual has these words. That does not mean pine cannot do it: say it with other words (the manual says "event source" for what watches something, "schedule" for what happens at a time, "approval" for what asks the owner, "conversation" for a session or tab), or look at the list of its parts with an empty search.`);
393
+ continue;
394
+ }
395
+ out.push(`"${q}": ${r.hits.length} of ${r.total} parts, the best first`);
396
+ r.hits.forEach((h, i) => out.push(`${i + 1}. ${h.id} (${h.where}${h.note ? `, ${h.note}` : h.kind === "section" ? `, ${h.chars} characters` : ", one row of a table"})\n ${h.line}`));
397
+ }
398
+ out.push(`Read a part in full by its id (the first word of an entry), several at once if you like. A line above is only the line that matched: read the part before you rely on it.`);
399
+ return out.join("\n");
400
+ }
401
+ /** One or several parts in full, within what fits into one answer. */
402
+ export function showRead(manual, ids, budget = 14_000) {
403
+ const out = [];
404
+ let used = 0;
405
+ for (const id of ids.slice(0, 8)) {
406
+ const r = manual.read(id);
407
+ if ("unknown" in r) {
408
+ out.push(`"${id}" is not a part of the manual.${r.near.length ? ` Close to it: ${r.near.join(", ")}.` : ""} Search for it instead.`);
409
+ continue;
410
+ }
411
+ if (used && used + r.text.length > budget) {
412
+ out.push(`(not shown, to keep this answer short: ${id}. Read it on its own.)`);
413
+ continue;
414
+ }
415
+ used += r.text.length;
416
+ const p = r.part;
417
+ const head = `## ${p.id} (${p.note ? p.file : `${p.file}.md`} › ${p.path.slice(p.path.length > 1 ? 1 : 0).join(" › ")}${r.pages > 1 ? `, page ${r.page} of ${r.pages}` : ""})`;
418
+ const more = r.page < r.pages ? `\n\n(continues: read ${p.id}@${r.page + 1})` : "";
419
+ const below = p.children.length && r.page === r.pages ? `\n\nParts below this one: ${p.children.join(", ")}` : "";
420
+ // A row is one line of a table: what the table is about, and what holds for all of its rows, stands in the part around it.
421
+ const around = p.kind === "row" && !p.note ? `\n\n(This is one row of a table in ${p.id.slice(0, p.id.lastIndexOf(p.id.includes("/") ? "/" : "#"))}. Read that part for what stands around it.)` : "";
422
+ out.push(`${head}\n\n${r.text || "(nothing but the parts below it)"}${more}${below}${around}`);
423
+ }
424
+ return out.join("\n\n");
425
+ }
@@ -0,0 +1,40 @@
1
+ import { type McpServerConfig, type PineConfig } from "./config.ts";
2
+ /** Change a JSON configuration file in place, keeping what is not touched (including ${NAME} placeholders). */
3
+ export declare function editJson(file: string, change: (json: any) => void): void;
4
+ export declare function validateServer(cfg: PineConfig, name: string, server: McpServerConfig, agentId?: string): void;
5
+ /**
6
+ * Add or replace an MCP server: in pine.json for everyone, or in one agent's agent.json. The file
7
+ * and the loaded configuration are both updated; sessions pick the server up when they next start.
8
+ */
9
+ export declare function addServer(cfg: PineConfig, name: string, server: McpServerConfig, agentId?: string): void;
10
+ export declare function removeServer(cfg: PineConfig, name: string, agentId?: string): boolean;
11
+ export interface ServerInfo {
12
+ name: string;
13
+ /** "global", or the id of the agent that owns it. */
14
+ scope: string;
15
+ /** Agents that end up with it. */
16
+ agents: string[];
17
+ target: string;
18
+ remote: boolean;
19
+ taints: boolean;
20
+ approve: string;
21
+ enabled: boolean;
22
+ }
23
+ export declare function listServers(cfg: PineConfig): ServerInfo[];
24
+ export declare function findServer(cfg: PineConfig, name: string, agentId?: string): McpServerConfig | undefined;
25
+ /**
26
+ * Run pi's own `mcp` command (list, login, logout) for the given servers. pi keeps OAuth tokens per
27
+ * server name and URL in its agent folder; pine points that folder at data/pi, which is where the
28
+ * daemon's sessions look too. The servers are written to a temporary mcp.json there, because the
29
+ * command only knows that file. The daemon's sessions never read that file (see createMcpExtension
30
+ * in agents.ts), so it cannot hand a server to an agent that is not meant to have it.
31
+ */
32
+ export declare function piMcp(cfg: PineConfig, servers: Record<string, McpServerConfig>, args: string[], opts?: {
33
+ capture?: boolean;
34
+ onOutput?: (text: string) => void;
35
+ }): Promise<{
36
+ code: number;
37
+ out: string;
38
+ }>;
39
+ /** Connection state of every configured server, as pi reports it ("connected", "needs sign-in", an error). */
40
+ export declare function serverStates(cfg: PineConfig): Promise<Record<string, string>>;
@@ -0,0 +1,132 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { mcpTaints, toPiMcp } from "./config.js";
6
+ /** Change a JSON configuration file in place, keeping what is not touched (including ${NAME} placeholders). */
7
+ export function editJson(file, change) {
8
+ const json = JSON.parse(fs.readFileSync(file, "utf8"));
9
+ change(json);
10
+ const mode = fs.statSync(file).mode & 0o777;
11
+ fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`, { mode });
12
+ }
13
+ const fileFor = (cfg, agentId) => (agentId ? path.join(cfg.agents[agentId].dir, "agent.json") : path.join(cfg.home, "pine.json"));
14
+ export function validateServer(cfg, name, server, agentId) {
15
+ if (!/^[A-Za-z0-9_-]{1,40}$/.test(name))
16
+ throw new Error(`"${name}" is not a usable server name (letters, digits, - and _)`);
17
+ if (agentId && !cfg.agents[agentId])
18
+ throw new Error(`unknown agent "${agentId}"`);
19
+ if (!server.url && !server.command && !server.pine)
20
+ throw new Error("an MCP server needs a url, a command, or the name of a bundled server");
21
+ if (server.url) {
22
+ let url;
23
+ try {
24
+ url = new URL(server.url);
25
+ }
26
+ catch {
27
+ throw new Error(`"${server.url}" is not a valid URL`);
28
+ }
29
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))
30
+ throw new Error("a remote MCP server must use https");
31
+ }
32
+ }
33
+ /**
34
+ * Add or replace an MCP server: in pine.json for everyone, or in one agent's agent.json. The file
35
+ * and the loaded configuration are both updated; sessions pick the server up when they next start.
36
+ */
37
+ export function addServer(cfg, name, server, agentId) {
38
+ validateServer(cfg, name, server, agentId);
39
+ editJson(fileFor(cfg, agentId), (json) => {
40
+ json.mcp ??= {};
41
+ json.mcp[name] = server;
42
+ });
43
+ (agentId ? cfg.agents[agentId].mcp : cfg.mcp)[name] = server;
44
+ }
45
+ export function removeServer(cfg, name, agentId) {
46
+ const target = agentId ? cfg.agents[agentId]?.mcp : cfg.mcp;
47
+ if (!target || !(name in target))
48
+ return false;
49
+ editJson(fileFor(cfg, agentId), (json) => {
50
+ delete json.mcp?.[name];
51
+ // Nothing is left behind: an empty list of servers is no entry at all.
52
+ if (json.mcp && !Object.keys(json.mcp).length)
53
+ delete json.mcp;
54
+ });
55
+ delete target[name];
56
+ return true;
57
+ }
58
+ export function listServers(cfg) {
59
+ const describe = (s) => s.url ?? (s.pine ? `bundled: ${s.pine}` : [s.command, ...(s.args ?? [])].join(" "));
60
+ const out = [];
61
+ for (const [name, s] of Object.entries(cfg.mcp)) {
62
+ const agents = Object.values(cfg.agents).filter((a) => {
63
+ if (name in a.mcp)
64
+ return false;
65
+ const scope = s.agents ?? ["*"];
66
+ return a.globalMcp === true ? scope.includes("*") || scope.includes(a.id) : Array.isArray(a.globalMcp) && a.globalMcp.includes(name);
67
+ });
68
+ out.push({ name, scope: "global", agents: agents.map((a) => a.id), target: describe(s), remote: Boolean(s.url), taints: mcpTaints(s), approve: s.approve ?? "never", enabled: s.enabled !== false });
69
+ }
70
+ for (const a of Object.values(cfg.agents))
71
+ for (const [name, s] of Object.entries(a.mcp))
72
+ out.push({ name, scope: a.id, agents: [a.id], target: describe(s), remote: Boolean(s.url), taints: mcpTaints(s), approve: s.approve ?? "never", enabled: s.enabled !== false });
73
+ return out;
74
+ }
75
+ export function findServer(cfg, name, agentId) {
76
+ if (agentId)
77
+ return cfg.agents[agentId]?.mcp[name];
78
+ return cfg.mcp[name] ?? Object.values(cfg.agents).map((a) => a.mcp[name]).find(Boolean);
79
+ }
80
+ /**
81
+ * Run pi's own `mcp` command (list, login, logout) for the given servers. pi keeps OAuth tokens per
82
+ * server name and URL in its agent folder; pine points that folder at data/pi, which is where the
83
+ * daemon's sessions look too. The servers are written to a temporary mcp.json there, because the
84
+ * command only knows that file. The daemon's sessions never read that file (see createMcpExtension
85
+ * in agents.ts), so it cannot hand a server to an agent that is not meant to have it.
86
+ */
87
+ export function piMcp(cfg, servers, args, opts = {}) {
88
+ const dir = path.join(cfg.dataDir, "pi");
89
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ const file = path.join(dir, "mcp.json");
91
+ const mcpServers = Object.fromEntries(Object.entries(servers).map(([name, s]) => [name, toPiMcp(name, s)]));
92
+ fs.writeFileSync(file, JSON.stringify({ mcpServers }, null, 2), { mode: 0o600 });
93
+ const cli = path.join(path.dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "bundle", "cli.js");
94
+ return new Promise((resolve) => {
95
+ const child = spawn(process.execPath, [cli, "mcp", ...args], {
96
+ cwd: dir,
97
+ env: { ...process.env, PI_CODING_AGENT_DIR: dir, PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" },
98
+ stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit",
99
+ });
100
+ let out = "";
101
+ const take = (d) => {
102
+ out += d;
103
+ opts.onOutput?.(d.toString("utf8"));
104
+ };
105
+ child.stdout?.on("data", take);
106
+ child.stderr?.on("data", take);
107
+ const done = (code) => {
108
+ fs.rmSync(file, { force: true });
109
+ resolve({ code, out });
110
+ };
111
+ child.on("error", (e) => ((out += e.message), done(1)));
112
+ child.on("close", (code) => done(code ?? 1));
113
+ });
114
+ }
115
+ /** Connection state of every configured server, as pi reports it ("connected", "needs sign-in", an error). */
116
+ export async function serverStates(cfg) {
117
+ const all = {};
118
+ for (const a of Object.values(cfg.agents))
119
+ Object.assign(all, a.mcp);
120
+ Object.assign(all, cfg.mcp);
121
+ const enabled = Object.fromEntries(Object.entries(all).filter(([, s]) => s.enabled !== false));
122
+ if (!Object.keys(enabled).length)
123
+ return {};
124
+ const { out } = await piMcp(cfg, enabled, ["list"], { capture: true });
125
+ const states = {};
126
+ for (const line of out.split("\n")) {
127
+ const m = line.match(/^([A-Za-z0-9_-]+): (.+?)(?: \([^)]*\))?$/);
128
+ if (m && m[1] in enabled)
129
+ states[m[1]] = m[2];
130
+ }
131
+ return states;
132
+ }