neoctl-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -0
- package/bin/neow.mjs +137 -0
- package/core-runtime.mjs +55 -0
- package/cpa-quota.mjs +209 -0
- package/dist/assets/index-BSFq6wfd.css +1 -0
- package/dist/assets/index-H7num-0s.js +119 -0
- package/dist/favicon.svg +3 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +28 -0
- package/memory-monitor.mjs +150 -0
- package/package.json +64 -0
- package/plugin-settings.mjs +67 -0
- package/plugins/downloads/downloads.mjs +147 -0
- package/plugins/downloads/index.mjs +19 -0
- package/plugins/downloads/neo-plugin.json +9 -0
- package/plugins/xhs-artifact/artifacts.mjs +388 -0
- package/plugins/xhs-artifact/editor-page.mjs +73 -0
- package/plugins/xhs-artifact/index.mjs +48 -0
- package/plugins/xhs-artifact/neo-plugin.json +9 -0
- package/plugins/xhs-artifact/xhs-artifact-contract.mjs +125 -0
- package/plugins.mjs +111 -0
- package/runtime-router-cleanup.mjs +152 -0
- package/runtime-workspaces.mjs +515 -0
- package/server.mjs +450 -0
- package/tool-settings.mjs +80 -0
package/dist/favicon.svg
ADDED
package/dist/icons.svg
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
3
|
+
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
4
|
+
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
5
|
+
</symbol>
|
|
6
|
+
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
7
|
+
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
8
|
+
</symbol>
|
|
9
|
+
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
10
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
11
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
12
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
13
|
+
</symbol>
|
|
14
|
+
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
15
|
+
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
16
|
+
</symbol>
|
|
17
|
+
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
18
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
19
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
20
|
+
</symbol>
|
|
21
|
+
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
22
|
+
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
23
|
+
</symbol>
|
|
24
|
+
</svg>
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<meta name="theme-color" content="#ffffff" />
|
|
8
|
+
<script>
|
|
9
|
+
(() => {
|
|
10
|
+
try {
|
|
11
|
+
const saved = localStorage.getItem('neoctl-web.theme')
|
|
12
|
+
const theme = saved === 'light' || saved === 'dark'
|
|
13
|
+
? saved
|
|
14
|
+
: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
15
|
+
document.documentElement.dataset.theme = theme
|
|
16
|
+
document.documentElement.style.colorScheme = theme
|
|
17
|
+
document.querySelector('meta[name="theme-color"]').content = theme === 'dark' ? '#0c0c0f' : '#ffffff'
|
|
18
|
+
} catch {}
|
|
19
|
+
})()
|
|
20
|
+
</script>
|
|
21
|
+
<title>neo runtime</title>
|
|
22
|
+
<script type="module" crossorigin src="/assets/index-H7num-0s.js"></script>
|
|
23
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BSFq6wfd.css">
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<div id="app"></div>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import v8 from 'node:v8';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SAMPLE_MS = 60_000;
|
|
6
|
+
const DEFAULT_RETENTION_MS = 24 * 60 * 60_000;
|
|
7
|
+
const DEFAULT_PUBLIC_SAMPLES = 60;
|
|
8
|
+
const MAX_PUBLIC_SAMPLES = 60;
|
|
9
|
+
const DEFAULT_MAX_PERSISTED_SAMPLES = 1_440;
|
|
10
|
+
const DEFAULT_MAX_PERSISTED_BYTES = 256 * 1024;
|
|
11
|
+
|
|
12
|
+
export function createMemoryMonitor({
|
|
13
|
+
storageFile,
|
|
14
|
+
sampleMs = DEFAULT_SAMPLE_MS,
|
|
15
|
+
retentionMs = DEFAULT_RETENTION_MS,
|
|
16
|
+
publicSamples = DEFAULT_PUBLIC_SAMPLES,
|
|
17
|
+
maxPersistedSamples = DEFAULT_MAX_PERSISTED_SAMPLES,
|
|
18
|
+
maxPersistedBytes = DEFAULT_MAX_PERSISTED_BYTES,
|
|
19
|
+
memoryUsage = () => process.memoryUsage(),
|
|
20
|
+
heapStatistics = () => v8.getHeapStatistics(),
|
|
21
|
+
now = () => Date.now(),
|
|
22
|
+
} = {}) {
|
|
23
|
+
const normalizedSampleMs = positiveInteger(sampleMs, DEFAULT_SAMPLE_MS);
|
|
24
|
+
const normalizedRetentionMs = Math.max(normalizedSampleMs, positiveInteger(retentionMs, DEFAULT_RETENTION_MS));
|
|
25
|
+
const persistedSampleLimit = Math.max(2, positiveInteger(maxPersistedSamples, DEFAULT_MAX_PERSISTED_SAMPLES));
|
|
26
|
+
const persistedByteLimit = Math.max(1_024, positiveInteger(maxPersistedBytes, DEFAULT_MAX_PERSISTED_BYTES));
|
|
27
|
+
const maxSamples = Math.min(persistedSampleLimit, Math.max(2, Math.ceil(normalizedRetentionMs / normalizedSampleMs) + 1));
|
|
28
|
+
const publicLimit = Math.min(MAX_PUBLIC_SAMPLES, Math.max(2, positiveInteger(publicSamples, DEFAULT_PUBLIC_SAMPLES)));
|
|
29
|
+
let samples = [];
|
|
30
|
+
let timer;
|
|
31
|
+
let writeQueue = Promise.resolve();
|
|
32
|
+
|
|
33
|
+
async function start() {
|
|
34
|
+
samples = (await readSamples(storageFile, persistedByteLimit)).slice(-maxSamples);
|
|
35
|
+
await sample();
|
|
36
|
+
timer = setInterval(() => { void sample(); }, normalizedSampleMs);
|
|
37
|
+
timer.unref?.();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stop() {
|
|
41
|
+
if (timer) clearInterval(timer);
|
|
42
|
+
timer = undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function sample() {
|
|
46
|
+
const timestamp = now();
|
|
47
|
+
const usage = memoryUsage();
|
|
48
|
+
const heap = heapStatistics();
|
|
49
|
+
const next = {
|
|
50
|
+
at: new Date(timestamp).toISOString(),
|
|
51
|
+
rss: byteValue(usage.rss),
|
|
52
|
+
heapUsed: byteValue(usage.heapUsed),
|
|
53
|
+
heapTotal: byteValue(usage.heapTotal),
|
|
54
|
+
heapLimit: byteValue(heap.heap_size_limit),
|
|
55
|
+
external: byteValue(usage.external),
|
|
56
|
+
arrayBuffers: byteValue(usage.arrayBuffers),
|
|
57
|
+
};
|
|
58
|
+
const cutoff = timestamp - normalizedRetentionMs;
|
|
59
|
+
samples = [...samples, next]
|
|
60
|
+
.filter((entry) => Date.parse(entry.at) >= cutoff)
|
|
61
|
+
.slice(-maxSamples);
|
|
62
|
+
const persisted = serializePersistedState({
|
|
63
|
+
version: 1,
|
|
64
|
+
sampleMs: normalizedSampleMs,
|
|
65
|
+
retentionMs: normalizedRetentionMs,
|
|
66
|
+
maxPersistedSamples: maxSamples,
|
|
67
|
+
maxPersistedBytes: persistedByteLimit,
|
|
68
|
+
samples,
|
|
69
|
+
}, persistedByteLimit);
|
|
70
|
+
samples = persisted.samples;
|
|
71
|
+
writeQueue = writeQueue
|
|
72
|
+
.catch(() => undefined)
|
|
73
|
+
.then(() => writeSamples(storageFile, persisted.text));
|
|
74
|
+
await writeQueue;
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getPublicState() {
|
|
79
|
+
return {
|
|
80
|
+
sampleMs: normalizedSampleMs,
|
|
81
|
+
retentionMs: normalizedRetentionMs,
|
|
82
|
+
maxPersistedSamples: maxSamples,
|
|
83
|
+
maxPersistedBytes: persistedByteLimit,
|
|
84
|
+
current: samples.at(-1) || null,
|
|
85
|
+
history: samples.slice(-publicLimit),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { start, stop, sample, getPublicState };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function positiveInteger(value, fallback) {
|
|
93
|
+
const number = Number(value);
|
|
94
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function byteValue(value) {
|
|
98
|
+
const number = Number(value);
|
|
99
|
+
return Number.isFinite(number) && number >= 0 ? Math.round(number) : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readSamples(storageFile, maxBytes) {
|
|
103
|
+
if (!storageFile) return [];
|
|
104
|
+
try {
|
|
105
|
+
const info = await fsp.stat(storageFile);
|
|
106
|
+
if (info.size > maxBytes) {
|
|
107
|
+
console.warn(`memory monitor data exceeds ${maxBytes} bytes; resetting history`);
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const parsed = JSON.parse(await fsp.readFile(storageFile, 'utf8'));
|
|
111
|
+
return Array.isArray(parsed?.samples) ? parsed.samples.map(normalizeSample).filter(Boolean) : [];
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code !== 'ENOENT') console.warn(`failed to read memory monitor data: ${error.message || error}`);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeSample(value) {
|
|
119
|
+
if (!value || typeof value !== 'object' || !Number.isFinite(Date.parse(value.at))) return null;
|
|
120
|
+
return {
|
|
121
|
+
at: new Date(Date.parse(value.at)).toISOString(),
|
|
122
|
+
rss: byteValue(value.rss),
|
|
123
|
+
heapUsed: byteValue(value.heapUsed),
|
|
124
|
+
heapTotal: byteValue(value.heapTotal),
|
|
125
|
+
heapLimit: byteValue(value.heapLimit),
|
|
126
|
+
external: byteValue(value.external),
|
|
127
|
+
arrayBuffers: byteValue(value.arrayBuffers),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function serializePersistedState(value, maxBytes) {
|
|
132
|
+
let samples = value.samples;
|
|
133
|
+
let text = `${JSON.stringify({ ...value, samples })}\n`;
|
|
134
|
+
while (Buffer.byteLength(text, 'utf8') > maxBytes && samples.length > 1) {
|
|
135
|
+
const excessRatio = maxBytes / Buffer.byteLength(text, 'utf8');
|
|
136
|
+
const keep = Math.max(1, Math.min(samples.length - 1, Math.floor(samples.length * excessRatio * 0.95)));
|
|
137
|
+
samples = samples.slice(-keep);
|
|
138
|
+
text = `${JSON.stringify({ ...value, samples })}\n`;
|
|
139
|
+
}
|
|
140
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes) throw new Error(`memory monitor state exceeds ${maxBytes} bytes`);
|
|
141
|
+
return { samples, text };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function writeSamples(storageFile, text) {
|
|
145
|
+
if (!storageFile) return;
|
|
146
|
+
await fsp.mkdir(path.dirname(storageFile), { recursive: true });
|
|
147
|
+
const temporary = `${storageFile}.${process.pid}.tmp`;
|
|
148
|
+
await fsp.writeFile(temporary, text, 'utf8');
|
|
149
|
+
await fsp.rename(temporary, storageFile);
|
|
150
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "neoctl-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Neo browser workspace with an embedded agent runtime.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"predev": "npm --prefix ../engine run build",
|
|
11
|
+
"dev": "node scripts/dev.mjs --core local",
|
|
12
|
+
"dev:package": "node scripts/dev.mjs --core package",
|
|
13
|
+
"dev:ui": "vite",
|
|
14
|
+
"test:xhs": "node --test artifacts.test.mjs",
|
|
15
|
+
"test:runtime": "node --test runtime-router-cleanup.test.mjs runtime-workspaces.test.mjs",
|
|
16
|
+
"test:plugins": "npm --prefix ../engine run build && node --test plugins.test.mjs plugin-settings.test.mjs tool-settings.test.mjs",
|
|
17
|
+
"test:monitoring": "node --test cpa-quota.test.mjs memory-monitor.test.mjs",
|
|
18
|
+
"build": "vite build",
|
|
19
|
+
"test:cli": "node --test neow.test.mjs",
|
|
20
|
+
"prestart": "npm run build",
|
|
21
|
+
"start": "node server.mjs",
|
|
22
|
+
"preview": "vite preview",
|
|
23
|
+
"neo": "neo",
|
|
24
|
+
"neo:web": "neo -web",
|
|
25
|
+
"neo:login": "neo -login",
|
|
26
|
+
"neo:help": "neo -help",
|
|
27
|
+
"prepack": "npm run build && npm run test:cli"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@tanstack/vue-virtual": "^3.13.36",
|
|
31
|
+
"highlight.js": "^11.11.1",
|
|
32
|
+
"marked": "^18.0.3",
|
|
33
|
+
"neoctl": "0.2.31",
|
|
34
|
+
"streaming-markdown": "^0.2.15",
|
|
35
|
+
"vue": "^3.5.13"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@vitejs/plugin-vue": "^5.2.4",
|
|
39
|
+
"vite": "^5.4.21"
|
|
40
|
+
},
|
|
41
|
+
"bin": {
|
|
42
|
+
"neow": "bin/neow.mjs"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"bin/neow.mjs",
|
|
46
|
+
"dist",
|
|
47
|
+
"plugins",
|
|
48
|
+
"server.mjs",
|
|
49
|
+
"core-runtime.mjs",
|
|
50
|
+
"plugins.mjs",
|
|
51
|
+
"plugin-settings.mjs",
|
|
52
|
+
"tool-settings.mjs",
|
|
53
|
+
"runtime-workspaces.mjs",
|
|
54
|
+
"runtime-router-cleanup.mjs",
|
|
55
|
+
"cpa-quota.mjs",
|
|
56
|
+
"memory-monitor.mjs",
|
|
57
|
+
"README.md"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"keywords": ["neoctl", "agent", "vue", "web"],
|
|
63
|
+
"license": "Apache-2.0"
|
|
64
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function createWebPluginSettings(storageFile) {
|
|
5
|
+
let state = await readState(storageFile);
|
|
6
|
+
let writeQueue = Promise.resolve();
|
|
7
|
+
|
|
8
|
+
function snapshot() {
|
|
9
|
+
return structuredClone(state);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function update(next) {
|
|
13
|
+
state = next;
|
|
14
|
+
writeQueue = writeQueue.catch(() => undefined).then(() => writeState(storageFile, state));
|
|
15
|
+
await writeQueue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
snapshot,
|
|
20
|
+
globalEnabledIds() {
|
|
21
|
+
return Array.isArray(state.globalEnabled) ? [...state.globalEnabled] : undefined;
|
|
22
|
+
},
|
|
23
|
+
sessionOverrides(sessionId) {
|
|
24
|
+
const value = state.sessions[String(sessionId || '')];
|
|
25
|
+
return value && typeof value === 'object' ? { ...value } : {};
|
|
26
|
+
},
|
|
27
|
+
async setGlobalEnabled(ids) {
|
|
28
|
+
await update({ ...state, globalEnabled: [...new Set(ids)].sort() });
|
|
29
|
+
},
|
|
30
|
+
async setSessionOverrides(sessionId, overrides) {
|
|
31
|
+
const id = String(sessionId || '').trim();
|
|
32
|
+
if (!id) throw new Error('session id is required');
|
|
33
|
+
const sessions = { ...state.sessions };
|
|
34
|
+
const normalized = Object.fromEntries(Object.entries(overrides).filter(([, value]) => typeof value === 'boolean'));
|
|
35
|
+
if (Object.keys(normalized).length) sessions[id] = normalized;
|
|
36
|
+
else delete sessions[id];
|
|
37
|
+
await update({ ...state, sessions });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readState(storageFile) {
|
|
43
|
+
if (!storageFile) return emptyState();
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(await fsp.readFile(storageFile, 'utf8'));
|
|
46
|
+
return {
|
|
47
|
+
version: 1,
|
|
48
|
+
globalEnabled: Array.isArray(parsed?.globalEnabled) ? parsed.globalEnabled.map(String) : undefined,
|
|
49
|
+
sessions: parsed?.sessions && typeof parsed.sessions === 'object' && !Array.isArray(parsed.sessions) ? parsed.sessions : {},
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.code !== 'ENOENT') console.warn(`failed to read web plugin settings: ${error.message || error}`);
|
|
53
|
+
return emptyState();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function emptyState() {
|
|
58
|
+
return { version: 1, globalEnabled: undefined, sessions: {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function writeState(storageFile, state) {
|
|
62
|
+
if (!storageFile) return;
|
|
63
|
+
await fsp.mkdir(path.dirname(storageFile), { recursive: true });
|
|
64
|
+
const temporary = `${storageFile}.${process.pid}.tmp`;
|
|
65
|
+
await fsp.writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
66
|
+
await fsp.rename(temporary, storageFile);
|
|
67
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export class DownloadRegistry {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.entries = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
add(entry) {
|
|
12
|
+
const id = crypto.randomUUID();
|
|
13
|
+
const full = {
|
|
14
|
+
id,
|
|
15
|
+
createdAt: Date.now(),
|
|
16
|
+
...entry,
|
|
17
|
+
};
|
|
18
|
+
this.entries.set(id, full);
|
|
19
|
+
return full;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get(id) {
|
|
23
|
+
const entry = this.entries.get(String(id || ''));
|
|
24
|
+
if (!entry) return undefined;
|
|
25
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
26
|
+
this.entries.delete(entry.id);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createExposeDownloadsTool(options) {
|
|
34
|
+
const ttlMs = options.ttlMs ?? 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
name: 'expose_downloads',
|
|
38
|
+
description:
|
|
39
|
+
'Expose one or more existing local files for the web user to download. Input must be absolute file paths. The result follows neoctl.resource-link.v1: copy each downloads[].markdown value verbatim into the final response, without rewriting or prefixing its link.',
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
paths: {
|
|
44
|
+
type: 'array',
|
|
45
|
+
items: { type: 'string' },
|
|
46
|
+
description: 'Absolute file paths to expose for browser download.',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ['paths'],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
metadata: {
|
|
53
|
+
readOnly: true,
|
|
54
|
+
concurrent: true,
|
|
55
|
+
visible: true,
|
|
56
|
+
requiresApproval: false,
|
|
57
|
+
maxResultSizeChars: 12000,
|
|
58
|
+
},
|
|
59
|
+
validate(input) {
|
|
60
|
+
const paths = Array.isArray(input?.paths)
|
|
61
|
+
? input.paths.map((item) => String(item || '').trim()).filter(Boolean)
|
|
62
|
+
: [];
|
|
63
|
+
if (!paths.length) throw new Error('paths must be a non-empty array');
|
|
64
|
+
if (paths.length > 20) throw new Error('too many paths; maximum is 20');
|
|
65
|
+
for (const filePath of paths) {
|
|
66
|
+
if (!path.isAbsolute(filePath)) throw new Error(`path must be absolute: ${filePath}`);
|
|
67
|
+
}
|
|
68
|
+
return { paths };
|
|
69
|
+
},
|
|
70
|
+
async execute(input, context) {
|
|
71
|
+
const downloads = [];
|
|
72
|
+
for (const rawPath of input.paths) {
|
|
73
|
+
const absolutePath = path.resolve(rawPath);
|
|
74
|
+
const stat = await fsp.stat(absolutePath).catch(() => undefined);
|
|
75
|
+
if (!stat) throw new Error(`file does not exist: ${absolutePath}`);
|
|
76
|
+
if (!stat.isFile()) throw new Error(`path is not a file: ${absolutePath}`);
|
|
77
|
+
const entry = options.registry.add({
|
|
78
|
+
absolutePath,
|
|
79
|
+
filename: path.basename(absolutePath),
|
|
80
|
+
sizeBytes: stat.size,
|
|
81
|
+
expiresAt: Date.now() + ttlMs,
|
|
82
|
+
sessionId: context.session?.sessionId,
|
|
83
|
+
});
|
|
84
|
+
const url = `/api/downloads/${encodeURIComponent(entry.id)}`;
|
|
85
|
+
const reference = `sandbox:${url}`;
|
|
86
|
+
downloads.push({
|
|
87
|
+
id: entry.id,
|
|
88
|
+
filename: entry.filename,
|
|
89
|
+
sizeBytes: entry.sizeBytes,
|
|
90
|
+
url,
|
|
91
|
+
reference,
|
|
92
|
+
markdown: `[${entry.filename.replace(/([\\\]])/g, '\\$1')}](${reference})`,
|
|
93
|
+
expiresAt: new Date(entry.expiresAt).toISOString(),
|
|
94
|
+
expiresAtEpochMs: entry.expiresAt,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
await options.onExpose?.({ sessionId: context.session?.sessionId, downloads });
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
output: {
|
|
101
|
+
resourceProtocol: 'neoctl.resource-link.v1',
|
|
102
|
+
usage: 'Copy downloads[].markdown verbatim when linking the resource. Do not alter the reference URI.',
|
|
103
|
+
downloads,
|
|
104
|
+
_ui: {
|
|
105
|
+
resources: downloads.map((item) => ({
|
|
106
|
+
kind: 'download',
|
|
107
|
+
url: item.url,
|
|
108
|
+
reference: item.reference,
|
|
109
|
+
label: item.filename,
|
|
110
|
+
downloadName: item.filename,
|
|
111
|
+
sizeBytes: item.sizeBytes,
|
|
112
|
+
expiresAt: item.expiresAtEpochMs,
|
|
113
|
+
})),
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
summary: `Exposed ${downloads.length} file(s) for browser download.`,
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function serveDownload(registry, req, res, id) {
|
|
123
|
+
const entry = registry.get(id);
|
|
124
|
+
if (!entry) {
|
|
125
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
126
|
+
res.end(JSON.stringify({ error: 'download not found or expired' }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
res.writeHead(200, {
|
|
131
|
+
'Content-Type': 'application/octet-stream',
|
|
132
|
+
'Content-Length': String(entry.sizeBytes),
|
|
133
|
+
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`,
|
|
134
|
+
'Cache-Control': 'no-store',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
fs.createReadStream(entry.absolutePath)
|
|
138
|
+
.on('error', () => {
|
|
139
|
+
if (!res.headersSent) {
|
|
140
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
141
|
+
res.end('failed to read file');
|
|
142
|
+
} else {
|
|
143
|
+
res.destroy();
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
.pipe(res);
|
|
147
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createExposeDownloadsTool, DownloadRegistry, serveDownload } from './downloads.mjs';
|
|
2
|
+
|
|
3
|
+
export function createPlugin() {
|
|
4
|
+
const registry = new DownloadRegistry();
|
|
5
|
+
return {
|
|
6
|
+
tools: [createExposeDownloadsTool({ registry })],
|
|
7
|
+
promptSections: [{
|
|
8
|
+
name: 'Web Downloads',
|
|
9
|
+
cacheStable: true,
|
|
10
|
+
content: 'When you create, modify, export, package, or identify local files that the web user should receive, call expose_downloads with the relevant absolute paths before the final response. For every link in your response, copy the returned downloads[].markdown value verbatim. This is the neoctl.resource-link.v1 protocol: never construct a link, alter the reference URI, or add/remove a sandbox: prefix. The tool also returns expiresAt as an ISO timestamp.',
|
|
11
|
+
}],
|
|
12
|
+
async route(req, res, url) {
|
|
13
|
+
if (req.method !== 'GET' || !url.pathname.startsWith('/api/downloads/')) return false;
|
|
14
|
+
const id = decodeURIComponent(url.pathname.slice('/api/downloads/'.length));
|
|
15
|
+
await serveDownload(registry, req, res, id);
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|