@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,32 @@
1
+ import { type EditorTheme, Markdown, type MarkdownTheme, type SelectListTheme } from "@earendil-works/pi-tui";
2
+ /** Plain 16/256-colour styling, so the UI follows the terminal's own palette. */
3
+ export declare const c: {
4
+ bold: (s: string) => string;
5
+ dim: (s: string) => string;
6
+ italic: (s: string) => string;
7
+ underline: (s: string) => string;
8
+ inverse: (s: string) => string;
9
+ strike: (s: string) => string;
10
+ red: (s: string) => string;
11
+ green: (s: string) => string;
12
+ yellow: (s: string) => string;
13
+ blue: (s: string) => string;
14
+ magenta: (s: string) => string;
15
+ cyan: (s: string) => string;
16
+ gray: (s: string) => string;
17
+ accent: (s: string) => string;
18
+ bgPanel: (s: string) => string;
19
+ bgUser: (s: string) => string;
20
+ bgAccent: (s: string) => string;
21
+ };
22
+ export declare const selectTheme: SelectListTheme;
23
+ export declare const editorTheme: EditorTheme;
24
+ export declare const markdownTheme: MarkdownTheme;
25
+ export declare const SPINNER: string[];
26
+ /** Markdown from an agent or a tool, drawn: headings, lists, tables, quotes, and code in colour. */
27
+ export declare class Answer extends Markdown {
28
+ private from?;
29
+ private drawn;
30
+ constructor(text: string);
31
+ render(width: number): string[];
32
+ }
@@ -0,0 +1,128 @@
1
+ import { Markdown, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { highlightCode } from "./highlight.js";
3
+ const on = !process.env.NO_COLOR;
4
+ const sgr = (open, close) => (s) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s);
5
+ /** Plain 16/256-colour styling, so the UI follows the terminal's own palette. */
6
+ export const c = {
7
+ bold: sgr("1", "22"),
8
+ dim: sgr("2", "22"),
9
+ italic: sgr("3", "23"),
10
+ underline: sgr("4", "24"),
11
+ inverse: sgr("7", "27"),
12
+ strike: sgr("9", "29"),
13
+ red: sgr("31", "39"),
14
+ green: sgr("32", "39"),
15
+ yellow: sgr("33", "39"),
16
+ blue: sgr("34", "39"),
17
+ magenta: sgr("35", "39"),
18
+ cyan: sgr("36", "39"),
19
+ gray: sgr("90", "39"),
20
+ accent: sgr("38;5;114", "39"),
21
+ bgPanel: sgr("48;5;236", "49"),
22
+ bgUser: sgr("48;5;237", "49"),
23
+ bgAccent: sgr("48;5;114;30", "49;39"),
24
+ };
25
+ export const selectTheme = {
26
+ selectedPrefix: c.accent,
27
+ selectedText: (s) => c.bold(c.accent(s)),
28
+ description: c.gray,
29
+ scrollInfo: c.gray,
30
+ noMatch: c.gray,
31
+ };
32
+ export const editorTheme = { borderColor: c.gray, selectList: selectTheme };
33
+ /** Marks a line that only held a code block's backticks. A private-use character: no text contains it. */
34
+ const FENCE = "\uE000";
35
+ export const markdownTheme = {
36
+ // From the third level on, the library puts the "###" in front as a piece of its own. It is markup, so it is left out.
37
+ heading: (s) => (/^(?:\x1b\[[0-9;]*m)*#{3,6} (?:\x1b\[[0-9;]*m)*$/.test(s) ? "" : c.bold(c.accent(s))),
38
+ link: c.cyan,
39
+ linkUrl: c.gray,
40
+ code: c.yellow,
41
+ codeBlock: (s) => s,
42
+ // A code block is a bar down its left side with its language above it. The lines of backticks
43
+ // around it are markup, not content: the opening one becomes the language, the others are
44
+ // marked here and left out by Answer below.
45
+ codeBlockBorder: (s) => {
46
+ const lang = s.replace(/^[`~]+/, "").trim();
47
+ return lang ? c.gray(lang) : FENCE;
48
+ },
49
+ codeBlockIndent: c.gray("│ "),
50
+ highlightCode,
51
+ quote: c.italic,
52
+ quoteBorder: c.gray,
53
+ hr: c.gray,
54
+ listBullet: c.accent,
55
+ bold: c.bold,
56
+ italic: c.italic,
57
+ strikethrough: c.strike,
58
+ underline: c.underline,
59
+ };
60
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
61
+ /** Markdown from an agent or a tool, drawn: headings, lists, tables, quotes, and code in colour. */
62
+ export class Answer extends Markdown {
63
+ from;
64
+ drawn = [];
65
+ constructor(text) {
66
+ super(text, 1, 0, markdownTheme);
67
+ }
68
+ render(width) {
69
+ const lines = super.render(width);
70
+ if (lines !== this.from) {
71
+ this.from = lines;
72
+ this.drawn = lines.some((l) => l.includes(FENCE)) ? lines.filter((l) => !l.includes(FENCE)) : lines;
73
+ }
74
+ return this.drawn;
75
+ }
76
+ }
77
+ // Two things the library's tables leave out, added where it wraps a cell's text to its column:
78
+ // a <br>, which models write for a line break inside a cell, becomes that break; and a column
79
+ // that the table's second line aligns to the right ("---:") or centres (":---:") is set that way.
80
+ const proto = Markdown.prototype;
81
+ const wrapCell = proto.wrapCellText;
82
+ const drawTable = proto.renderTable;
83
+ const inline = proto.renderInlineTokens;
84
+ const BR = /\s*<br\s*\/?>\s*/i;
85
+ if (wrapCell && drawTable && inline) {
86
+ const own = Answer.prototype;
87
+ own.renderTable = function (token, ...rest) {
88
+ // The library first reads every cell once to size the columns, then once more, cell after
89
+ // cell and row by row, to draw them. Counting says which of the two it is doing, and which
90
+ // column a cell is in.
91
+ const cells = (token?.header?.length ?? 0) + (token?.rows ?? []).reduce((n, row) => n + row.length, 0);
92
+ this.table = { align: Array.isArray(token?.align) ? token.align : [], columns: token?.header?.length || 1, cell: 0, sizing: cells, depth: 0 };
93
+ try {
94
+ return drawTable.call(this, token, ...rest);
95
+ }
96
+ finally {
97
+ this.table = undefined;
98
+ }
99
+ };
100
+ own.renderInlineTokens = function (...args) {
101
+ const table = this.table;
102
+ if (!table)
103
+ return inline.apply(this, args);
104
+ table.depth++;
105
+ let text;
106
+ try {
107
+ text = inline.apply(this, args);
108
+ }
109
+ finally {
110
+ table.depth--;
111
+ }
112
+ if (table.depth > 0 || table.sizing <= 0)
113
+ return text;
114
+ table.sizing--;
115
+ // A cell with line breaks is as wide as its longest line, not as all of them in a row.
116
+ return BR.test(text) ? text.split(BR).reduce((a, b) => (visibleWidth(b) > visibleWidth(a) ? b : a)) : text;
117
+ };
118
+ own.wrapCellText = function (text, width, stylePrefix) {
119
+ const lines = text.split(BR).flatMap((part) => wrapCell.call(this, part, width, stylePrefix));
120
+ const side = this.table ? this.table.align[this.table.cell++ % this.table.columns] : undefined;
121
+ if (side !== "right" && side !== "center")
122
+ return lines;
123
+ return lines.map((line) => {
124
+ const free = Math.max(0, width - visibleWidth(line));
125
+ return " ".repeat(side === "right" ? free : Math.floor(free / 2)) + line;
126
+ });
127
+ };
128
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,124 @@
1
+ /** Where a handler's answer should go: an event on `topic` whose payload is `payload` plus `text`. */
2
+ export interface ReplyTarget {
3
+ topic: string;
4
+ payload?: Record<string, unknown>;
5
+ }
6
+ export interface PineEvent {
7
+ /** Row id in the event log; 0 for events published with `persist: false`. */
8
+ id: number;
9
+ ts: number;
10
+ topic: string;
11
+ /** Who published it: "source:<name>", "router", "timer", "http", or "agent:<id>". */
12
+ source: string;
13
+ payload: unknown;
14
+ /** Id of the event that led to this one. */
15
+ cause?: number;
16
+ /** How many agent or router steps separate this event from an outside trigger. */
17
+ hops: number;
18
+ reply?: ReplyTarget;
19
+ /**
20
+ * "owner": the content was written by a person the configuration names as owner (an
21
+ * allow-listed chat user, the authenticated control API, a schedule). Everything else is
22
+ * "untrusted": data that may try to give instructions.
23
+ */
24
+ trust: Trust;
25
+ }
26
+ export type Trust = "owner" | "untrusted";
27
+ export interface PublishOptions {
28
+ source?: string;
29
+ cause?: PineEvent | null;
30
+ reply?: ReplyTarget;
31
+ /** false keeps the event out of the log (telemetry, progress). Subscribers still get it. */
32
+ persist?: boolean;
33
+ /** Set when the publisher is acting for an agent or a route, so loops can be cut. */
34
+ hop?: boolean;
35
+ /** Default "untrusted". */
36
+ trust?: Trust;
37
+ }
38
+ export type TaskStatus = "pending" | "running" | "done" | "failed" | "cancelled";
39
+ export type TaskKind = "event" | "prompt" | "delegate" | "subagent" | "notify";
40
+ export interface ImageRef {
41
+ /** Absolute file path, or base64 data when `data` is set. */
42
+ path?: string;
43
+ data?: string;
44
+ mimeType: string;
45
+ }
46
+ export interface TaskMeta {
47
+ images?: ImageRef[];
48
+ /** Deliver the result to this agent session as a new message when the task ends. */
49
+ notify?: {
50
+ agent: string;
51
+ session: string;
52
+ };
53
+ /** Agents above this task in the delegation chain, outermost first. */
54
+ chain?: string[];
55
+ /** The task at the top of this task's delegation tree (its own id for a top-level task). */
56
+ root?: number;
57
+ /** Child sessions of a sub-agent get no delegation tools and an optional narrower tool list. */
58
+ leaf?: boolean;
59
+ tools?: string[];
60
+ label?: string;
61
+ /** Trust of whatever started this task. Children inherit the weaker of theirs and their parent's. */
62
+ trust?: Trust;
63
+ /** The session that created this task had already seen outside content. */
64
+ tainted?: string;
65
+ /** `input` is what the model server had to read anew; `cached` is what it took from its prompt cache. */
66
+ stats?: {
67
+ input: number;
68
+ output: number;
69
+ cached?: number;
70
+ turns: number;
71
+ tools: string[];
72
+ ms: number;
73
+ context?: ContextUse;
74
+ };
75
+ }
76
+ /** How full a session's context window is. `percent` is missing right after a compaction. */
77
+ export interface ContextUse {
78
+ tokens?: number;
79
+ window: number;
80
+ percent?: number;
81
+ }
82
+ export interface Task {
83
+ id: number;
84
+ agent: string;
85
+ /** Session key. Ephemeral sessions use "_t<id>". */
86
+ session: string;
87
+ kind: TaskKind;
88
+ prompt: string;
89
+ status: TaskStatus;
90
+ priority: number;
91
+ created: number;
92
+ started?: number;
93
+ finished?: number;
94
+ result?: string;
95
+ error?: string;
96
+ event?: number;
97
+ hops: number;
98
+ parent?: number;
99
+ depth: number;
100
+ reply?: ReplyTarget;
101
+ meta: TaskMeta;
102
+ }
103
+ /** One step of a task as its session recorded it: what it was given, what it wrote, what it called. */
104
+ export interface TranscriptEntry {
105
+ kind: "user" | "text" | "thinking" | "tool";
106
+ text?: string;
107
+ tool?: string;
108
+ subject?: string;
109
+ /** For a tool call: missing while it runs. */
110
+ ok?: boolean;
111
+ /** Pictures the tool returned, by the id the control API serves them under (GET /media/tool/<id>). */
112
+ images?: string[];
113
+ ms?: number;
114
+ result?: string;
115
+ }
116
+ export interface TaskResult {
117
+ id: number;
118
+ agent: string;
119
+ status: TaskStatus;
120
+ text: string;
121
+ error?: string;
122
+ /** Set when the run handled outside content; the reason names the first source. */
123
+ tainted?: string;
124
+ }
package/src/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ /**
2
+ * How this copy of pine got here.
3
+ * checkout: a git clone, which runs the sources directly. It is updated with git.
4
+ * global: installed with "npm install -g". It is updated with npm.
5
+ * local: a dependency inside some project's node_modules. That project decides its version.
6
+ */
7
+ export type InstallKind = "checkout" | "global" | "local";
8
+ export declare function installKind(root: string, globalRoot?: string): InstallKind;
9
+ /** Whether version a is higher than version b ("1.10.0" is higher than "1.9.3"). */
10
+ export declare function newer(a: string, b: string): boolean;
11
+ export declare function installed(root?: string): {
12
+ name: string;
13
+ version: string;
14
+ kind: InstallKind;
15
+ root: string;
16
+ };
17
+ /**
18
+ * Update pine, or with `check` only say whether there is something newer. Returns the exit code.
19
+ * `browser` fetches the browser build of the version that is on disk afterwards.
20
+ */
21
+ export declare function update(opts: {
22
+ check: boolean;
23
+ out: (line: string) => void;
24
+ browser: boolean;
25
+ }): Promise<number>;
package/src/update.js ADDED
@@ -0,0 +1,110 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { PINE_ROOT } from "./config.js";
5
+ const real = (p) => {
6
+ try {
7
+ return fs.realpathSync(p);
8
+ }
9
+ catch {
10
+ return path.resolve(p);
11
+ }
12
+ };
13
+ export function installKind(root, globalRoot) {
14
+ if (fs.existsSync(path.join(root, ".git")) && fs.existsSync(path.join(root, "src", "cli.ts")))
15
+ return "checkout";
16
+ if (globalRoot && (real(root) + path.sep).startsWith(real(globalRoot) + path.sep))
17
+ return "global";
18
+ return "local";
19
+ }
20
+ /** Whether version a is higher than version b ("1.10.0" is higher than "1.9.3"). */
21
+ export function newer(a, b) {
22
+ const parse = (v) => v.split("-")[0].split(".").map((n) => Number(n) || 0);
23
+ const [x, y] = [parse(a), parse(b)];
24
+ for (let i = 0; i < 3; i++)
25
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
26
+ return (x[i] ?? 0) > (y[i] ?? 0);
27
+ return false;
28
+ }
29
+ export function installed(root = PINE_ROOT) {
30
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
31
+ const globalRoot = capture("npm", ["root", "-g"]);
32
+ return { name: pkg.name, version: pkg.version, kind: installKind(root, globalRoot), root };
33
+ }
34
+ function capture(cmd, args, cwd) {
35
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
36
+ return r.status === 0 ? r.stdout.trim() : undefined;
37
+ }
38
+ const show = (cmd, args, cwd) => spawnSync(cmd, args, { cwd, stdio: "inherit" }).status ?? 1;
39
+ const RESTART = "A pine that is running keeps the old version until it is restarted: quit the terminal UI and open it again, or restart the daemon.";
40
+ /**
41
+ * Update pine, or with `check` only say whether there is something newer. Returns the exit code.
42
+ * `browser` fetches the browser build of the version that is on disk afterwards.
43
+ */
44
+ export async function update(opts) {
45
+ const { out } = opts;
46
+ const me = installed();
47
+ if (me.kind === "checkout") {
48
+ const branch = capture("git", ["rev-parse", "--abbrev-ref", "HEAD"], me.root) ?? "?";
49
+ if (!capture("git", ["rev-parse", "--abbrev-ref", "@{u}"], me.root)) {
50
+ out(`This is a checkout in ${me.root}, and its branch ${branch} does not follow one on a server, so there is nothing to update from. Set that up with git, or pull by hand.`);
51
+ return 1;
52
+ }
53
+ if (spawnSync("git", ["fetch", "--quiet"], { cwd: me.root, stdio: "inherit" }).status !== 0) {
54
+ out("Could not reach the repository this checkout follows.");
55
+ return 1;
56
+ }
57
+ const behind = Number(capture("git", ["rev-list", "--count", "HEAD..@{u}"], me.root) ?? 0);
58
+ if (!behind) {
59
+ out(`pine ${me.version} (checkout in ${me.root}, branch ${branch}) is up to date.`);
60
+ return 0;
61
+ }
62
+ if (opts.check) {
63
+ out(`${behind} new commit${behind === 1 ? "" : "s"} for this checkout (${me.root}, branch ${branch}). Run "pine update" to get ${behind === 1 ? "it" : "them"}.`);
64
+ return 0;
65
+ }
66
+ out(`Getting ${behind} new commit${behind === 1 ? "" : "s"} ...`);
67
+ // Only ever a fast-forward: your own commits and changes are never rewritten or thrown away.
68
+ if (show("git", ["pull", "--ff-only"], me.root) !== 0) {
69
+ out("git could not simply move this checkout forward (you have commits or changes of your own in the way). Nothing was changed; sort it out with git and run pine update again.");
70
+ return 1;
71
+ }
72
+ out("Installing what the new version depends on ...");
73
+ if (show("npm", ["install", "--no-audit", "--no-fund"], me.root) !== 0)
74
+ return 1;
75
+ }
76
+ else {
77
+ const latest = capture("npm", ["view", me.name, "version"]);
78
+ if (!latest) {
79
+ out(`Could not ask npm for the latest version of ${me.name}. You may be offline.`);
80
+ return 1;
81
+ }
82
+ if (!newer(latest, me.version)) {
83
+ out(`pine ${me.version} is the latest version.`);
84
+ return 0;
85
+ }
86
+ if (me.kind === "local") {
87
+ out(`pine ${latest} is available (this is ${me.version}). This copy is a dependency of the project it is installed in (${me.root}); update it there:\n\n npm install ${me.name}@latest`);
88
+ return opts.check ? 0 : 1;
89
+ }
90
+ if (opts.check) {
91
+ out(`pine ${latest} is available (this is ${me.version}). Run "pine update" to install it.`);
92
+ return 0;
93
+ }
94
+ out(`Updating pine ${me.version} to ${latest} ...`);
95
+ if (show("npm", ["install", "-g", `${me.name}@latest`, "--no-audit", "--no-fund"]) !== 0) {
96
+ out(`npm could not install the new version. Run it yourself to see why:\n\n npm install -g ${me.name}@latest`);
97
+ return 1;
98
+ }
99
+ }
100
+ // The browser build has to match the new version, so the new version's own installer fetches it.
101
+ if (opts.browser) {
102
+ out("Making sure the browser the web tools drive matches ...");
103
+ const status = show(process.execPath, [path.join(me.root, "bin", "pine.js"), "browser-install"]);
104
+ if (status !== 0)
105
+ out(`The browser could not be fetched (exit ${status}). Run "pine browser-install" when you are online.`);
106
+ }
107
+ const now = installed(me.root);
108
+ out(`\npine is now ${now.version}. ${RESTART}`);
109
+ return 0;
110
+ }
package/src/util.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ export declare const sleep: (ms: number) => Promise<void>;
2
+ export declare function expandHome(p: string): string;
3
+ /** Resolve a config path: `~` expands, relative paths are relative to `base`. */
4
+ export declare function resolvePath(p: string, base: string): string;
5
+ /** MQTT-style topic match: `+` is one level, a trailing `#` is the rest (including nothing). */
6
+ export declare function topicMatches(pattern: string, topic: string): boolean;
7
+ export declare const matchesAny: (patterns: readonly string[], topic: string) => boolean;
8
+ export declare function getPath(obj: unknown, dotted: string): unknown;
9
+ /** Replace `{{a.b}}` with values from `ctx`. Objects render as JSON, missing values as "". */
10
+ export declare function render(template: string, ctx: unknown): string;
11
+ /** Render every string inside a JSON-like value. A string that is exactly one `{{path}}` keeps the value's type. */
12
+ export declare function renderDeep(value: unknown, ctx: unknown): unknown;
13
+ /**
14
+ * Declarative filter. Keys are dotted paths into `ctx`; a plain value means equality, an object
15
+ * may use eq, ne, in, gt, gte, lt, lte, exists, contains, regex. All keys must match.
16
+ */
17
+ export declare function matchWhen(when: Record<string, unknown> | undefined, ctx: unknown): boolean;
18
+ /** "90s", "15m", "2h", "1d" or a number of milliseconds. */
19
+ export declare function parseDuration(v: string | number): number;
20
+ /** A string that is safe as one path component: no dots, no separators, never empty. */
21
+ export declare const safeName: (s: string) => string;
22
+ export declare const truncate: (s: string, n: number) => string;
23
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
24
+ export interface Logger {
25
+ debug(msg: string, extra?: unknown): void;
26
+ info(msg: string, extra?: unknown): void;
27
+ warn(msg: string, extra?: unknown): void;
28
+ error(msg: string, extra?: unknown): void;
29
+ child(scope: string): Logger;
30
+ }
31
+ export declare function createLogger(level?: LogLevel, scope?: string, sink?: (line: string) => void): Logger;
32
+ export declare const errText: (e: unknown) => string;
package/src/util.js ADDED
@@ -0,0 +1,116 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4
+ export function expandHome(p) {
5
+ return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
6
+ }
7
+ /** Resolve a config path: `~` expands, relative paths are relative to `base`. */
8
+ export function resolvePath(p, base) {
9
+ const e = expandHome(p);
10
+ return path.isAbsolute(e) ? e : path.resolve(base, e);
11
+ }
12
+ /** MQTT-style topic match: `+` is one level, a trailing `#` is the rest (including nothing). */
13
+ export function topicMatches(pattern, topic) {
14
+ if (pattern === "#" || pattern === topic)
15
+ return true;
16
+ const p = pattern.split("/");
17
+ const t = topic.split("/");
18
+ for (let i = 0; i < p.length; i++) {
19
+ if (p[i] === "#")
20
+ return i === p.length - 1;
21
+ if (i >= t.length)
22
+ return false;
23
+ if (p[i] !== "+" && p[i] !== t[i])
24
+ return false;
25
+ }
26
+ return p.length === t.length;
27
+ }
28
+ export const matchesAny = (patterns, topic) => patterns.some((p) => topicMatches(p, topic));
29
+ export function getPath(obj, dotted) {
30
+ let cur = obj;
31
+ for (const key of dotted.split(".")) {
32
+ if (cur == null)
33
+ return undefined;
34
+ cur = cur[key];
35
+ }
36
+ return cur;
37
+ }
38
+ const asText = (v) => (v == null ? "" : typeof v === "string" ? v : JSON.stringify(v));
39
+ /** Replace `{{a.b}}` with values from `ctx`. Objects render as JSON, missing values as "". */
40
+ export function render(template, ctx) {
41
+ return template.replace(/\{\{\s*([\w.$/-]+)\s*\}\}/g, (_m, p) => asText(getPath(ctx, p)));
42
+ }
43
+ /** Render every string inside a JSON-like value. A string that is exactly one `{{path}}` keeps the value's type. */
44
+ export function renderDeep(value, ctx) {
45
+ if (typeof value === "string") {
46
+ const whole = value.match(/^\{\{\s*([\w.$/-]+)\s*\}\}$/);
47
+ return whole ? getPath(ctx, whole[1]) : render(value, ctx);
48
+ }
49
+ if (Array.isArray(value))
50
+ return value.map((v) => renderDeep(v, ctx));
51
+ if (value && typeof value === "object")
52
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderDeep(v, ctx)]));
53
+ return value;
54
+ }
55
+ /**
56
+ * Declarative filter. Keys are dotted paths into `ctx`; a plain value means equality, an object
57
+ * may use eq, ne, in, gt, gte, lt, lte, exists, contains, regex. All keys must match.
58
+ */
59
+ export function matchWhen(when, ctx) {
60
+ if (!when)
61
+ return true;
62
+ for (const [key, cond] of Object.entries(when)) {
63
+ const v = getPath(ctx, key);
64
+ if (cond === null || typeof cond !== "object" || Array.isArray(cond)) {
65
+ if (JSON.stringify(v) !== JSON.stringify(cond))
66
+ return false;
67
+ continue;
68
+ }
69
+ const c = cond;
70
+ if ("eq" in c && JSON.stringify(v) !== JSON.stringify(c.eq))
71
+ return false;
72
+ if ("ne" in c && JSON.stringify(v) === JSON.stringify(c.ne))
73
+ return false;
74
+ if ("in" in c && !(Array.isArray(c.in) && c.in.some((x) => JSON.stringify(x) === JSON.stringify(v))))
75
+ return false;
76
+ if ("gt" in c && !(v > c.gt))
77
+ return false;
78
+ if ("gte" in c && !(v >= c.gte))
79
+ return false;
80
+ if ("lt" in c && !(v < c.lt))
81
+ return false;
82
+ if ("lte" in c && !(v <= c.lte))
83
+ return false;
84
+ if ("exists" in c && (v !== undefined && v !== null) !== Boolean(c.exists))
85
+ return false;
86
+ if ("contains" in c && !(typeof v === "string" || Array.isArray(v) ? v.includes(c.contains) : false))
87
+ return false;
88
+ if ("regex" in c && !(typeof v === "string" && new RegExp(c.regex, c.flags ?? "").test(v)))
89
+ return false;
90
+ }
91
+ return true;
92
+ }
93
+ /** "90s", "15m", "2h", "1d" or a number of milliseconds. */
94
+ export function parseDuration(v) {
95
+ if (typeof v === "number")
96
+ return v;
97
+ const m = v.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/);
98
+ if (!m)
99
+ throw new Error(`invalid duration: ${v}`);
100
+ return Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] ?? "ms"];
101
+ }
102
+ /** A string that is safe as one path component: no dots, no separators, never empty. */
103
+ export const safeName = (s) => s.replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 80) || "_";
104
+ export const truncate = (s, n) => (s.length > n ? `${s.slice(0, n)}… [${s.length - n} more chars]` : s);
105
+ const LEVELS = ["debug", "info", "warn", "error", "silent"];
106
+ export function createLogger(level = "info", scope = "pine", sink = (l) => process.stderr.write(`${l}\n`)) {
107
+ const min = LEVELS.indexOf(level);
108
+ const emit = (lvl) => (msg, extra) => {
109
+ if (LEVELS.indexOf(lvl) < min)
110
+ return;
111
+ const tail = extra === undefined ? "" : ` ${extra instanceof Error ? (extra.stack ?? extra.message) : typeof extra === "string" ? extra : JSON.stringify(extra)}`;
112
+ sink(`${new Date().toISOString()} ${lvl.toUpperCase().padEnd(5)} [${scope}] ${msg}${tail}`);
113
+ };
114
+ return { debug: emit("debug"), info: emit("info"), warn: emit("warn"), error: emit("error"), child: (s) => createLogger(level, `${scope}:${s}`, sink) };
115
+ }
116
+ export const errText = (e) => (e instanceof Error ? e.message : String(e));
@@ -0,0 +1,20 @@
1
+ export declare const clip: (s: string, n?: number) => string;
2
+ /** A value as an event carries it: text and large structures cut to a sensible length. */
3
+ export declare function shown(value: unknown): unknown;
4
+ /**
5
+ * A value as a status line may say it. A status line is read by agents without the mark that
6
+ * outside content gets, so it only repeats what cannot carry a sentence: a number, an amount, one word.
7
+ */
8
+ export declare function harmless(value: unknown): string;
9
+ /** A regular expression an agent or owner wrote, or an error that says what is wrong with it. */
10
+ export declare function pattern(source: string): RegExp;
11
+ /** What a pattern finds in a text: its first group if it has one, else the whole match. Nothing found is null. */
12
+ export declare function found(re: RegExp, text: string): string | null;
13
+ /** The lines that are new and the lines that are gone between two texts, a handful of each. */
14
+ export declare function lineDiff(before: string, now: string): {
15
+ added: string[];
16
+ removed: string[];
17
+ more?: number;
18
+ };
19
+ /** What a reader of a web page sees, line by line: no scripts, no styles, no markup. */
20
+ export declare function pageText(html: string): string;
@@ -0,0 +1,68 @@
1
+ // What the source types that look at something again and again (http, command) have in common:
2
+ // picking the part that counts out of what they got, and saying what changed in it.
3
+ const LINES = 30;
4
+ const LINE = 300;
5
+ const VALUE = 4000;
6
+ export const clip = (s, n = VALUE) => (s.length > n ? `${s.slice(0, n)}…` : s);
7
+ /** A value as an event carries it: text and large structures cut to a sensible length. */
8
+ export function shown(value) {
9
+ if (typeof value === "string")
10
+ return clip(value);
11
+ const json = JSON.stringify(value) ?? "null";
12
+ return json.length > VALUE ? clip(json) : value;
13
+ }
14
+ /**
15
+ * A value as a status line may say it. A status line is read by agents without the mark that
16
+ * outside content gets, so it only repeats what cannot carry a sentence: a number, an amount, one word.
17
+ */
18
+ export function harmless(value) {
19
+ if (value === null || value === undefined)
20
+ return "nothing";
21
+ const s = typeof value === "string" ? value : JSON.stringify(value);
22
+ return /^[\d.,:%€$£+\- ]{1,24}$/.test(s) || /^[\w.-]{1,32}$/.test(s) ? s : `a value of ${s.length} characters`;
23
+ }
24
+ /** A regular expression an agent or owner wrote, or an error that says what is wrong with it. */
25
+ export function pattern(source) {
26
+ try {
27
+ return new RegExp(source, "m");
28
+ }
29
+ catch (e) {
30
+ throw new Error(`"match" is not a regular expression: ${e instanceof Error ? e.message : e}`);
31
+ }
32
+ }
33
+ /** What a pattern finds in a text: its first group if it has one, else the whole match. Nothing found is null. */
34
+ export function found(re, text) {
35
+ const m = re.exec(text);
36
+ return m ? clip((m[1] ?? m[0]).trim()) : null;
37
+ }
38
+ /** The lines that are new and the lines that are gone between two texts, a handful of each. */
39
+ export function lineDiff(before, now) {
40
+ const lines = (s) => s.split("\n").map((l) => l.trim()).filter(Boolean);
41
+ const a = lines(before);
42
+ const b = lines(now);
43
+ const had = new Set(a);
44
+ const has = new Set(b);
45
+ const added = b.filter((l) => !had.has(l));
46
+ const removed = a.filter((l) => !has.has(l));
47
+ const more = Math.max(0, added.length - LINES) + Math.max(0, removed.length - LINES);
48
+ return { added: added.slice(0, LINES).map((l) => clip(l, LINE)), removed: removed.slice(0, LINES).map((l) => clip(l, LINE)), ...(more ? { more } : {}) };
49
+ }
50
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", euro: "€", pound: "£", copy: "©", ndash: "–", mdash: "—", hellip: "…" };
51
+ /** What a reader of a web page sees, line by line: no scripts, no styles, no markup. */
52
+ export function pageText(html) {
53
+ return html
54
+ .replace(/<!--[\s\S]*?-->/g, "")
55
+ .replace(/<(script|style|noscript|template|svg|head)\b[\s\S]*?<\/\1\s*>/gi, "")
56
+ .replace(/<(br|hr)\b[^>]*>|<\/(p|div|li|tr|h[1-6]|section|article|table|ul|ol|dd|dt|header|footer|main|nav|blockquote|pre|title)\s*>/gi, "\n")
57
+ .replace(/<[^>]+>/g, " ")
58
+ .replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (all, name) => {
59
+ if (name[0] !== "#")
60
+ return ENTITIES[name.toLowerCase()] ?? all;
61
+ const code = name[1].toLowerCase() === "x" ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
62
+ return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all;
63
+ })
64
+ .split("\n")
65
+ .map((l) => l.replace(/\s+/g, " ").trim())
66
+ .filter(Boolean)
67
+ .join("\n");
68
+ }