mio-agent-runtime 0.13.4 → 0.14.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.
@@ -0,0 +1,186 @@
1
+ 'use strict'
2
+
3
+ // Source auto-aggregation for the creativity engine (spec Capability 2).
4
+ //
5
+ // Default providers read ONLY local MIO_HOME data — zero network. Insight and
6
+ // observer sources arrive as plain arrays from the caller, because only the
7
+ // entry point knows which store is authoritative (MIO_HOME for the CLI, the
8
+ // MCP server's MIO_DATA_DIR) and whether its optional package resolves.
9
+ // SourceAggregator already tolerates a throwing provider (per-provider
10
+ // try/catch with a WARN log), so providers here do not need extra guards.
11
+
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+ const { SourceAggregator } = require('@akemi-mio/creativity/SourceAggregator')
15
+ const { sourcesFromInsights, isoOf } = require('./creativity-engine.js')
16
+
17
+ function readJsonl(file) {
18
+ try {
19
+ return fs
20
+ .readFileSync(file, 'utf8')
21
+ .split('\n')
22
+ .filter(Boolean)
23
+ .map((line) => {
24
+ try { return JSON.parse(line) } catch { return null }
25
+ })
26
+ .filter(Boolean)
27
+ } catch (_) {
28
+ return []
29
+ }
30
+ }
31
+
32
+ // Every weight is > 0.7 on purpose: IdeaGenerator.applyTemperature keeps any
33
+ // source with weight > 0.7 unconditionally (temperature 0.5 middle branch), so
34
+ // the assembled set reaches the prompt intact — deterministic, no RNG survival.
35
+ const WEIGHTS = {
36
+ memory: 0.75,
37
+ traceOk: 0.74,
38
+ traceFail: 0.73,
39
+ hypothesisActive: 0.72,
40
+ hypothesisRejected: 0.72,
41
+ trend: 0.76,
42
+ }
43
+
44
+ function memoryProvider(dataDir) {
45
+ return {
46
+ domain: 'feedback',
47
+ name: 'memory',
48
+ collect() {
49
+ return readJsonl(path.join(dataDir, 'memory.jsonl'))
50
+ .filter((r) => r && r.archived !== true && (r.kind === 'decision' || r.kind === 'note'))
51
+ .slice(-6)
52
+ .map((r) => ({
53
+ name: `${r.kind}: ${String(r.content || '').slice(0, 40)}`,
54
+ content: String(r.content || ''),
55
+ type: 'feedback',
56
+ weight: WEIGHTS.memory,
57
+ origin: `memory:${r.id}`,
58
+ originId: r.id,
59
+ timestamp: isoOf(r.timestamp),
60
+ }))
61
+ },
62
+ }
63
+ }
64
+
65
+ function traceProviders(dataDir) {
66
+ const events = () => readJsonl(path.join(dataDir, 'traces.jsonl')).slice(-40)
67
+ const fmt = (r) => `${r.event_type}${r.outcome ? `/${r.outcome}` : ''}: ${JSON.stringify(r.payload || {}).slice(0, 300)}`
68
+ const stamp = (r) => isoOf(r.timestamp ?? r.ts)
69
+ return [
70
+ {
71
+ domain: 'behavior',
72
+ name: 'traces-success',
73
+ collect() {
74
+ return events()
75
+ .filter((r) => r.event_type === 'task_outcome' && r.outcome === 'success')
76
+ .slice(-3)
77
+ .map((r) => ({
78
+ name: `success: ${String((r.payload && (r.payload.task || r.payload.summary)) || r.trace_id || 'task').slice(0, 40)}`,
79
+ content: fmt(r),
80
+ type: 'behavior',
81
+ weight: WEIGHTS.traceOk,
82
+ origin: `trace:${r.trace_id}`,
83
+ originId: r.trace_id,
84
+ timestamp: stamp(r),
85
+ }))
86
+ },
87
+ },
88
+ {
89
+ domain: 'failure',
90
+ name: 'traces-failure',
91
+ collect() {
92
+ return events()
93
+ .filter(
94
+ (r) =>
95
+ (r.event_type === 'error' || r.event_type === 'retry') ||
96
+ (r.event_type === 'task_outcome' && (r.outcome === 'failure' || r.outcome === 'error' || r.outcome === 'aborted')),
97
+ )
98
+ .slice(-3)
99
+ .map((r) => ({
100
+ name: `failure: ${String(r.event_type)}${r.trace_id ? ` ${String(r.trace_id).slice(0, 12)}` : ''}`,
101
+ content: fmt(r),
102
+ type: 'failure',
103
+ weight: WEIGHTS.traceFail,
104
+ origin: `trace:${r.trace_id || 'unknown'}`,
105
+ originId: r.trace_id,
106
+ timestamp: stamp(r),
107
+ }))
108
+ },
109
+ },
110
+ ]
111
+ }
112
+
113
+ function hypothesesProvider(dataDir) {
114
+ return {
115
+ domain: 'module',
116
+ name: 'hypotheses',
117
+ collect() {
118
+ return readJsonl(path.join(dataDir, 'creativity', 'creativity-hypotheses.jsonl'))
119
+ .filter((h) => h && (h.status === 'active' || h.status === 'rejected'))
120
+ .slice(-6)
121
+ .map((h) => ({
122
+ name: String(h.title || h.id).slice(0, 60),
123
+ content: `idea: ${h.idea}\nrisk: ${h.risk || '-'}`,
124
+ type: h.status === 'rejected' ? 'failure' : 'knowledge',
125
+ weight: h.status === 'rejected' ? WEIGHTS.hypothesisRejected : WEIGHTS.hypothesisActive,
126
+ origin: `hypothesis:${h.id}`,
127
+ originId: h.id,
128
+ timestamp: isoOf(h.createdAt),
129
+ }))
130
+ },
131
+ }
132
+ }
133
+
134
+ // Observer trend reports → provocation sources. The report schema has drifted
135
+ // across observer versions, so fields are read defensively with fallbacks.
136
+ function trendSources(trends) {
137
+ return (Array.isArray(trends) ? trends : []).slice(0, 4).map((t, i) => {
138
+ const title = (t && (t.title || t.name || t.date)) || `trend-${i}`
139
+ const body =
140
+ (t && t.summary) ||
141
+ (Array.isArray(t && t.themes) ? t.themes.join('; ') : '') ||
142
+ JSON.stringify(t || {}).slice(0, 400)
143
+ const key = (t && (t.date || t.id)) || String(i)
144
+ const stamp = isoOf(t && (t.generatedAt || t.date))
145
+ return {
146
+ name: String(title).slice(0, 60),
147
+ content: String(body),
148
+ type: 'provocation',
149
+ weight: WEIGHTS.trend,
150
+ origin: `observer-trends:${key}`,
151
+ ...(t && t.id ? { originId: t.id } : {}),
152
+ ...(stamp ? { timestamp: stamp } : {}),
153
+ }
154
+ })
155
+ }
156
+
157
+ // Assemble every local (+ optionally insight/observer) domain through the
158
+ // package SourceAggregator so dedup, per-domain caps and failure tolerance
159
+ // behave identically for idea.generate and the generate top-up.
160
+ function buildAutoSources({ dataDir, insights = [], trends = [] }) {
161
+ const aggregator = new SourceAggregator(4, 1)
162
+ aggregator.addProvider(memoryProvider(dataDir))
163
+ for (const provider of traceProviders(dataDir)) aggregator.addProvider(provider)
164
+ aggregator.addProvider(hypothesesProvider(dataDir))
165
+ if (Array.isArray(insights) && insights.length > 0) {
166
+ aggregator.addProvider({ domain: 'observation', name: 'insight', collect: () => sourcesFromInsights(insights) })
167
+ }
168
+ const trendList = Array.isArray(trends) ? trends : []
169
+ if (trendList.length > 0) {
170
+ aggregator.addProvider({ domain: 'external', name: 'observer-trends', collect: () => trendSources(trendList) })
171
+ }
172
+ return aggregator.build()
173
+ }
174
+
175
+ // Explicit --source entries always win: auto sources only top a set up to the
176
+ // two entries the concept mixer needs; they never replace or reorder explicit
177
+ // ones (spec Capability 2, injection point 2).
178
+ function topUpSources(explicit, auto) {
179
+ const list = Array.isArray(explicit) ? explicit.filter(Boolean) : []
180
+ if (list.length >= 2) return list
181
+ const seen = new Set(list.map((s) => s.name))
182
+ const extra = (Array.isArray(auto) ? auto : []).filter((s) => s && !seen.has(s.name))
183
+ return [...list, ...extra]
184
+ }
185
+
186
+ module.exports = { buildAutoSources, topUpSources, trendSources, memoryProvider, traceProviders, hypothesesProvider }
@@ -72,13 +72,16 @@ function createEvolutionCutoverTools({ dataDir, appendJsonl, readJsonl, projectN
72
72
  const project = projectFromArgs(args)
73
73
  const shadowRuns = readJsonl(shadowPath).filter((record) => record.project === project)
74
74
  const dualWriteRuns = readJsonl(dualWritePath).filter((record) => record.project === project)
75
+ // Apply documented defaults (schema says minShadowRuns: 5, maxMismatchRate: 0)
76
+ const minShadowRuns = Number.isFinite(args.minShadowRuns) ? args.minShadowRuns : 5
77
+ const maxMismatchRate = Number.isFinite(args.maxMismatchRate) ? args.maxMismatchRate : 0
75
78
  return {
76
79
  project,
77
80
  ...assessCutoverReadiness({
78
81
  shadowRuns,
79
82
  dualWriteRuns,
80
- minShadowRuns: Number.isFinite(args.minShadowRuns) ? args.minShadowRuns : undefined,
81
- maxMismatchRate: Number.isFinite(args.maxMismatchRate) ? args.maxMismatchRate : undefined,
83
+ minShadowRuns,
84
+ maxMismatchRate,
82
85
  }),
83
86
  }
84
87
  }
@@ -0,0 +1,136 @@
1
+ 'use strict'
2
+
3
+ // `mio.idea.generate` — one-shot goal-driven idea pipeline (spec Capability 1).
4
+ // Shared by the CLI (`mio idea generate`) and the MCP server (tool
5
+ // `mio.idea.generate`): each entry point only assembles entry-specific inputs
6
+ // (which store is authoritative, where local data lives) and renders the
7
+ // response this module returns.
8
+ //
9
+ // Contract:
10
+ // - source order: goal → context → constraints → grounding → local auto,
11
+ // deduplicated by `origin` (fallback name), first occurrence wins — so a
12
+ // memory that arrives both as a grounding hit and through the local
13
+ // provider contributes exactly once, the grounding copy first;
14
+ // - pairing strategy: cross-type `explore` only when two types exist, else
15
+ // the `stable` fallback (an all-knowledge set explores into ZERO combos);
16
+ // - every candidate passes evaluateNovelty against stored hypotheses: a
17
+ // near-duplicate of a REJECTED one is persisted as rejected with a reason,
18
+ // a merely-similar recent one is down-scored; drafts AND rejects both land
19
+ // in the store so later runs can dedup against them;
20
+ // - ideas are never written to Mio memory — CreativityStore only.
21
+
22
+ const { IdeaGenerator } = require('@akemi-mio/creativity/IdeaGenerator')
23
+ const { evaluateNovelty } = require('@akemi-mio/creativity/NoveltyScorer')
24
+
25
+ // All > 0.7: IdeaGenerator.applyTemperature keeps such sources unconditionally
26
+ // (temperature 0.5 middle branch) — deterministic prompt, no RNG survival.
27
+ const WEIGHTS = { goal: 0.95, context: 0.9, constraint: 0.88, grounding: 0.75 }
28
+
29
+ // `queryMemory` result → grounding sources + ids. Tolerates null/empty
30
+ // (a thrown query is the caller's problem to catch; a miss is not).
31
+ function groundingFrom(queryResult) {
32
+ const results = Array.isArray(queryResult && queryResult.results) ? queryResult.results : []
33
+ const sources = results.map((r) => ({
34
+ name: `${r.kind || 'memory'}: ${String(r.content || '').slice(0, 40)}`,
35
+ content: String(r.content || ''),
36
+ type: 'feedback',
37
+ weight: WEIGHTS.grounding,
38
+ origin: `memory:${r.id}`,
39
+ originId: r.id,
40
+ ...(r.timestamp ? { timestamp: r.timestamp } : {}),
41
+ }))
42
+ return { sources, relatedMemoryIds: results.map((r) => r.id), groundedWith: results.length }
43
+ }
44
+
45
+ function namedSource(role, text, origin, weight) {
46
+ const body = String(text).trim()
47
+ return {
48
+ name: `${role}: ${body.slice(0, 40)}`,
49
+ content: body,
50
+ type: 'knowledge',
51
+ weight,
52
+ origin,
53
+ }
54
+ }
55
+
56
+ async function runIdeaGenerate(options) {
57
+ const {
58
+ goal,
59
+ context = '',
60
+ constraints = [],
61
+ numIdeas,
62
+ chatJson,
63
+ store,
64
+ groundingSources = [],
65
+ relatedMemoryIds = [],
66
+ autoSources = [],
67
+ } = options || {}
68
+
69
+ const ordered = []
70
+ if (typeof goal === 'string' && goal.trim()) ordered.push(namedSource('goal', goal, 'goal', WEIGHTS.goal))
71
+ if (typeof context === 'string' && context.trim()) ordered.push(namedSource('context', context, 'context', WEIGHTS.context))
72
+ if (Array.isArray(constraints)) {
73
+ constraints.forEach((c, i) => {
74
+ if (typeof c === 'string' && c.trim()) ordered.push(namedSource('constraint', c, `constraint:${i}`, WEIGHTS.constraint))
75
+ })
76
+ }
77
+ for (const s of [...groundingSources, ...autoSources]) {
78
+ if (s && typeof s === 'object' && s.name) ordered.push(s)
79
+ }
80
+
81
+ const seen = new Set()
82
+ const sources = []
83
+ for (const s of ordered) {
84
+ const key = s.origin || s.name
85
+ if (seen.has(key)) continue
86
+ seen.add(key)
87
+ sources.push(s)
88
+ }
89
+
90
+ if (sources.length < 2) {
91
+ return { reason: 'need at least 2 sources — add --context/--constraints, or run `mio remember` first' }
92
+ }
93
+
94
+ const pairingStrategy = new Set(sources.map((s) => s.type)).size >= 2 ? 'explore' : 'stable'
95
+ const requested = Number(numIdeas)
96
+ const maxIdeas = Number.isFinite(requested) ? Math.min(Math.max(Math.trunc(requested), 1), 3) : 3
97
+
98
+ const rotationSeed = store.getHypotheses().length
99
+ const generator = new IdeaGenerator(chatJson, 0.5, undefined, rotationSeed)
100
+ // method left undefined: HypothesisGenerator rotates techniques internally
101
+ // from rotationSeed; the chosen one comes back on hypothesis.technique.
102
+ const creativeIdeas = await generator.generateIdeas(sources, maxIdeas, pairingStrategy, [])
103
+
104
+ const generatedAt = new Date().toISOString()
105
+ const recent = store.getHypotheses().slice(-30)
106
+ const rejected = store.getHypotheses({ status: 'rejected' }).slice(-50)
107
+
108
+ const ideas = []
109
+ const persistedIds = []
110
+ for (const { hypothesis: h, experiment } of creativeIdeas) {
111
+ const verdict = evaluateNovelty(h, recent, rejected)
112
+ if (verdict.shouldReject) {
113
+ h.status = 'rejected'
114
+ h.rejectionReason = verdict.rejectReason
115
+ } else {
116
+ h.novelty = verdict.adjustedNovelty
117
+ }
118
+ const labels = new Set(Array.isArray(h.sourceLabels) ? h.sourceLabels : [])
119
+ h.provenance = {
120
+ strategy: pairingStrategy,
121
+ technique: h.technique,
122
+ relatedMemoryIds: [...relatedMemoryIds],
123
+ sources: sources
124
+ .filter((s) => labels.has(s.name))
125
+ .map((s) => ({ name: s.name, type: s.type, origin: s.origin, timestamp: s.timestamp })),
126
+ generatedAt,
127
+ }
128
+ store.addHypothesis(h)
129
+ persistedIds.push(h.id)
130
+ ideas.push({ title: h.title, hypothesis: h, experiment, novelty: h.novelty, provenance: h.provenance })
131
+ }
132
+
133
+ return { ideas, generatedAt, groundedWith: groundingSources.length, persistedIds }
134
+ }
135
+
136
+ module.exports = { runIdeaGenerate, groundingFrom }
@@ -52,10 +52,12 @@ function isInsightAvailable() {
52
52
  return Boolean(InsightStore)
53
53
  }
54
54
 
55
- // Score at or above which an unreported insight is counted as high-value in the
56
- // status summary.
57
- const INSIGHT_HIGH_VALUE_SCORE = 0.7
58
- const INSIGHT_HIGH_VALUE_LIMIT = 50
55
+ // "High value" means score >= 50 (0-100 scale) AND confidence >= 0.7 (0-1).
56
+ // These used to be named INSIGHT_HIGH_VALUE_SCORE = 0.7 / ..._LIMIT = 50 and
57
+ // were passed in (LIMIT, SCORE) order -- correct only because the constants
58
+ // happened to occupy each other's slots. Named for what they are now.
59
+ const INSIGHT_HIGH_VALUE_SCORE_THRESHOLD = 50
60
+ const INSIGHT_HIGH_VALUE_CONFIDENCE_THRESHOLD = 0.7
59
61
 
60
62
  function createInsightStore(options = {}) {
61
63
  const dataDir = options.dataDir
@@ -94,7 +96,10 @@ function createInsightStore(options = {}) {
94
96
  total: all.length,
95
97
  unreported: unreported.length,
96
98
  reported: all.length - unreported.length,
97
- highValue: s.getHighValueUnreported(INSIGHT_HIGH_VALUE_LIMIT, INSIGHT_HIGH_VALUE_SCORE).length,
99
+ highValue: s.getHighValueUnreported(
100
+ INSIGHT_HIGH_VALUE_SCORE_THRESHOLD,
101
+ INSIGHT_HIGH_VALUE_CONFIDENCE_THRESHOLD,
102
+ ).length,
98
103
  }
99
104
  }
100
105
 
@@ -125,7 +130,11 @@ function createInsightStore(options = {}) {
125
130
  createdAt: m.createdAt || m.timestamp || Date.now(),
126
131
  })),
127
132
  summaries: args.summaries || [],
128
- interactionCount: 0,
133
+ // 桌面端有真实交互流;CLI 默认没有。调用方可显式传入
134
+ // interactionCount/eventCount(如 MCP 侧按 trace 统计),
135
+ // 不传时 Scorer 会按记忆新鲜度给 confidence 兜底(旧逻辑
136
+ // 恒 0.2 会把 highValue 永久压成 0)。
137
+ interactionCount: Number(args.interactionCount) || 0,
129
138
  plans: (args.plans || []).map((p) => ({
130
139
  title: p.title || p.name || '',
131
140
  status: p.status || 'unknown',
@@ -133,13 +142,30 @@ function createInsightStore(options = {}) {
133
142
  steps: p.steps || [],
134
143
  createdAt: p.createdAt || Date.now(),
135
144
  })),
136
- eventCount: 0,
145
+ eventCount: Number(args.eventCount) || 0,
137
146
  }
138
147
  const insights = await generator.generate(ctx)
139
148
  if (InsightStore && storePath) {
140
- requireStore().addMany(insights)
149
+ // addMany is a pure push: every `mio insight generate` run used to
150
+ // append another copy of the same detection. Drop titles the store
151
+ // already holds (reported or not) so re-running is idempotent.
152
+ const s = requireStore()
153
+ const seen = new Set(s.getAll().map((i) => String(i.title || '').trim().toLowerCase()))
154
+ // Dedup against the store AND within the batch -- a generator that emits
155
+ // the same detection twice in one run must not survive just because the
156
+ // second copy was not there when the set was built.
157
+ const fresh = []
158
+ for (const i of insights || []) {
159
+ if (!i) continue
160
+ const key = i.title ? String(i.title).trim().toLowerCase() : null
161
+ if (key !== null && seen.has(key)) continue
162
+ if (key !== null) seen.add(key)
163
+ fresh.push(i)
164
+ }
165
+ if (fresh.length > 0) s.addMany(fresh)
166
+ return { generated: fresh.length, duplicates: (insights || []).length - fresh.length, insights: fresh }
141
167
  }
142
- return { generated: insights.length, insights }
168
+ return { generated: (insights || []).length, insights }
143
169
  }
144
170
 
145
171
  return {
@@ -154,6 +180,6 @@ function createInsightStore(options = {}) {
154
180
  module.exports = {
155
181
  createInsightStore,
156
182
  isInsightAvailable,
157
- INSIGHT_HIGH_VALUE_SCORE,
158
- INSIGHT_HIGH_VALUE_LIMIT,
183
+ INSIGHT_HIGH_VALUE_SCORE_THRESHOLD,
184
+ INSIGHT_HIGH_VALUE_CONFIDENCE_THRESHOLD,
159
185
  }
@@ -27,14 +27,13 @@ const path = require('path')
27
27
  const DEFAULT_API_URL = 'https://opencode.ai/zen/go/v1/chat/completions'
28
28
  const DEFAULT_MODEL = 'deepseek-v4-flash'
29
29
 
30
- // Resolve the directory holding config.json. The CLI and the MCP server use
31
- // different data dirs by design (MIO_HOME vs MIO_DATA_DIR||cwd/.mio-intelligence),
32
- // so we honour whichever is set and fall back to the CLI default. No third
33
- // convention is introduced here on purpose.
30
+ // Resolve the directory holding config.json. The CLI uses MIO_HOME
31
+ // (~/.mio-intelligence by default). The MCP server uses MIO_DATA_DIR ||
32
+ // cwd/.mio-intelligence. This module serves the CLI-facing `llm-config`/
33
+ // `config llm` commands, so we follow the CLI convention: MIO_HOME first.
34
34
  function homeDir() {
35
35
  return (
36
36
  process.env.MIO_HOME ||
37
- process.env.MIO_DATA_DIR ||
38
37
  path.join(os.homedir(), '.mio-intelligence')
39
38
  )
40
39
  }
@@ -118,6 +117,13 @@ async function chatJson(userText, opts = {}) {
118
117
  const system = opts.system || 'You are a creative AI assistant. Output JSON.'
119
118
  const temperature = opts.temperature ?? 0.3
120
119
 
120
+ // response_format must match the prompt's top-level shape. Prompts that
121
+ // demand a top-level JSON array (InsightPrompt) can be rejected outright
122
+ // (HTTP 400) by OpenAI-compatible gateways that enforce json_object, so
123
+ // array callers omit the field and rely on prompt + fence-stripping parse.
124
+ const wantsArray = opts.responseFormat === 'array'
125
+ const responseFormat = wantsArray ? undefined : { type: 'json_object' }
126
+
121
127
  try {
122
128
  const resp = await fetch(apiUrl, {
123
129
  method: 'POST',
@@ -128,11 +134,12 @@ async function chatJson(userText, opts = {}) {
128
134
  body: JSON.stringify({
129
135
  model,
130
136
  temperature,
137
+ ...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
131
138
  messages: [
132
139
  { role: 'system', content: system },
133
140
  { role: 'user', content: userText },
134
141
  ],
135
- response_format: { type: 'json_object' },
142
+ ...(responseFormat ? { response_format: responseFormat } : {}),
136
143
  }),
137
144
  signal: AbortSignal.timeout(opts.timeoutMs || 30000),
138
145
  })
@@ -140,10 +147,24 @@ async function chatJson(userText, opts = {}) {
140
147
  if (!resp.ok) return { error: `HTTP ${resp.status}` }
141
148
  const json = await resp.json()
142
149
  const text = json.choices?.[0]?.message?.content || ''
150
+
151
+ // Parse failures used to return { data: rawText }, so a prose answer
152
+ // masqueraded as a parsed object: callers testing truthy `result.data`
153
+ // stored an untitled, score-less record (and polluted the novelty
154
+ // dedup pool). A model that answered with anything but JSON is an
155
+ // error, not a degraded success. One retry with a code fence stripped
156
+ // covers models that wrap JSON despite response_format.
143
157
  try {
144
158
  return { data: JSON.parse(text) }
145
- } catch {
146
- return { data: text }
159
+ } catch (_) {}
160
+ const stripped = text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')
161
+ try {
162
+ return { data: JSON.parse(stripped) }
163
+ } catch (_) {
164
+ return {
165
+ error: 'model returned unparseable JSON',
166
+ raw: String(text).slice(0, 300),
167
+ }
147
168
  }
148
169
  } catch (err) {
149
170
  return { error: String(err) }