@rubytech/create-maxy-lite 0.1.5 → 0.1.7
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/index.mjs +15 -14
- package/lib/orchestrate.mjs +2 -2
- package/lib/paths.mjs +40 -0
- package/package.json +1 -1
- package/payload/package.json +2 -1
- package/payload/skills/README.md +26 -0
- package/payload/skills/admin/datetime/SKILL.md +147 -0
- package/payload/skills/admin/session-management/SKILL.md +39 -0
- package/payload/skills/admin/upgrade/SKILL.md +32 -0
- package/payload/skills/browser/SKILL.md +60 -0
- package/payload/skills/browser/scripts/cdp.mjs +134 -0
- package/payload/skills/browser/scripts/pdf.mjs +38 -0
- package/payload/skills/browser/scripts/render.mjs +43 -0
- package/payload/skills/browser/scripts/screenshot.mjs +52 -0
- package/payload/skills/business-assistant/SKILL.md +110 -0
- package/payload/skills/deep-research/SKILL.md +70 -0
- package/payload/skills/deep-research/references/citation-styles.md +52 -0
- package/payload/skills/deep-research/references/research-modes.md +22 -0
- package/payload/skills/deep-research/references/search-strategy.md +24 -0
- package/payload/skills/docs/SKILL.md +23 -0
- package/payload/skills/docs/references/capability-map.md +25 -0
- package/payload/skills/docs/references/getting-started.md +29 -0
- package/payload/skills/docs/references/vault-model.md +40 -0
- package/payload/skills/email-composition/SKILL.md +107 -0
- package/payload/skills/replicate/SKILL.md +63 -0
- package/payload/skills/replicate/scripts/replicate-image.mjs +131 -0
- package/payload/skills/url-get/SKILL.md +48 -0
- package/payload/skills/url-get/scripts/url-get.mjs +93 -0
- package/payload/webchat/inject-line.mjs +11 -0
- package/payload/webchat/package.json +2 -1
- package/payload/webchat/request-handler.mjs +62 -0
- package/payload/webchat/server.mjs +31 -31
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: replicate
|
|
3
|
+
description: >
|
|
4
|
+
Generate an image from a text prompt. Use when the owner wants a picture,
|
|
5
|
+
logo, illustration, diagram, icon, or any visual created from a description.
|
|
6
|
+
Trigger phrases: "generate an image", "make a picture of", "create a logo",
|
|
7
|
+
"draw me", "design an illustration", "I need a graphic of". Produces a real
|
|
8
|
+
image file in the vault.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# replicate
|
|
12
|
+
|
|
13
|
+
Turn a text prompt into an image using the Replicate API. The script picks the model, calls the API, downloads the result into the vault, and prints the file path.
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
The owner wants an image made from a description: a logo, an illustration, a concept sketch, a photoreal scene, a diagram, an icon. This produces an actual file, not a description of one.
|
|
18
|
+
|
|
19
|
+
## Models
|
|
20
|
+
|
|
21
|
+
Pick the model to match the job:
|
|
22
|
+
|
|
23
|
+
- `nano-banana-pro` (default): photorealistic, data-driven visuals.
|
|
24
|
+
- `recraft-v4`: design compositions and branded assets. The only model that can return `svg`.
|
|
25
|
+
- `flux-schnell`: fast drafts and concept sketches.
|
|
26
|
+
|
|
27
|
+
## How to run
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node ~/.maxy-lite/skills/replicate/scripts/replicate-image.mjs \
|
|
31
|
+
--prompt="a calm mountain lake at dawn, soft light" \
|
|
32
|
+
--model=nano-banana-pro \
|
|
33
|
+
--aspect=16:9 \
|
|
34
|
+
--format=png
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `--prompt` (required): what to draw.
|
|
38
|
+
- `--model` (optional, default `nano-banana-pro`): one of the three above.
|
|
39
|
+
- `--aspect` (optional, default `1:1`): one of `1:1`, `4:3`, `3:2`, `16:9`, `21:9`, `9:16`.
|
|
40
|
+
- `--format` (optional, default `png`): `png`, `jpg`, `webp`, or `svg` (svg only with `recraft-v4`).
|
|
41
|
+
|
|
42
|
+
On success the file path prints to stdout and the image is saved under `$LITE_VAULT/Assets/generated/`. Show the owner the path.
|
|
43
|
+
|
|
44
|
+
## Token
|
|
45
|
+
|
|
46
|
+
The script reads `REPLICATE_API_TOKEN` from the environment, or falls back to `~/.replicate/api-token`. The lite runtime supplies the token from its secrets. If neither is set the script exits with `error=no-token`. Tell the owner a Replicate token is needed and stop.
|
|
47
|
+
|
|
48
|
+
## Reading the result
|
|
49
|
+
|
|
50
|
+
- **A file path on stdout, exit 0**: the image was generated and saved.
|
|
51
|
+
- **Non-zero exit**: an error on stderr as `[replicate] error=<code>`:
|
|
52
|
+
- `no-token`: no token configured.
|
|
53
|
+
- `auth`: the token was rejected.
|
|
54
|
+
- `network`: the Replicate API was unreachable.
|
|
55
|
+
- `model`: an unknown model, a bad model option, or the generation failed.
|
|
56
|
+
- `download`: the image was generated but could not be fetched.
|
|
57
|
+
- `disk`: the image could not be written to the vault.
|
|
58
|
+
|
|
59
|
+
Surface the error plainly. Never claim an image was made when the script failed.
|
|
60
|
+
|
|
61
|
+
## Boundaries
|
|
62
|
+
|
|
63
|
+
Generates one image per call. Does not edit existing images, run image-to-image, or upscale. Those need a different model and are not wired here.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// replicate-image — image generation via the Replicate HTTP API. Zero deps.
|
|
3
|
+
//
|
|
4
|
+
// Ported from the maxy-code replicate MCP tool to a single lite CLI script.
|
|
5
|
+
// Uses native fetch against the Replicate predictions API (no replicate SDK, no
|
|
6
|
+
// nanoid). Same three-model registry and aspect-ratio table. The generated image
|
|
7
|
+
// is downloaded into the vault assets directory and its path printed to stdout.
|
|
8
|
+
//
|
|
9
|
+
// Token resolution (parity with maxy-code): REPLICATE_API_TOKEN env first, then
|
|
10
|
+
// ~/.replicate/api-token. Errors go to stderr as [replicate] error=<code> and
|
|
11
|
+
// exit non-zero. Codes: usage, no-token, model, auth, network, download, disk.
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
const MODELS = {
|
|
18
|
+
"nano-banana-pro": { owner: "google", name: "nano-banana-pro" },
|
|
19
|
+
"recraft-v4": { owner: "recraft-ai", name: "recraft-v4", svg: "recraft-v4-svg" },
|
|
20
|
+
"flux-schnell": { owner: "black-forest-labs", name: "flux-schnell" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ASPECT = {
|
|
24
|
+
"1:1": { width: 1024, height: 1024 },
|
|
25
|
+
"4:3": { width: 1024, height: 768 },
|
|
26
|
+
"3:2": { width: 1024, height: 683 },
|
|
27
|
+
"16:9": { width: 1024, height: 576 },
|
|
28
|
+
"21:9": { width: 1344, height: 576 },
|
|
29
|
+
"9:16": { width: 576, height: 1024 },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const fail = (code, msg) => {
|
|
33
|
+
console.error(`[replicate] error=${code} detail=${JSON.stringify(msg)}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const args = Object.fromEntries(
|
|
38
|
+
process.argv.slice(2).map((a) => {
|
|
39
|
+
const m = a.match(/^--([^=]+)=(.*)$/);
|
|
40
|
+
return m ? [m[1], m[2]] : [a, true];
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
const prompt = args.prompt;
|
|
44
|
+
const model = args.model || "nano-banana-pro";
|
|
45
|
+
const aspectRatio = args.aspect || "1:1";
|
|
46
|
+
const outputFormat = args.format || "png";
|
|
47
|
+
if (!prompt || prompt === true) {
|
|
48
|
+
fail("usage", "usage: replicate-image.mjs --prompt=<text> [--model=] [--aspect=] [--format=]");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const token =
|
|
52
|
+
process.env.REPLICATE_API_TOKEN ||
|
|
53
|
+
(existsSync(join(homedir(), ".replicate", "api-token"))
|
|
54
|
+
? readFileSync(join(homedir(), ".replicate", "api-token"), "utf-8").trim()
|
|
55
|
+
: "");
|
|
56
|
+
if (!token) fail("no-token", "no Replicate token (set REPLICATE_API_TOKEN or ~/.replicate/api-token)");
|
|
57
|
+
|
|
58
|
+
const entry = MODELS[model];
|
|
59
|
+
if (!entry) fail("model", `unknown model: ${model} (use nano-banana-pro, recraft-v4, or flux-schnell)`);
|
|
60
|
+
if (outputFormat === "svg" && model !== "recraft-v4") fail("model", "svg output is only available with recraft-v4");
|
|
61
|
+
const name = outputFormat === "svg" && entry.svg ? entry.svg : entry.name;
|
|
62
|
+
|
|
63
|
+
const dims = ASPECT[aspectRatio] ?? ASPECT["1:1"];
|
|
64
|
+
const input =
|
|
65
|
+
model === "flux-schnell"
|
|
66
|
+
? { prompt, aspect_ratio: aspectRatio, ...(outputFormat !== "svg" ? { output_format: outputFormat } : {}) }
|
|
67
|
+
: { prompt, width: dims.width, height: dims.height, ...(outputFormat !== "svg" ? { output_format: outputFormat } : {}) };
|
|
68
|
+
|
|
69
|
+
// Create a prediction against the model. Prefer:wait blocks until the prediction
|
|
70
|
+
// is terminal where the model supports it; otherwise we poll the get URL.
|
|
71
|
+
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Prefer: "wait" };
|
|
72
|
+
let pred;
|
|
73
|
+
try {
|
|
74
|
+
const r = await fetch(`https://api.replicate.com/v1/models/${entry.owner}/${name}/predictions`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers,
|
|
77
|
+
body: JSON.stringify({ input }),
|
|
78
|
+
});
|
|
79
|
+
if (r.status === 401 || r.status === 403) fail("auth", "Replicate token rejected (invalid or revoked)");
|
|
80
|
+
if (!r.ok) fail("model", `Replicate API HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
|
81
|
+
pred = await r.json();
|
|
82
|
+
} catch (err) {
|
|
83
|
+
fail("network", err instanceof Error ? err.message : String(err));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
while (pred.status && pred.status !== "succeeded" && pred.status !== "failed" && pred.status !== "canceled") {
|
|
87
|
+
if (!pred.urls?.get) fail("model", "prediction is not terminal but carries no poll URL");
|
|
88
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
89
|
+
try {
|
|
90
|
+
const r = await fetch(pred.urls.get, { headers: { Authorization: `Bearer ${token}` } });
|
|
91
|
+
pred = await r.json();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
fail("network", err instanceof Error ? err.message : String(err));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (pred.status !== "succeeded") {
|
|
97
|
+
fail("model", `generation ${pred.status}: ${JSON.stringify(pred.error ?? "").slice(0, 200)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Replicate output is a URL string or an array of URL strings, depending on model.
|
|
101
|
+
const out = pred.output;
|
|
102
|
+
const imageUrl = Array.isArray(out)
|
|
103
|
+
? out.find((u) => typeof u === "string" && u.startsWith("http"))
|
|
104
|
+
: typeof out === "string" && out.startsWith("http")
|
|
105
|
+
? out
|
|
106
|
+
: null;
|
|
107
|
+
if (!imageUrl) fail("model", "generation returned unexpected output format");
|
|
108
|
+
|
|
109
|
+
let buf;
|
|
110
|
+
try {
|
|
111
|
+
const r = await fetch(imageUrl);
|
|
112
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
113
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
114
|
+
} catch (err) {
|
|
115
|
+
fail("download", err instanceof Error ? err.message : String(err));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ext = outputFormat === "svg" ? "svg" : outputFormat;
|
|
119
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
120
|
+
const vault = process.env.LITE_VAULT || join(homedir(), ".maxy-lite", "vault");
|
|
121
|
+
const genDir = resolve(vault, "Assets", "generated");
|
|
122
|
+
const file = resolve(genDir, `${date}-${randomUUID().slice(0, 6)}.${ext}`);
|
|
123
|
+
try {
|
|
124
|
+
mkdirSync(genDir, { recursive: true });
|
|
125
|
+
writeFileSync(file, buf);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
fail("disk", err instanceof Error ? err.message : String(err));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.error(`[replicate] complete model=${model} format=${outputFormat} aspect=${aspectRatio} file=${file}`);
|
|
131
|
+
process.stdout.write(file + "\n");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: url-get
|
|
3
|
+
description: >
|
|
4
|
+
Fetch a web page faithfully and read its content as markdown. Use when the
|
|
5
|
+
owner gives a URL and wants the page read, quoted, or summarised, or when you
|
|
6
|
+
need the verbatim text of an article, doc, or listing. Trigger phrases:
|
|
7
|
+
"fetch this page", "get the content of", "read this article", "what does this
|
|
8
|
+
link say", "pull the text from", "open this URL and tell me". For
|
|
9
|
+
JavaScript-heavy pages that come back empty, fall to the browser skill.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# url-get
|
|
13
|
+
|
|
14
|
+
Retrieve a web page exactly as the server sent it and read it as markdown. There is no model in the fetch path, so nothing is summarised or inferred on the way in. The script prints the cleaned markdown and writes the full verbatim copy to a scratch file.
|
|
15
|
+
|
|
16
|
+
## When to use
|
|
17
|
+
|
|
18
|
+
- The owner gives a URL and wants it read, quoted, fact-checked, or summarised.
|
|
19
|
+
- You need the real text of a page rather than a recollection of it.
|
|
20
|
+
- A research step (see `[[deep-research]]`) needs a source fetched.
|
|
21
|
+
|
|
22
|
+
Use this for server-rendered HTML, which is most of the web. For a page that runs its content in the browser (a client-rendered app), this returns little or nothing; switch to the `[[browser]]` render script.
|
|
23
|
+
|
|
24
|
+
## How to run
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node ~/.maxy-lite/skills/url-get/scripts/url-get.mjs "https://example.com/article"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The cleaned markdown prints to stdout. The full copy is written to `$LITE_SCRATCH/url-get/<hash>.md` (default `~/.maxy-lite/scratch/url-get/`) so a long page does not have to be re-fetched.
|
|
31
|
+
|
|
32
|
+
## Reading the result
|
|
33
|
+
|
|
34
|
+
- **Markdown on stdout, exit 0**: the page text. Read it, then answer.
|
|
35
|
+
- **Empty stdout, exit 0**: the page is HTTP 200 but has no extractable text. This is the client-rendered-shell signal. Re-fetch with the `[[browser]]` render script, which runs the page's JavaScript first.
|
|
36
|
+
- **Non-zero exit**: a fetch error printed to stderr as `[url-get] error=<code>`:
|
|
37
|
+
- `fetch`: network failure or timeout.
|
|
38
|
+
- `http`: the server returned a non-2xx status.
|
|
39
|
+
- `non-html`: the URL is a PDF, image, or other non-HTML type this script does not handle.
|
|
40
|
+
- `challenge`: the response was a bot-challenge interstitial, not content. Try the `[[browser]]` render script, which presents a real browser.
|
|
41
|
+
|
|
42
|
+
Surface the error to the owner plainly. Do not invent the page contents.
|
|
43
|
+
|
|
44
|
+
## Boundaries
|
|
45
|
+
|
|
46
|
+
- Reads one URL per call. For several pages, call it once per URL.
|
|
47
|
+
- Does not run page JavaScript, submit forms, or follow in-page actions. Those are the `[[browser]]` skill.
|
|
48
|
+
- Does not fetch PDFs or images. A PDF link needs a different tool.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// url-get — faithful HTTP page fetch into markdown. No model in the path.
|
|
3
|
+
//
|
|
4
|
+
// Ported from the maxy-code url-get MCP tool to a single lite CLI script.
|
|
5
|
+
// One mechanism: fetch the URL, refuse non-HTML and bot-challenge pages, convert
|
|
6
|
+
// the HTML to markdown verbatim, write the full copy to a scratch reference file,
|
|
7
|
+
// and print the same (capped) markdown to stdout. Nothing is summarised — a
|
|
8
|
+
// caller that needs a summary reads the text and summarises in its own context.
|
|
9
|
+
//
|
|
10
|
+
// FETCH -> HTTP STATUS -> CONTENT-TYPE -> CHALLENGE -> HTML->MD -> WRITE REF -> PRINT
|
|
11
|
+
//
|
|
12
|
+
// Errors go to stderr as [url-get] error=<code> and exit non-zero. Codes:
|
|
13
|
+
// fetch network failure / timeout
|
|
14
|
+
// http non-2xx response
|
|
15
|
+
// non-html content-type is not HTML or text/plain
|
|
16
|
+
// challenge response is a bot-challenge interstitial, not content
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
22
|
+
|
|
23
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
24
|
+
const TEXT_CAP = 100_000;
|
|
25
|
+
const UA =
|
|
26
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
27
|
+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
28
|
+
|
|
29
|
+
// Strong, low-false-positive markers of a bot-challenge interstitial. These do
|
|
30
|
+
// not occur in legitimate article bodies; a match means the response is a
|
|
31
|
+
// challenge page, not the requested content.
|
|
32
|
+
const CHALLENGE = [
|
|
33
|
+
"/cdn-cgi/challenge-platform/",
|
|
34
|
+
"cf-browser-verification",
|
|
35
|
+
"enable javascript and cookies to continue",
|
|
36
|
+
"checking your browser before accessing",
|
|
37
|
+
"attention required! | cloudflare",
|
|
38
|
+
"<title>just a moment",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const fail = (code, msg) => {
|
|
42
|
+
console.error(`[url-get] error=${code} detail=${JSON.stringify(msg)}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const url = process.argv[2];
|
|
47
|
+
if (!url) fail("usage", "usage: url-get.mjs <url>");
|
|
48
|
+
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(url, {
|
|
52
|
+
redirect: "follow",
|
|
53
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
54
|
+
headers: { "User-Agent": UA, Accept: "text/html,application/xhtml+xml" },
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
fail("fetch", err instanceof Error ? err.message : String(err));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!res.ok) fail("http", `HTTP ${res.status}`);
|
|
61
|
+
|
|
62
|
+
const ct = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
63
|
+
if (ct && !ct.includes("html") && !ct.includes("text/plain")) {
|
|
64
|
+
fail("non-html", `unsupported content-type: ${ct} (url-get handles server-rendered HTML only)`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const html = await res.text();
|
|
68
|
+
if (CHALLENGE.some((sig) => html.slice(0, 8000).toLowerCase().includes(sig))) {
|
|
69
|
+
fail("challenge", "page returned a bot-challenge interstitial, not content");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const markdown = NodeHtmlMarkdown.translate(html).trim();
|
|
73
|
+
|
|
74
|
+
// The full verbatim markdown is the ground truth on disk; stdout is the same
|
|
75
|
+
// content capped so a large page does not flood the caller's context. An empty
|
|
76
|
+
// markdown body (textLength=0) on an HTTP 200 is the JavaScript-rendered-shell
|
|
77
|
+
// signal: retry with the browser render script.
|
|
78
|
+
const scratch = process.env.LITE_SCRATCH || join(homedir(), ".maxy-lite", "scratch");
|
|
79
|
+
const dir = join(scratch, "url-get");
|
|
80
|
+
const hash = createHash("sha256").update(url).digest("hex").slice(0, 32);
|
|
81
|
+
const refPath = join(dir, `${hash}.md`);
|
|
82
|
+
const fetchedAt = new Date().toISOString();
|
|
83
|
+
await mkdir(dir, { recursive: true });
|
|
84
|
+
await writeFile(
|
|
85
|
+
refPath,
|
|
86
|
+
`<!-- url-get source=${url} fetchedAt=${fetchedAt} httpStatus=${res.status} -->\n\n${markdown}\n`,
|
|
87
|
+
"utf-8",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
console.error(
|
|
91
|
+
`[url-get] url=${url} httpStatus=${res.status} textLength=${markdown.length} refPath=${refPath}`,
|
|
92
|
+
);
|
|
93
|
+
process.stdout.write(markdown.slice(0, TEXT_CAP));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Turn a message into the byte sequence written into the claude pty.
|
|
2
|
+
//
|
|
3
|
+
// The TUI submits on a carriage return, so an embedded newline would submit
|
|
4
|
+
// early and truncate the message. Collapse every newline run to a single space,
|
|
5
|
+
// then append the one trailing CR that submits the whole line. Shared by the
|
|
6
|
+
// browser send path and the channel `/inject` path so both are protected.
|
|
7
|
+
|
|
8
|
+
/** @param {string} text @returns {string} */
|
|
9
|
+
export function ptyLine(text) {
|
|
10
|
+
return text.replace(/[\r\n]+/g, ' ') + '\r'
|
|
11
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// HTTP request router for the webchat relay.
|
|
2
|
+
//
|
|
3
|
+
// Two surfaces:
|
|
4
|
+
// - GET /, /app.js, /style.css → static files from the public dir.
|
|
5
|
+
// - POST /inject {text} → deliver an inbound message to the agent.
|
|
6
|
+
//
|
|
7
|
+
// The `/inject` route is how the on-device channel servers (Telegram, WhatsApp)
|
|
8
|
+
// reach the one shared agent: they POST a context-tagged line here and it is
|
|
9
|
+
// written into the same pty a browser send uses. `onInject` is injected so the
|
|
10
|
+
// router stays testable without a live pty.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
const CONTENT_TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }
|
|
16
|
+
const STATIC = { '/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css' }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ publicDir: string, onInject: (text: string) => void }} deps
|
|
20
|
+
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
|
|
21
|
+
*/
|
|
22
|
+
export function makeRequestHandler({ publicDir, onInject }) {
|
|
23
|
+
return (req, res) => {
|
|
24
|
+
if (req.method === 'POST' && req.url === '/inject') {
|
|
25
|
+
let body = ''
|
|
26
|
+
req.on('data', (c) => (body += c))
|
|
27
|
+
req.on('end', () => {
|
|
28
|
+
let text
|
|
29
|
+
try {
|
|
30
|
+
text = JSON.parse(body).text
|
|
31
|
+
} catch {
|
|
32
|
+
text = undefined
|
|
33
|
+
}
|
|
34
|
+
if (typeof text !== 'string' || text === '') {
|
|
35
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
36
|
+
res.end('{"ok":false,"error":"text required"}')
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
onInject(text)
|
|
40
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
41
|
+
res.end('{"ok":true}')
|
|
42
|
+
})
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const file = STATIC[req.url]
|
|
47
|
+
if (!file) {
|
|
48
|
+
res.writeHead(404)
|
|
49
|
+
res.end('not found')
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
fs.readFile(path.join(publicDir, file), (err, data) => {
|
|
53
|
+
if (err) {
|
|
54
|
+
res.writeHead(404)
|
|
55
|
+
res.end('not found')
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
res.writeHead(200, { 'content-type': CONTENT_TYPES[path.extname(file)] || 'text/plain' })
|
|
59
|
+
res.end(data)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -22,6 +22,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
22
22
|
import pty from 'node-pty'
|
|
23
23
|
import { WebSocketServer } from 'ws'
|
|
24
24
|
import { parseTranscript } from './parse-transcript.mjs'
|
|
25
|
+
import { ptyLine } from './inject-line.mjs'
|
|
26
|
+
import { makeRequestHandler } from './request-handler.mjs'
|
|
25
27
|
|
|
26
28
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
27
29
|
const PUBLIC_DIR = path.join(HERE, 'public')
|
|
@@ -166,28 +168,34 @@ setInterval(() => {
|
|
|
166
168
|
}
|
|
167
169
|
}, 500)
|
|
168
170
|
|
|
169
|
-
// ----
|
|
171
|
+
// ---- deliver a message to the agent -----------------------------------------
|
|
172
|
+
|
|
173
|
+
// Start a turn and write the message into the pty. The single source of truth
|
|
174
|
+
// for both the browser send path and the channel `/inject` path, so a channel
|
|
175
|
+
// inbound is delivered byte-for-byte the same way a browser message is. An
|
|
176
|
+
// embedded newline is collapsed by ptyLine so the TUI is not submitted early.
|
|
177
|
+
function submitToAgent(text) {
|
|
178
|
+
turn += 1
|
|
179
|
+
turnActive = true
|
|
180
|
+
turnSawGrowth = false
|
|
181
|
+
turnStart = Date.now()
|
|
182
|
+
lastActivity = Date.now()
|
|
183
|
+
log('inbound', { turn, chars: text.length })
|
|
184
|
+
child.write(ptyLine(text))
|
|
185
|
+
log('spawn', { turn, pty: true, pid: child.pid })
|
|
186
|
+
}
|
|
170
187
|
|
|
171
|
-
|
|
172
|
-
const STATIC = { '/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css' }
|
|
188
|
+
// ---- transport: HTTP static + /inject + WebSocket ---------------------------
|
|
173
189
|
|
|
174
|
-
const server = http.createServer(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
res.writeHead(404)
|
|
184
|
-
res.end('not found')
|
|
185
|
-
return
|
|
186
|
-
}
|
|
187
|
-
res.writeHead(200, { 'content-type': CONTENT_TYPES[path.extname(file)] || 'text/plain' })
|
|
188
|
-
res.end(data)
|
|
189
|
-
})
|
|
190
|
-
})
|
|
190
|
+
const server = http.createServer(
|
|
191
|
+
makeRequestHandler({
|
|
192
|
+
publicDir: PUBLIC_DIR,
|
|
193
|
+
onInject: (text) => {
|
|
194
|
+
log('inject', { chars: text.length })
|
|
195
|
+
submitToAgent(text)
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
191
199
|
|
|
192
200
|
const wss = new WebSocketServer({ server, path: '/ws' })
|
|
193
201
|
|
|
@@ -209,18 +217,10 @@ wss.on('connection', (ws) => {
|
|
|
209
217
|
return
|
|
210
218
|
}
|
|
211
219
|
if (m.type !== 'send' || typeof m.text !== 'string' || m.text === '') return
|
|
212
|
-
turn += 1
|
|
213
|
-
turnActive = true
|
|
214
|
-
turnSawGrowth = false
|
|
215
|
-
turnStart = Date.now()
|
|
216
|
-
lastActivity = Date.now()
|
|
217
|
-
log('inbound', { turn, chars: m.text.length })
|
|
218
220
|
// Deliver to claude by writing into the pty, then submit with a carriage
|
|
219
|
-
// return. This is the TUI typing model — the same input path ttyd uses
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
child.write(m.text.replace(/[\r\n]+/g, ' ') + '\r')
|
|
223
|
-
log('spawn', { turn, pty: true, pid: child.pid })
|
|
221
|
+
// return. This is the TUI typing model — the same input path ttyd uses, and
|
|
222
|
+
// the same path the channel `/inject` route uses via submitToAgent.
|
|
223
|
+
submitToAgent(m.text)
|
|
224
224
|
})
|
|
225
225
|
})
|
|
226
226
|
|