memory-eternal 0.9.6 → 0.9.8

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,99 @@
1
+ // 记忆核心 · 子进程用的 node 可执行文件解析
2
+ //
3
+ // 背景(真实故障):
4
+ // 在 Electron 宿主里(例如 DSH Desktop),`process.execPath` 指向的是
5
+ // Electron 主程序,而不是 node。此前 `ensureWebServer()` / watchdog / 插件的
6
+ // 保活逻辑都用 `spawn(process.execPath, ['.../web.js', ...])` 拉起子进程,
7
+ // 于是在 Electron 宿主下 spawn 出来的是「第二个 Electron 实例」:
8
+ // 它不会执行 web.js,而是立刻退出(或被单实例锁挡掉),而调用方用的是
9
+ // `stdio: 'ignore'` + `.catch(() => {})`,失败被彻底吞掉——表现就是侧边栏
10
+ // 「记忆」弹窗里那个指向 127.0.0.1:7999 的 iframe 永远白屏。
11
+ //
12
+ // 本模块把「找出一个真正能跑 node 脚本的子进程入口」收敛到一处:
13
+ // 1. MEMORY_ETERNAL_NODE 显式指定(逃生口,便于测试与非常规宿主);
14
+ // 2. 非 Electron 宿主:process.execPath 本身就是 node,直接用;
15
+ // 3. Electron 宿主:优先从 PATH 找真正的 node;
16
+ // 4. 都找不到时退回 process.execPath,并由 childEnv() 打开
17
+ // ELECTRON_RUN_AS_NODE,让 Electron 二进制以纯 node 模式运行。
18
+
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+
22
+ let cachedBinary = null
23
+ let cachedSource = null
24
+
25
+ /** 在 PATH 中查找真正的 node 可执行文件;找不到返回 null。 */
26
+ function findNodeOnPath() {
27
+ const names = process.platform === 'win32' ? ['node.exe', 'node.cmd', 'node'] : ['node', 'node.exe']
28
+ const dirs = String(process.env.PATH || '').split(path.delimiter)
29
+ for (const dir of dirs) {
30
+ if (!dir) continue
31
+ for (const name of names) {
32
+ const candidate = path.join(dir, name)
33
+ try {
34
+ if (fs.statSync(candidate).isFile()) return candidate
35
+ } catch {
36
+ // 目录不存在或无权访问:继续找下一个
37
+ }
38
+ }
39
+ }
40
+ return null
41
+ }
42
+
43
+ /**
44
+ * 返回用于 spawn 子进程(跑 .js 脚本)的可执行文件路径。
45
+ * @returns {string}
46
+ */
47
+ export function nodeBinary() {
48
+ if (cachedBinary) return cachedBinary
49
+
50
+ if (process.env.MEMORY_ETERNAL_NODE) {
51
+ cachedBinary = process.env.MEMORY_ETERNAL_NODE
52
+ cachedSource = 'env'
53
+ return cachedBinary
54
+ }
55
+
56
+ if (!process.versions.electron) {
57
+ cachedBinary = process.execPath
58
+ cachedSource = 'execPath'
59
+ return cachedBinary
60
+ }
61
+
62
+ const found = findNodeOnPath()
63
+ if (found) {
64
+ cachedBinary = found
65
+ cachedSource = 'path'
66
+ return cachedBinary
67
+ }
68
+
69
+ cachedBinary = process.execPath
70
+ cachedSource = 'electron-run-as-node'
71
+ return cachedBinary
72
+ }
73
+
74
+ /** 说明 nodeBinary() 最终选了哪条路径(诊断用,不参与逻辑)。 */
75
+ export function nodeBinarySource() {
76
+ nodeBinary()
77
+ return cachedSource
78
+ }
79
+
80
+ /**
81
+ * 子进程环境:始终打开 ELECTRON_RUN_AS_NODE。
82
+ * 真正的 node 会忽略这个变量,所以无条件设置是安全的;而当 nodeBinary()
83
+ * 只能退回 Electron 二进制时,它就是让子进程以 node 模式运行的关键。
84
+ * @param {Record<string, string>} [extra]
85
+ */
86
+ export function childEnv(extra) {
87
+ return { ...process.env, ...(extra || {}), ELECTRON_RUN_AS_NODE: '1' }
88
+ }
89
+
90
+ /** 是否只能依赖 Electron 二进制的 node 模式(PATH 上没有真 node)。 */
91
+ export function usesElectronBinary() {
92
+ return nodeBinarySource() === 'electron-run-as-node'
93
+ }
94
+
95
+ /** 仅测试用:清掉进程内缓存。 */
96
+ export function resetNodeBinaryCache() {
97
+ cachedBinary = null
98
+ cachedSource = null
99
+ }
package/lib/stall.js ADDED
@@ -0,0 +1,30 @@
1
+ // 记忆核心 · 「轮次收尾事件是否真的没来」判定
2
+ //
3
+ // 旧判据:turnsStarted > turnsStopped && claimIdleMs > 15min && idleMs > 15min。两个毛病:
4
+ // 1) 两个计数**累积且永不归零**,而被取消/被拒的轮次不发 turn-stopping(子代理轮次同样不计)
5
+ // → 差额一旦出现就永久为真,判据退化成「只要空闲 15 分钟就报一次」(issue #4 的
6
+ // 「已开始 16 / 已收尾 12,且 15 分钟无任何沉淀活动」就是这么来的);
7
+ // 2) 真故障的特征恰好相反:轮次**持续开始**、只是没有收尾 —— 此时 claimed 一直新鲜,
8
+ // claimIdleMs 条件不满足 → 真故障反而不报。
9
+ //
10
+ // 现在改成滑动窗口:只看「窗口内开始了几个、收尾了几个」。
11
+ // 持续开始 + 零收尾 → 判停滞;用户离开(窗口内 0 开始)→ 不报。
12
+
13
+ /**
14
+ * @param {object} input
15
+ * @param {number[]} input.claimedAt 轮次开始时间戳(ms)
16
+ * @param {number[]} input.stoppedAt 轮次收尾时间戳(ms)
17
+ * @param {number} [input.now] 当前时间
18
+ * @param {number} [input.windowMs] 观察窗口,默认 20 分钟
19
+ * @param {number} [input.minStarted] 窗口内至少开始几个轮次才判停滞,默认 3
20
+ * @returns {{alert:boolean, started:number, stopped:number, windowMs:number}}
21
+ */
22
+ export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3 } = {}) {
23
+ const from = now - windowMs
24
+ const started = claimedAt.filter((t) => t >= from).length
25
+ const stopped = stoppedAt.filter((t) => t >= from).length
26
+ return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs }
27
+ }
28
+
29
+ /** 时间戳数组的裁剪上限:只保留窗口相关的近邻,避免长跑无界增长。 */
30
+ export const MAX_STAMPS = 200
package/lib/vault.js CHANGED
@@ -561,26 +561,94 @@ export async function graph(root) {
561
561
  if (arr) arr.push(c.path)
562
562
  else tagIndex.set(t, [c.path])
563
563
  }
564
+ // 标签边:同标签「全连接」会爆炸 —— 593 卡时 2.4 万条边,占总 payload 的 99%,前端每帧都要遍历。
565
+ // 改为「按共享标签数取 Top-K」:语义仍是「靠共享标签相邻」,但边数降一个数量级。
566
+ // 对每张卡用标签倒排收集候选与其共享标签数,只保留权重最高的 K 个(双向去重后约 n*K/2 条)。
567
+ const TAG_TOPK = 6
568
+ const pairWeight = new Map() // `a\u0000b`(排序后)-> 已加边的共享标签数
564
569
  for (const card of cards) {
570
+ const weights = new Map()
565
571
  for (const tag of card.tags) {
566
572
  for (const other of tagIndex.get(tag) || []) {
567
- if (other !== card.path) addEdge(card.path, other, `tag:${tag}`)
573
+ if (other === card.path) continue
574
+ weights.set(other, (weights.get(other) || 0) + 1)
568
575
  }
569
576
  }
577
+ // 候选不超过 K 时全连(小库/小标签簇),超过才截断 Top-K
578
+ const ranked = [...weights.entries()].sort((x, y) => y[1] - x[1])
579
+ const top = weights.size <= TAG_TOPK ? ranked : ranked.slice(0, TAG_TOPK)
580
+ for (const [other, w] of top) {
581
+ const [x, y] = [card.path, other].sort()
582
+ const key = `${x}\u0000${y}`
583
+ if ((pairWeight.get(key) || 0) >= w) continue // 已按更高权重加过这条边
584
+ pairWeight.set(key, w)
585
+ const xc = byPath.get(x)
586
+ const yc = byPath.get(y)
587
+ const shared = xc && yc ? xc.tags.find((t) => yc.tags.includes(t)) : ''
588
+ addEdge(x, y, shared ? `tag:${shared}` : 'tag')
589
+ }
570
590
  }
571
591
 
572
592
  // 相似度关联:bigram 集合只算一次(原先每对都重新分词两段正文=指数级白烧),
573
593
  // 再用数学上界剪枝(J ≤ min/max)跳过长度比过小的配对。
574
594
  const SIM_THRESHOLD = 0.42
575
595
  const bigramsByPath = new Map()
576
- for (const c of cards) bigramsByPath.set(c.path, bigramSet(bodyMap.get(c.path) || ''))
596
+ // df = 有多少张卡含这个 bigram。常见 bigram(df 很大)几乎均匀铺满全库,
597
+ // 撑不起 0.42 的 Jaccard;只用稀有 bigram 建倒排即可生成候选,避免 5.8 万对全量比对。
598
+ const DF_CAP = 24
599
+ const RARE_CAP = 120
600
+ const gramDf = new Map()
601
+ for (const c of cards) {
602
+ const s = bigramSet(bodyMap.get(c.path) || '')
603
+ bigramsByPath.set(c.path, s)
604
+ for (const g of s) gramDf.set(g, (gramDf.get(g) || 0) + 1)
605
+ }
606
+ const rareIndex = new Map()
607
+ const rareByPath = new Map()
608
+ for (const c of cards) {
609
+ const rare = []
610
+ for (const g of bigramsByPath.get(c.path)) if ((gramDf.get(g) || 0) <= DF_CAP) rare.push(g)
611
+ // 只取「最稀有」的 RARE_CAP 个参与候选生成:近重复卡一定共享大量稀有 gram,
612
+ // 而这一步是冷启动的真正热点(每张卡 × 每个稀有 gram 的倒排表)。
613
+ rare.sort((x, y) => (gramDf.get(x) || 0) - (gramDf.get(y) || 0))
614
+ rareByPath.set(c.path, rare.length <= RARE_CAP ? rare : rare.slice(0, RARE_CAP))
615
+ for (const g of rare) {
616
+ const arr = rareIndex.get(g)
617
+ if (arr) arr.push(c.path)
618
+ else rareIndex.set(g, [c.path])
619
+ }
620
+ }
577
621
  const similarCount = {}
578
- for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) {
579
- const a = cards[i], b = cards[j]
580
- if (a.kind !== b.kind && !a.tags.some((t) => b.tags.includes(t))) continue
581
- if ((similarCount[a.path] || 0) >= 1 || (similarCount[b.path] || 0) >= 1) continue
582
- const sim = jaccardSets(bigramsByPath.get(a.path), bigramsByPath.get(b.path), SIM_THRESHOLD)
583
- if (sim >= SIM_THRESHOLD) { addEdge(a.path, b.path, 'similar'); addDegree(a.path); addDegree(b.path); similarCount[a.path] = (similarCount[a.path] || 0) + 1; similarCount[b.path] = (similarCount[b.path] || 0) + 1 }
622
+ // 标签集合预先建成 Set:原先每个配对都做 tags.some(includes) 是 O(标签数²),配对 5.8 万次很可观。
623
+ const tagSetByPath = new Map(cards.map((c) => [c.path, new Set(c.tags)]))
624
+ // 候选生成:按共享稀有 bigram 数降序,只对候选做精确 Jaccard。
625
+ // 每张卡最多保留 1 条 similar 边(与旧行为一致)。
626
+ const sharedRare = new Map()
627
+ for (const card of cards) {
628
+ for (const gram of rareByPath.get(card.path)) {
629
+ for (const other of rareIndex.get(gram) || []) {
630
+ if (other !== card.path) sharedRare.set(other, (sharedRare.get(other) || 0) + 1)
631
+ }
632
+ }
633
+ if (!sharedRare.size) continue
634
+ // 候选上限:共享稀有 gram 最多的 40 张(近重复卡一定排在最前面,召回不受影响)
635
+ const ranked = [...sharedRare.entries()].sort((x, y) => y[1] - x[1])
636
+ const cands = ranked.length <= 40 ? ranked : ranked.slice(0, 40)
637
+ sharedRare.clear()
638
+ const A = bigramsByPath.get(card.path)
639
+ for (const [other, shared] of cands) {
640
+ if ((similarCount[card.path] || 0) >= 1) break
641
+ if ((similarCount[other] || 0) >= 1) continue
642
+ const b = byPath.get(other)
643
+ if (!b) continue
644
+ if (b.kind !== card.kind && !card.tags.some((t) => tagSetByPath.get(other).has(t))) continue
645
+ const B = bigramsByPath.get(other)
646
+ const lo = Math.min(A.size, B.size)
647
+ const hi = Math.max(A.size, B.size)
648
+ if (!hi || lo / hi < SIM_THRESHOLD) continue // 无损上界:J ≤ min/max
649
+ const sim = jaccardSets(A, B, SIM_THRESHOLD)
650
+ if (sim >= SIM_THRESHOLD) { addEdge(card.path, other, 'similar'); addDegree(card.path); addDegree(other); similarCount[card.path] = 1; similarCount[other] = 1; break }
651
+ }
584
652
  }
585
653
  const value = { nodes, edges }
586
654
  graphCache.set(root, { fp, value, at: Date.now() })
package/lib/watchdog.js CHANGED
@@ -15,14 +15,64 @@
15
15
 
16
16
  import http from 'node:http'
17
17
  import net from 'node:net'
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
18
20
  import { spawn } from 'node:child_process'
19
21
  import path from 'node:path'
20
22
  import { fileURLToPath } from 'node:url'
21
23
  import process from 'node:process'
24
+ import { nodeBinary, childEnv } from './node-bin.js'
22
25
 
23
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
24
27
  const WEB_JS = path.join(__dirname, 'web.js')
25
28
  const API_PREFIX = '/memory-eternal/api'
29
+ // -- 单例锁 -------------------------------------------------------------------
30
+ // 每个 DSH 启动都会 spawn 一个 detached watchdog,而旧代码没有任何存活检查 ——
31
+ // 多次重启就堆积多个 watchdog 常驻轮询同一端口(dsh-memory-eternal#6)。
32
+ // 这里用一个 pid 锁文件:同端口已有活着的 watchdog 就让它直接退出。
33
+ const LOCK_NAME = 'memory-eternal-watchdog.json'
34
+
35
+ /** 锁文件路径($DSH_HOME 下,与共享配置同处)。 */
36
+ export function watchdogLockPath(env = process.env) {
37
+ return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), LOCK_NAME)
38
+ }
39
+
40
+ /** 进程是否存活(Windows 下 process.kill(pid,0) 同样可用;EPERM 说明进程在但无权限)。 */
41
+ export function isPidAlive(pid) {
42
+ const n = Number(pid)
43
+ if (!n || n <= 0) return false
44
+ if (n === process.pid) return true
45
+ try { process.kill(n, 0); return true } catch (error) { return error?.code === 'EPERM' }
46
+ }
47
+
48
+ /** 读取锁文件;损坏或不存在返回 null。 */
49
+ export function readWatchdogLock(env = process.env) {
50
+ try { return JSON.parse(fs.readFileSync(watchdogLockPath(env), 'utf8')) } catch { return null }
51
+ }
52
+
53
+ /**
54
+ * 尝试取得看门狗锁。
55
+ * @returns {{acquired:boolean, file:string, previous:object|null}} acquired=false 表示「同端口已有活着的看门狗」,调用方应直接退出。
56
+ */
57
+ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid } = {}) {
58
+ const file = watchdogLockPath(env)
59
+ const prev = readWatchdogLock(env)
60
+ const samePortOwnerAlive = prev && prev.pid !== pid && Number(prev.port) === Number(port) && isPidAlive(prev.pid)
61
+ if (samePortOwnerAlive) return { acquired: false, file, previous: prev }
62
+ const stale = prev && !isPidAlive(prev.pid) ? prev : null
63
+ try {
64
+ fs.mkdirSync(path.dirname(file), { recursive: true })
65
+ fs.writeFileSync(file, JSON.stringify({ pid, port: Number(port), vault, startedAt: new Date().toISOString() }))
66
+ } catch { /* 写不了锁不阻塞保活 */ }
67
+ return { acquired: true, file, previous: stale }
68
+ }
69
+
70
+ /** 释放锁(只清自己的,避免误删别人刚写的)。 */
71
+ export function releaseWatchdogLock({ env = process.env, pid = process.pid } = {}) {
72
+ const cur = readWatchdogLock(env)
73
+ if (!cur || cur.pid !== pid) return false
74
+ try { fs.unlinkSync(watchdogLockPath(env)); return true } catch { return false }
75
+ }
26
76
 
27
77
  const ts = () => new Date().toISOString().slice(11, 19) // HH:MM:SS
28
78
  const log = (...args) => process.stderr.write(`[${ts()}] [watchdog] ${args.join(' ')}\n`)
@@ -93,13 +143,17 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
93
143
  const targetPort = await findFreePort(port)
94
144
  const args = [WEB_JS, '--port', String(targetPort)]
95
145
  if (vaultRoot) args.push('--vault', vaultRoot)
96
- log(`spawn web: node ${args.join(' ')}`)
97
- const c = spawn(process.execPath, args, {
146
+ log(`spawn web: ${nodeBinary()} ${args.join(' ')}`)
147
+ const c = spawn(nodeBinary(), args, {
98
148
  detached: true,
99
149
  stdio: 'ignore',
100
- env: { ...process.env, MEMORY_VAULT_DIR: vaultRoot || process.env.MEMORY_VAULT_DIR || '' },
150
+ env: childEnv({ MEMORY_VAULT_DIR: vaultRoot || process.env.MEMORY_VAULT_DIR || '' }),
101
151
  windowsHide: true,
102
152
  })
153
+ // spawn 失败默认静默,显式记录(Electron 宿主下曾因此完全无迹可查)
154
+ c.on('error', (error) => {
155
+ log(`web spawn failed (${nodeBinary()}): ${error?.message || error}`)
156
+ })
103
157
  c.on('exit', (code, sig) => {
104
158
  spawned.delete(c)
105
159
  if (!stopped && code !== 0) log(`web exited code=${code} sig=${sig}`)
@@ -221,5 +275,13 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
221
275
  const maxRestart = Number(argOf('--max-restart')) || 10
222
276
  const vaultRoot = argOf('--vault') || undefined
223
277
  const autoStart = !argv.includes('--no-restart')
278
+ const lock = acquireWatchdogLock({ port, vault: vaultRoot || '' })
279
+ if (!lock.acquired) {
280
+ log('同端口已有看门狗在跑(pid ' + (lock.previous && lock.previous.pid) + ',启动于 ' + (lock.previous && lock.previous.startedAt) + ')→ 本次启动退出,避免进程堆积')
281
+ process.exit(0)
282
+ }
283
+ if (lock.previous) log('清理陈旧看门狗记录(pid ' + lock.previous.pid + ' 已不存在)')
284
+ process.on('exit', () => releaseWatchdogLock())
224
285
  startWatchdog({ port, interval, maxRestart, vaultRoot, autoStart })
286
+ log('看门狗已启动(pid ' + process.pid + ',端口 ' + port + ')')
225
287
  }
package/lib/web.js CHANGED
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url'
17
17
  import { createApi, API_PREFIX, encodeBody } from './api.js'
18
18
  import { defaultVaultDir, configFilePath } from './capture-run.js'
19
19
  import { readCaptureLog, deriveHealth } from './capture-log.js'
20
+ import { nodeBinary, childEnv } from './node-bin.js'
20
21
 
21
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
22
23
  const WEB_ROOT = path.join(__dirname, '..', 'web')
@@ -112,13 +113,19 @@ export async function ensureWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = def
112
113
  }
113
114
  if (found) return found
114
115
 
115
- // detached spawn:独立进程,父进程退出不影响(常驻);stdio ignore 防阻塞
116
+ // detached spawn:独立进程,父进程退出不影响(常驻);stdio ignore 防阻塞。
117
+ // 注意用 nodeBinary() 而不是 process.execPath:在 Electron 宿主里后者是
118
+ // Electron 主程序,spawn 出来不会执行 web.js(见 lib/node-bin.js)。
116
119
  const { spawn } = await import('node:child_process')
117
120
  const child = spawn(
118
- process.execPath,
121
+ nodeBinary(),
119
122
  [path.join(__dirname, 'web.js'), '--port', String(target), '--vault', vaultRoot],
120
- { detached: true, stdio: 'ignore', env: { ...process.env, ...spawnEnv, MEMORY_VAULT_DIR: vaultRoot }, windowsHide: true },
123
+ { detached: true, stdio: 'ignore', env: childEnv({ ...spawnEnv, MEMORY_VAULT_DIR: vaultRoot }), windowsHide: true },
121
124
  )
125
+ // spawn 失败(ENOENT/EACCES/...)默认是静默的,这里显式暴露出来
126
+ child.on('error', (error) => {
127
+ process.stderr.write(`[memory-eternal] web server spawn failed (${nodeBinary()}): ${error?.message || error}\n`)
128
+ })
122
129
  child.unref()
123
130
 
124
131
  const deadline = Date.now() + totalTimeoutMs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,7 +60,7 @@
60
60
  "build": "node build.mjs",
61
61
  "build:client": "node build.mjs",
62
62
  "prepublishOnly": "node build.mjs",
63
- "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/api.test.mjs && node tests/settings-compat.test.mjs && node tests/ssr.test.mjs"
63
+ "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/api.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/ssr.test.mjs"
64
64
  },
65
65
  "dependencies": {
66
66
  "@deepseek-ai/schemastery": "^3.18.1"
@@ -89,7 +89,8 @@
89
89
  },
90
90
  "marketplace": {
91
91
  "profiles": [
92
- "web"
92
+ "web",
93
+ "desktop"
93
94
  ],
94
95
  "requiresBuildApproval": false,
95
96
  "requiresRestart": true,
@@ -122,7 +123,8 @@
122
123
  },
123
124
  "node": ">=22",
124
125
  "profiles": [
125
- "web"
126
+ "web",
127
+ "desktop"
126
128
  ]
127
129
  }
128
130
  }