modelfunction-cli 0.4.4 → 0.5.1

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/lib/mcp-server.js CHANGED
@@ -8,8 +8,9 @@ import { z } from "zod";
8
8
  import { loadSuite } from "./suite.js";
9
9
  import { scanProject, addTools, buildSuiteDraft, redactReport } from "./scan.js";
10
10
  import { writeFileAtomic } from "./fsutil.js";
11
- import { probeStdioServer } from "./mcp-probe.js";
11
+ import { probeStdioServer, probeHttpServer } from "./mcp-probe.js";
12
12
  import { addCases, computeCoverage } from "./cases.js";
13
+ import { auditSuite } from "./audit.js";
13
14
 
14
15
  const MF_PATH = fileURLToPath(new URL("../mf.js", import.meta.url));
15
16
  const PACKAGE_JSON = new URL("../package.json", import.meta.url);
@@ -32,7 +33,7 @@ export async function startMcpServer() {
32
33
  {
33
34
  title: "Scan tools",
34
35
  description:
35
- "扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio MCP server 拉取 tools/list。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核;来源 ≥2 时草稿带按来源分组的 profiles,并列出不参与评测的 skills)。",
36
+ "扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio 或 streamable-HTTP MCP server 拉取 tools/list(HTTP headers 只用于连接,不写入报告)。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核;来源 ≥2 时草稿带按来源分组的 profiles,并列出不参与评测的 skills)。",
36
37
  inputSchema: {
37
38
  root: z.string().optional().describe("仓库目录,默认进程当前目录"),
38
39
  probe: z.boolean().optional().describe("是否连接 MCP server 拉取工具清单"),
@@ -44,11 +45,10 @@ export async function startMcpServer() {
44
45
  const report = await scanProject(root ?? process.cwd());
45
46
  if (probe) {
46
47
  for (const mcpServer of [...report.mcpServers]) {
47
- if (mcpServer.transport !== "stdio") {
48
- report.probes.push({ server: mcpServer.name, ok: false, error: "http transport 暂不支持" });
49
- continue;
50
- }
51
- const result = await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
48
+ const result =
49
+ mcpServer.transport === "stdio"
50
+ ? await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 })
51
+ : await probeHttpServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
52
52
  if (!result.ok) {
53
53
  report.probes.push({ server: mcpServer.name, ok: false, error: result.error });
54
54
  continue;
@@ -109,6 +109,22 @@ export async function startMcpServer() {
109
109
  async ({ suite }) => json(computeCoverage(await loadSuite(suitePath(suite))))
110
110
  );
111
111
 
112
+ server.registerTool(
113
+ "audit",
114
+ {
115
+ title: "Audit tool set",
116
+ description:
117
+ "一次静态审计整个工具集:lint 发现、混淆族与边界建议、用例覆盖、挂载检查。不调用模型、不执行工具、无网络。返回 JSON。",
118
+ inputSchema: { suite: z.string().optional().describe("套件路径,默认 ./mf.suite.json") }
119
+ },
120
+ async ({ suite }) =>
121
+ json(
122
+ auditSuite(
123
+ await loadSuite(suitePath(suite), { allowEmptyCases: true, allowEmptyTools: true })
124
+ )
125
+ )
126
+ );
127
+
112
128
  server.registerTool(
113
129
  "add_cases",
114
130
  {
@@ -154,16 +170,20 @@ export async function startMcpServer() {
154
170
  .max(10)
155
171
  .optional()
156
172
  .describe("每条用例重复次数,取多数结果;建议正式基线用 3"),
157
- failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2")
173
+ failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2"),
174
+ maxCost: z.number().positive().optional().describe("累计估算成本上限(CNY),超限退出 2 且不返回部分评分卡"),
175
+ progress: z.boolean().optional().describe("把逐用例进度写到 stderr(stdout 仍为纯 JSON)")
158
176
  }
159
177
  },
160
- async ({ suite, models, cases, profile, repeat, failUnder }) => {
178
+ async ({ suite, models, cases, profile, repeat, failUnder, maxCost, progress }) => {
161
179
  const args = ["eval", "--json", "--suite", suitePath(suite)];
162
180
  if (models) args.push("--models", models);
163
181
  if (cases) args.push("--case", cases);
164
182
  if (profile) args.push("--profile", profile);
165
183
  if (repeat !== undefined) args.push("--repeat", String(repeat));
166
184
  if (failUnder !== undefined) args.push("--fail-under", String(failUnder));
185
+ if (maxCost !== undefined) args.push("--max-cost", String(maxCost));
186
+ if (progress) args.push("--progress");
167
187
 
168
188
  const result = await runCli(args);
169
189
  let scorecard = null;
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { permissionFlag } from "./node-permission.js";
3
3
 
4
- function run(command, args, timeout = 4000) {
4
+ // Shared CI runners can be slow to start a process; a 4s probe that times out
5
+ // under load turns into a false "engine unavailable" and flakes the suite.
6
+ function run(command, args, timeout = 10_000) {
5
7
  const result = spawnSync(command, args, { encoding: "utf8", timeout, windowsHide: true });
6
8
  return {
7
9
  ok: result.status === 0,
package/lib/scan.js CHANGED
@@ -337,9 +337,11 @@ export function addTools(
337
337
  export function redactReport(report) {
338
338
  return {
339
339
  ...report,
340
- mcpServers: report.mcpServers.map(({ env, ...rest }) => ({
340
+ mcpServers: report.mcpServers.map(({ env, headers, ...rest }) => ({
341
341
  ...rest,
342
- envKeys: env ? Object.keys(env) : []
342
+ url: rest.url ? sanitizeUrl(rest.url) : null,
343
+ envKeys: env ? Object.keys(env) : [],
344
+ headerKeys: headers ? Object.keys(headers).sort() : []
343
345
  }))
344
346
  };
345
347
  }
@@ -526,10 +528,26 @@ function mcpServersFrom(config) {
526
528
  command: value.command ?? null,
527
529
  args: Array.isArray(value.args) ? value.args : [],
528
530
  env: value.env && typeof value.env === "object" ? value.env : null,
531
+ headers: value.headers && typeof value.headers === "object" ? value.headers : null,
529
532
  url: value.url ?? null
530
533
  }));
531
534
  }
532
535
 
536
+ // Remote MCP configs often carry tokens in the query string; keep the report
537
+ // safe to share while the live server object still probes the full URL.
538
+ function sanitizeUrl(raw) {
539
+ try {
540
+ const url = new URL(raw);
541
+ url.username = "";
542
+ url.password = "";
543
+ url.search = "";
544
+ url.hash = "";
545
+ return url.toString();
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
533
551
  function toolsFromOpenApi(spec) {
534
552
  const tools = [];
535
553
  const paths = spec.paths ?? {};
package/lib/score.js CHANGED
@@ -76,6 +76,19 @@ export function pickRepresentative(runs) {
76
76
  };
77
77
  }
78
78
 
79
+ export function costCnyOf(usage, pricing) {
80
+ if (!pricing) return 0;
81
+ const input = usage?.input ?? 0;
82
+ const cached = Math.min(input, usage?.cached ?? 0);
83
+ const output = usage?.output ?? 0;
84
+ return (
85
+ ((input - cached) * (pricing.input ?? 0) +
86
+ cached * (pricing.cachedInput ?? pricing.input ?? 0) +
87
+ output * (pricing.output ?? 0)) /
88
+ 1e6
89
+ );
90
+ }
91
+
79
92
  export function scoreModel({ model, cases, results, pricing }) {
80
93
  const rows = cases.map((testCase, index) => {
81
94
  const result = results[index];
@@ -115,13 +128,7 @@ export function scoreModel({ model, cases, results, pricing }) {
115
128
  inputTokens,
116
129
  rows.reduce((sum, row) => sum + (row.usage.cached ?? 0), 0)
117
130
  );
118
- const uncachedTokens = inputTokens - cachedTokens;
119
- const costCny = pricing
120
- ? (uncachedTokens * (pricing.input ?? 0) +
121
- cachedTokens * (pricing.cachedInput ?? pricing.input ?? 0) +
122
- outputTokens * (pricing.output ?? 0)) /
123
- 1e6
124
- : 0;
131
+ const costCny = rows.reduce((sum, row) => sum + costCnyOf(row.usage, pricing), 0);
125
132
  const reviewedTotal = reviewedRows.length;
126
133
 
127
134
  return {
package/lib/usage.js ADDED
@@ -0,0 +1,341 @@
1
+ import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { configDir } from "./credentials.js";
5
+
6
+ // Events are redacted by construction: only counts, ids and ratios leave the
7
+ // process. Never add free-form text (inputs, descriptions, paths, errors).
8
+ const OPT_OUT_ENV = "MF_NO_USAGE";
9
+ const CI_ENV_KEYS = [
10
+ "GITHUB_ACTIONS",
11
+ "GITLAB_CI",
12
+ "CIRCLECI",
13
+ "TRAVIS",
14
+ "BUILDKITE",
15
+ "TF_BUILD",
16
+ "JENKINS_URL",
17
+ "CNB_BUILD_ID"
18
+ ];
19
+
20
+ let pendingDetails = null;
21
+
22
+ export function usageDir() {
23
+ return join(configDir(), "usage");
24
+ }
25
+
26
+ export function usageFile(date = new Date()) {
27
+ const month = date.toISOString().slice(0, 7);
28
+ return join(usageDir(), `${month}.jsonl`);
29
+ }
30
+
31
+ export function feedbackDir() {
32
+ return join(configDir(), "feedback");
33
+ }
34
+
35
+ export function defaultFeedbackPath(date = new Date()) {
36
+ const stamp = date.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
37
+ return join(feedbackDir(), `mf-feedback-${stamp}.json`);
38
+ }
39
+
40
+ export function noteUsage(details) {
41
+ pendingDetails = { ...(pendingDetails ?? {}), ...sanitizeDetails(details) };
42
+ }
43
+
44
+ export async function recordRun({ command, exitCode = 0, durationMs = 0, version }) {
45
+ if (process.env[OPT_OUT_ENV] === "1") {
46
+ pendingDetails = null;
47
+ return;
48
+ }
49
+ const record = buildRecord({ command, exitCode, durationMs, version });
50
+ try {
51
+ await mkdir(usageDir(), { recursive: true, mode: 0o700 });
52
+ await appendFile(usageFile(), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
53
+ } catch {
54
+ // Usage logging must never fail a command.
55
+ } finally {
56
+ pendingDetails = null;
57
+ }
58
+ }
59
+
60
+ // The process "exit" hook is synchronous, so the CLI uses this variant.
61
+ export function recordRunSync({ command, exitCode = 0, durationMs = 0, version }) {
62
+ if (process.env[OPT_OUT_ENV] === "1") {
63
+ pendingDetails = null;
64
+ return;
65
+ }
66
+ const record = buildRecord({ command, exitCode, durationMs, version });
67
+ try {
68
+ mkdirSync(usageDir(), { recursive: true, mode: 0o700 });
69
+ appendFileSync(usageFile(), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
70
+ } catch {
71
+ // ignore
72
+ } finally {
73
+ pendingDetails = null;
74
+ }
75
+ }
76
+
77
+ export function buildRecord({ command, exitCode, durationMs, version }) {
78
+ return {
79
+ schema: 1,
80
+ ts: new Date().toISOString(),
81
+ command: typeof command === "string" ? command.slice(0, 32) : "unknown",
82
+ exitCode: Number.isInteger(exitCode) ? exitCode : 1,
83
+ durationMs: Math.max(0, Math.round(Number(durationMs) || 0)),
84
+ mf: typeof version === "string" ? version : "unknown",
85
+ platform: process.platform,
86
+ arch: process.arch,
87
+ node: process.versions.node,
88
+ ci: detectCi(),
89
+ ...(pendingDetails ?? {})
90
+ };
91
+ }
92
+
93
+ function detectCi() {
94
+ if (process.env.CI === "true" || process.env.CI === "1") return true;
95
+ return CI_ENV_KEYS.some((key) => Boolean(process.env[key]));
96
+ }
97
+
98
+ export async function readUsageEvents() {
99
+ let files = [];
100
+ try {
101
+ files = (await readdir(usageDir())).filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).sort();
102
+ } catch (error) {
103
+ if (error.code === "ENOENT") return { events: [], files: [], skipped: 0 };
104
+ throw error;
105
+ }
106
+ const events = [];
107
+ let skipped = 0;
108
+ for (const file of files) {
109
+ const text = await readFile(join(usageDir(), file), "utf8");
110
+ for (const line of text.split(/\r?\n/)) {
111
+ if (!line.trim()) continue;
112
+ try {
113
+ const parsed = JSON.parse(line);
114
+ if (parsed && typeof parsed === "object" && typeof parsed.ts === "string") events.push(parsed);
115
+ else skipped += 1;
116
+ } catch {
117
+ skipped += 1;
118
+ }
119
+ }
120
+ }
121
+ events.sort((a, b) => a.ts.localeCompare(b.ts));
122
+ return { events, files, skipped };
123
+ }
124
+
125
+ export function summarizeUsage(events) {
126
+ const summary = {
127
+ runs: events.length,
128
+ firstRun: events[0]?.ts ?? null,
129
+ lastRun: events.at(-1)?.ts ?? null,
130
+ activeDays: new Set(events.map((event) => event.ts.slice(0, 10))).size,
131
+ ciRuns: 0,
132
+ localRuns: 0,
133
+ exitCodes: {},
134
+ commands: {},
135
+ platforms: {},
136
+ nodeVersions: {},
137
+ mfVersions: {},
138
+ models: {},
139
+ eval: { runs: 0, executed: 0, meanSelection: null, meanPass: null, meanArgs: null, unstableCases: 0 },
140
+ lint: { runs: 0, errors: 0, warns: 0, infos: 0, rules: {} },
141
+ check: { runs: 0, verdicts: {} },
142
+ scan: { runs: 0, tools: 0, skills: 0 }
143
+ };
144
+ const selection = [];
145
+ const pass = [];
146
+ const args = [];
147
+
148
+ for (const event of events) {
149
+ if (event.ci) summary.ciRuns += 1;
150
+ else summary.localRuns += 1;
151
+ bump(summary.exitCodes, String(event.exitCode ?? 0));
152
+ bump(summary.commands, event.command ?? "unknown");
153
+ if (event.platform) bump(summary.platforms, event.platform);
154
+ if (event.node) bump(summary.nodeVersions, event.node);
155
+ if (event.mf) bump(summary.mfVersions, event.mf);
156
+ for (const model of event.models ?? []) bump(summary.models, model);
157
+
158
+ if (event.command === "eval") {
159
+ summary.eval.runs += 1;
160
+ if (event.execEngine) summary.eval.executed += 1;
161
+ if (event.unstableCases) summary.eval.unstableCases += event.unstableCases;
162
+ if (typeof event.selectionAccuracy === "number") selection.push(event.selectionAccuracy);
163
+ if (typeof event.passRate === "number") pass.push(event.passRate);
164
+ if (typeof event.argAccuracy === "number") args.push(event.argAccuracy);
165
+ }
166
+ if (event.command === "lint") {
167
+ summary.lint.runs += 1;
168
+ summary.lint.errors += event.lintErrors ?? 0;
169
+ summary.lint.warns += event.lintWarns ?? 0;
170
+ summary.lint.infos += event.lintInfos ?? 0;
171
+ for (const [rule, count] of Object.entries(event.lintRules ?? {})) {
172
+ summary.lint.rules[rule] = (summary.lint.rules[rule] ?? 0) + count;
173
+ }
174
+ }
175
+ if (event.command === "check") {
176
+ summary.check.runs += 1;
177
+ bump(summary.check.verdicts, event.checkVerdict ?? "unknown");
178
+ }
179
+ if (event.command === "scan") {
180
+ summary.scan.runs += 1;
181
+ summary.scan.tools += event.scanTools ?? 0;
182
+ summary.scan.skills += event.scanSkills ?? 0;
183
+ }
184
+ }
185
+
186
+ summary.eval.meanSelection = mean(selection);
187
+ summary.eval.meanPass = mean(pass);
188
+ summary.eval.meanArgs = mean(args);
189
+ return summary;
190
+ }
191
+
192
+ export function buildFeedbackBundle({ events, version, generatedAt = new Date() }) {
193
+ return {
194
+ schema: 1,
195
+ kind: "modelfunction-feedback",
196
+ generatedAt: generatedAt.toISOString(),
197
+ mf: {
198
+ version,
199
+ node: process.versions.node,
200
+ platform: process.platform,
201
+ arch: process.arch
202
+ },
203
+ usage: summarizeUsage(events),
204
+ privacy:
205
+ "仅包含命令名、次数、模型 id、计数与比率。不含用例原文、工具描述、源码、文件路径或密钥。",
206
+ request:
207
+ "我们想了解:哪些命令被真实使用、在哪些平台上跑、评测通过率大概什么水平、lint/check 主要命中哪些规则。用于决定下一步做什么,不需要你的项目细节。"
208
+ };
209
+ }
210
+
211
+ export function formatStats(summary, { events = 0, files = 0, skipped = 0, path = usageDir() } = {}) {
212
+ if (summary.runs === 0) {
213
+ return [
214
+ `local usage: ${path}`,
215
+ "还没有本地记录。运行任意 mf 命令后会自动记录(仅计数,不上传)。",
216
+ "opt out: set MF_NO_USAGE=1"
217
+ ].join("\n");
218
+ }
219
+ const lines = [
220
+ `local usage: ${path} (${events} events, ${files} file(s)${skipped > 0 ? `, ${skipped} unreadable line(s)` : ""})`,
221
+ `runs: ${summary.runs} (local ${summary.localRuns}, ci ${summary.ciRuns}) first: ${summary.firstRun} last: ${summary.lastRun} active days: ${summary.activeDays}`,
222
+ `exit codes: ${pairs(summary.exitCodes)}`,
223
+ `commands: ${pairs(summary.commands)}`
224
+ ];
225
+ if (Object.keys(summary.models).length > 0) lines.push(`models: ${pairs(summary.models)}`);
226
+ if (summary.eval.runs > 0) {
227
+ const parts = [`${summary.eval.runs} run(s)`];
228
+ if (summary.eval.meanSelection !== null) parts.push(`select ${percent(summary.eval.meanSelection)}`);
229
+ if (summary.eval.meanPass !== null) parts.push(`pass ${percent(summary.eval.meanPass)}`);
230
+ if (summary.eval.executed > 0) parts.push(`${summary.eval.executed} with execution`);
231
+ if (summary.eval.unstableCases > 0) parts.push(`${summary.eval.unstableCases} unstable case(s)`);
232
+ lines.push(`eval: ${parts.join(", ")}`);
233
+ }
234
+ if (summary.lint.runs > 0) {
235
+ const top = Object.entries(summary.lint.rules)
236
+ .sort((a, b) => b[1] - a[1])
237
+ .slice(0, 5)
238
+ .map(([rule, count]) => `${rule} ${count}`);
239
+ lines.push(
240
+ `lint: ${summary.lint.runs} run(s), error ${summary.lint.errors} / warn ${summary.lint.warns} / info ${summary.lint.infos}` +
241
+ (top.length > 0 ? `; top: ${top.join(", ")}` : "")
242
+ );
243
+ }
244
+ if (summary.check.runs > 0) lines.push(`check: ${summary.check.runs} run(s), ${pairs(summary.check.verdicts)}`);
245
+ if (summary.scan.runs > 0) lines.push(`scan: ${summary.scan.runs} run(s), ${summary.scan.tools} tools, ${summary.scan.skills} skills`);
246
+ lines.push(`versions: mf ${keys(summary.mfVersions)} node ${keys(summary.nodeVersions)} os ${keys(summary.platforms)}`);
247
+ lines.push(`no network, no telemetry. opt out: set MF_NO_USAGE=1. clear: delete ${path}`);
248
+ return lines.join("\n");
249
+ }
250
+
251
+ function bump(target, key) {
252
+ target[key] = (target[key] ?? 0) + 1;
253
+ }
254
+
255
+ function mean(values) {
256
+ if (values.length === 0) return null;
257
+ return Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10000) / 10000;
258
+ }
259
+
260
+ function percent(value) {
261
+ return `${(value * 100).toFixed(1)}%`;
262
+ }
263
+
264
+ function keys(counts) {
265
+ return Object.keys(counts).sort().slice(0, 5).join(", ") || "-";
266
+ }
267
+
268
+ function pairs(counts) {
269
+ return (
270
+ Object.entries(counts)
271
+ .sort((a, b) => b[1] - a[1])
272
+ .map(([key, value]) => `${key} ${value}`)
273
+ .join(", ") || "-"
274
+ );
275
+ }
276
+
277
+ const INT_FIELDS = new Set([
278
+ "caseCount",
279
+ "toolCount",
280
+ "reviewedCount",
281
+ "profileGroups",
282
+ "repeat",
283
+ "unstableCases",
284
+ "execExecuted",
285
+ "execPassed",
286
+ "execBlocked",
287
+ "execSkipped",
288
+ "scanTools",
289
+ "scanSkills",
290
+ "scanMcpServers",
291
+ "scanProbes",
292
+ "lintErrors",
293
+ "lintWarns",
294
+ "lintInfos",
295
+ "checkFindings",
296
+ "clusters"
297
+ ]);
298
+ const RATIO_FIELDS = new Set(["selectionAccuracy", "argAccuracy", "passRate"]);
299
+ const BOOL_FIELDS = new Set(["failedThreshold", "regressions"]);
300
+ const STRING_FIELDS = new Set(["execEngine", "checkVerdict"]);
301
+ const STRING_ARRAY_FIELDS = new Set(["models"]);
302
+ const COUNT_OBJECT_FIELDS = new Set(["lintRules"]);
303
+
304
+ export function sanitizeDetails(details) {
305
+ if (!details || typeof details !== "object") return {};
306
+ const out = {};
307
+ for (const [key, value] of Object.entries(details)) {
308
+ if (INT_FIELDS.has(key)) {
309
+ if (Number.isInteger(value) && value >= 0) out[key] = value;
310
+ continue;
311
+ }
312
+ if (RATIO_FIELDS.has(key)) {
313
+ if (typeof value === "number" && Number.isFinite(value)) out[key] = Math.round(value * 10000) / 10000;
314
+ continue;
315
+ }
316
+ if (BOOL_FIELDS.has(key)) {
317
+ if (typeof value === "boolean") out[key] = value;
318
+ continue;
319
+ }
320
+ if (STRING_FIELDS.has(key)) {
321
+ if (typeof value === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(value)) out[key] = value;
322
+ continue;
323
+ }
324
+ if (STRING_ARRAY_FIELDS.has(key)) {
325
+ if (Array.isArray(value)) {
326
+ out[key] = value.filter((item) => typeof item === "string" && item.length <= 64).slice(0, 20);
327
+ }
328
+ continue;
329
+ }
330
+ if (COUNT_OBJECT_FIELDS.has(key)) {
331
+ if (value && typeof value === "object" && !Array.isArray(value)) {
332
+ out[key] = Object.fromEntries(
333
+ Object.entries(value)
334
+ .filter(([rule, count]) => /^[a-z0-9-]{1,40}$/.test(rule) && Number.isInteger(count) && count >= 0)
335
+ .slice(0, 40)
336
+ );
337
+ }
338
+ }
339
+ }
340
+ return out;
341
+ }