dsh-mini-remote 0.1.8 → 0.1.9
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/CHANGELOG.md +36 -0
- package/lib/commands.js +6 -0
- package/lib/events.js +8 -3
- package/lib/history.js +7 -2
- package/lib/index.js +669 -13
- package/lib/page.html +2151 -103
- package/lib/page.js +3 -1
- package/lib/permissions.js +1 -0
- package/lib/server.js +268 -6
- package/lib/session-meta-cache.js +100 -0
- package/lib/store.js +232 -3
- package/lib/subagent-cache.js +63 -0
- package/lib/trajectory.js +570 -0
- package/lib/tree.js +30 -8
- package/package.json +1 -1
package/lib/store.js
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
8
8
|
import { join, dirname } from 'node:path'
|
|
9
|
+
import {
|
|
10
|
+
applyTrajectoryPatch,
|
|
11
|
+
capTrajectory,
|
|
12
|
+
mergeTrajectory,
|
|
13
|
+
TRAJECTORY_MAX_ENTRIES,
|
|
14
|
+
} from './trajectory.js'
|
|
9
15
|
|
|
10
16
|
export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
11
17
|
const state = {
|
|
@@ -41,6 +47,24 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
41
47
|
* 所以刷新页面看到的是「当前这一句」,而不是上一次的残留。
|
|
42
48
|
*/
|
|
43
49
|
thoughtBySession: {},
|
|
50
|
+
/**
|
|
51
|
+
* 执行轨迹(「完整」模式里那一块过程):sessionId ->
|
|
52
|
+
* `{ entries, turns, currentTurn, patches, dropped, seq }`。**故意不落盘**。
|
|
53
|
+
*
|
|
54
|
+
* 为什么连它一起不落盘(照 liveBySession 的理由,但这里更硬):实测轨迹正文是
|
|
55
|
+
* 回答正文的 90.7 倍(单轮最高约 946KB),而落盘那份是**每 500 毫秒重写整份 JSON**。
|
|
56
|
+
* 灌进去等于每半秒重写一遍几兆的文件。它本来就是「刚发生的过程」,重启后从会话记录里
|
|
57
|
+
* 重放即可(见 lib/index.js 读历史那一段),聊天记录才是要留的那份。
|
|
58
|
+
*
|
|
59
|
+
* - `entries`:扁平的条目数组,每条自带 turn/step;上限 TRAJECTORY_MAX_ENTRIES 条,从尾部留。
|
|
60
|
+
* - `turns`:轮号 -> { state, reason },只留「还有条目的轮」和「当前这一轮」。
|
|
61
|
+
* - `patches`:没认领到的补丁(插件中途起来时,那次调用的 tool/call 没亲眼看见,
|
|
62
|
+
* 但结果看见了)。少了它,手机上的那一行会一直停在「正在跑」。
|
|
63
|
+
* - `seq`:这个会话的帧序号,客户端靠它认出「我漏了几帧」。
|
|
64
|
+
*/
|
|
65
|
+
trajectoryBySession: {},
|
|
66
|
+
/** 当前父会话名下正在运行的子智能体:parentId -> childId -> true。只在内存中。 */
|
|
67
|
+
subagentRunningBySession: {},
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
/** 已知会话的元信息:sessionId -> { id, title, running, lastActivity, seq } */
|
|
@@ -99,6 +123,11 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
99
123
|
* (`sessionQuery.readSession`),而 store 拿不到 ctx,所以由插件注入一个回调。
|
|
100
124
|
* 回调返回 `{ entries, latest, pending }`;没读过这个会话时返回 null。
|
|
101
125
|
*
|
|
126
|
+
* **同一份记录里还带着执行轨迹**(`trajectory` / `trajectoryDropped`):轨迹和聊天记录
|
|
127
|
+
* 是同一批原始事件的两面,读一次日志顺手把两面都重放出来,别为它再读一遍盘。
|
|
128
|
+
* 轨迹那份**已经在插件那边按上限截过**(120 条)再放这儿,否则 20 个会话的缓存
|
|
129
|
+
* 会各压着几兆。
|
|
130
|
+
*
|
|
102
131
|
* **为什么不把读出来的历史塞进 `state.historyBySession`**:那一份是要落盘的,
|
|
103
132
|
* 而且是 500 毫秒写一次整个 JSON。几百条历史灌进去,等于每半秒重写一遍几百 KB
|
|
104
133
|
* 的文件。所以完整历史只活在内存里,落盘的仍然是「插件亲眼看过的事件」。
|
|
@@ -190,6 +219,92 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
190
219
|
return t ? { text: t.text, at: t.at } : null
|
|
191
220
|
}
|
|
192
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 「当前这一轮」的哨兵。用 Symbol 而不是 null:`turn` 本身可能是 null
|
|
224
|
+
* (事件里没带轮号),拿 null 当哨兵就分不清「还没轮到过」和「这一轮没有轮号」。
|
|
225
|
+
*/
|
|
226
|
+
const NO_TURN = Symbol('no-turn')
|
|
227
|
+
|
|
228
|
+
/** 轨迹缓冲的那一格。第一次碰到才建,删会话时一起丢。 */
|
|
229
|
+
function trajectorySlot(sessionId) {
|
|
230
|
+
let buf = state.trajectoryBySession[sessionId]
|
|
231
|
+
if (!buf) {
|
|
232
|
+
buf = state.trajectoryBySession[sessionId] = {
|
|
233
|
+
entries: [],
|
|
234
|
+
turns: new Map(),
|
|
235
|
+
currentTurn: NO_TURN,
|
|
236
|
+
patches: new Map(),
|
|
237
|
+
dropped: 0,
|
|
238
|
+
seq: 0,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return buf
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 轨迹缓冲也按会话数收着点:插件会看到**所有**主会话的事件,不设上限的话,
|
|
246
|
+
* 一个跑了几十上百个会话的晚上就是几十份几 MB 的条目压在内存里。
|
|
247
|
+
* 丢掉的是「没在看的那些会话」刚发生的那一段——老的都在会话记录里(重放那条路),
|
|
248
|
+
* 手机切过去照样能拉出来。
|
|
249
|
+
*/
|
|
250
|
+
function trimTrajectory(keepId) {
|
|
251
|
+
const ids = Object.keys(state.trajectoryBySession)
|
|
252
|
+
if (ids.length <= maxSessions) return
|
|
253
|
+
ids.sort((a, b) => (sessions.get(b)?.seq ?? 0) - (sessions.get(a)?.seq ?? 0))
|
|
254
|
+
for (const id of ids.slice(maxSessions)) {
|
|
255
|
+
if (id === keepId) continue
|
|
256
|
+
delete state.trajectoryBySession[id]
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 找那条条目(结果总是紧跟着它的调用回来,所以从后往前找)。 */
|
|
261
|
+
function trajectoryEntryOf(buf, id) {
|
|
262
|
+
for (let i = buf.entries.length - 1; i >= 0; i -= 1) {
|
|
263
|
+
if (buf.entries[i]?.id === id) return buf.entries[i]
|
|
264
|
+
}
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 轮状态表只留「还有条目的轮」+「当前这一轮」。
|
|
270
|
+
* 不留的话,一个长会话跑几百轮就攒几百个 key;而当前这一轮哪怕一条条目都还没有
|
|
271
|
+
* (turn/start 刚到)也得留——手机靠它把那一组先立起来。
|
|
272
|
+
*/
|
|
273
|
+
function pruneTrajectoryTurns(buf) {
|
|
274
|
+
if (!buf.turns.size) return
|
|
275
|
+
const live = new Set(buf.entries.map((e) => e.turn))
|
|
276
|
+
for (const turn of [...buf.turns.keys()]) {
|
|
277
|
+
if (live.has(turn) || turn === buf.currentTurn) continue
|
|
278
|
+
buf.turns.delete(turn)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 内存那份轨迹按轮分组(形状和重放那份、和增量帧要的形状一样)。 */
|
|
283
|
+
function liveTrajectoryTurns(buf) {
|
|
284
|
+
const out = []
|
|
285
|
+
const byTurn = new Map()
|
|
286
|
+
for (const entry of buf.entries) {
|
|
287
|
+
let group = byTurn.get(entry.turn)
|
|
288
|
+
if (!group) {
|
|
289
|
+
const st = buf.turns.get(entry.turn)
|
|
290
|
+
group = {
|
|
291
|
+
turn: entry.turn,
|
|
292
|
+
state: st?.state ?? 'running',
|
|
293
|
+
reason: st?.reason ?? null,
|
|
294
|
+
entries: [],
|
|
295
|
+
}
|
|
296
|
+
byTurn.set(entry.turn, group)
|
|
297
|
+
out.push(group)
|
|
298
|
+
}
|
|
299
|
+
group.entries.push(entry)
|
|
300
|
+
}
|
|
301
|
+
for (const [turn, st] of buf.turns) {
|
|
302
|
+
if (byTurn.has(turn)) continue
|
|
303
|
+
out.push({ turn, state: st.state, reason: st.reason ?? null, entries: [] })
|
|
304
|
+
}
|
|
305
|
+
return out
|
|
306
|
+
}
|
|
307
|
+
|
|
193
308
|
/**
|
|
194
309
|
* 只保留最近碰过的若干会话的数据,免得这个 JSON 文件无限长大。
|
|
195
310
|
* 被丢掉的会话切过去会显示「还没有回复」——**这比显示别人的回答强**。
|
|
@@ -203,6 +318,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
203
318
|
delete state.latestBySession[id]
|
|
204
319
|
delete state.liveBySession[id]
|
|
205
320
|
delete state.thoughtBySession[id]
|
|
321
|
+
delete state.trajectoryBySession[id]
|
|
206
322
|
}
|
|
207
323
|
}
|
|
208
324
|
|
|
@@ -291,6 +407,15 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
291
407
|
s.running = running
|
|
292
408
|
},
|
|
293
409
|
|
|
410
|
+
setSubagentRunning(parentSessionId, childSessionId, running) {
|
|
411
|
+
if (!parentSessionId || !childSessionId) return
|
|
412
|
+
const group = state.subagentRunningBySession[parentSessionId]
|
|
413
|
+
?? (state.subagentRunningBySession[parentSessionId] = {})
|
|
414
|
+
if (running) group[childSessionId] = true
|
|
415
|
+
else delete group[childSessionId]
|
|
416
|
+
if (!Object.keys(group).length) delete state.subagentRunningBySession[parentSessionId]
|
|
417
|
+
},
|
|
418
|
+
|
|
294
419
|
/** 最近活跃的会话——手机没绑定时默认遥控它。 */
|
|
295
420
|
mostRecentSession,
|
|
296
421
|
|
|
@@ -318,7 +443,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
318
443
|
* interrupted 是「这一轮被中止了,下面只是已经吐出来的那半句」。
|
|
319
444
|
* 手机要把它标出来——不标的话,按了停止之后冒出来的半句话看着像模型答崩了。
|
|
320
445
|
*/
|
|
321
|
-
pushReply({ text, sessionId, reason, interrupted, timestamp }) {
|
|
446
|
+
pushReply({ text, sessionId, reason, interrupted, timestamp, turn }) {
|
|
322
447
|
// 同上:事件带过来的 `time` 优先。合成时两边的钟不一样,同一条回复就会
|
|
323
448
|
// 既从硬盘来一遍、又从手里这份来一遍,手机上显示两遍。
|
|
324
449
|
const at = timestamp ?? Date.now()
|
|
@@ -329,12 +454,14 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
329
454
|
}
|
|
330
455
|
pushHistory(sessionId, {
|
|
331
456
|
role: 'assistant', text, timestamp: at, sessionId, interrupted: interrupted === true,
|
|
457
|
+
// 这条回复是第几轮的——完整模式把轨迹块插回对话原位要用(见 lib/history.js)。
|
|
458
|
+
turn: turn ?? null,
|
|
332
459
|
})
|
|
333
460
|
persist()
|
|
334
461
|
},
|
|
335
462
|
|
|
336
463
|
/** 用户(手机上或电脑上)发出了一条指令。 */
|
|
337
|
-
pushUser({ text, sessionId, id, timestamp }) {
|
|
464
|
+
pushUser({ text, sessionId, id, timestamp, turn }) {
|
|
338
465
|
// id 要留着:排队中的指令靠它和 agent 收件箱里的那一条对上号,
|
|
339
466
|
// 界面上才能标出「这条还在排队」、也才能撤掉它。
|
|
340
467
|
//
|
|
@@ -344,6 +471,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
344
471
|
// 同一条指令显示两遍(2026-09-27 用户实机报的,实测两份差 1 毫秒)。
|
|
345
472
|
pushHistory(sessionId, {
|
|
346
473
|
role: 'user', text, timestamp: timestamp ?? Date.now(), sessionId, id,
|
|
474
|
+
// 第几轮的指令——完整模式把轨迹块插回对话原位要用。手机自己发的那条
|
|
475
|
+
// (onInstruction 那路)发出去时轮还没开,轮号留空即可。
|
|
476
|
+
turn: turn ?? null,
|
|
347
477
|
})
|
|
348
478
|
persist()
|
|
349
479
|
},
|
|
@@ -358,7 +488,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
358
488
|
* 时间戳同样用事件自己的 `time`:硬盘重放出来那份(lib/history.js 里的 notice)
|
|
359
489
|
* 用的也是它,两边同一个钟才合成得成一条,而不是显示两遍。
|
|
360
490
|
*/
|
|
361
|
-
pushNotice({ sessionId, text, summary, senderSessionId, timestamp }) {
|
|
491
|
+
pushNotice({ sessionId, text, summary, senderSessionId, timestamp, turn }) {
|
|
362
492
|
pushHistory(sessionId, {
|
|
363
493
|
role: 'notice',
|
|
364
494
|
text,
|
|
@@ -366,6 +496,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
366
496
|
senderSessionId: senderSessionId ?? '',
|
|
367
497
|
timestamp: timestamp ?? Date.now(),
|
|
368
498
|
sessionId,
|
|
499
|
+
turn: turn ?? null,
|
|
369
500
|
})
|
|
370
501
|
persist()
|
|
371
502
|
},
|
|
@@ -460,6 +591,101 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
460
591
|
return state.presence === 'foreground' && Date.now() - state.lastSeen < 60_000
|
|
461
592
|
},
|
|
462
593
|
|
|
594
|
+
/**
|
|
595
|
+
* 实时轨迹来了一批变化:落进内存缓冲,并给出**这一批的增量帧**。
|
|
596
|
+
*
|
|
597
|
+
* 为什么是增量而不是整份:实测轨迹正文是回答正文的 90.7 倍(单轮最高约 946KB),
|
|
598
|
+
* 每来一次工具事件就把一整份重新发一遍(现在的 state 快照就是这么发的),
|
|
599
|
+
* 手机上会被压垮、隧道里流量也不可接受。所以这里只回「新加的那几条 + 改了的那几笔」。
|
|
600
|
+
*
|
|
601
|
+
* **不 persist()**:理由见 state.trajectoryBySession 那段。
|
|
602
|
+
*
|
|
603
|
+
* @returns {null|{turn:number|null, state:string|null, reason:string|null, seq:number,
|
|
604
|
+
* add:Array<object>, update:Array<object>}} null = 什么都没变,不必推
|
|
605
|
+
*/
|
|
606
|
+
applyTrajectory(sessionId, change) {
|
|
607
|
+
if (!sessionId || !change) return null
|
|
608
|
+
const entries = Array.isArray(change.entries) ? change.entries : []
|
|
609
|
+
const updates = Array.isArray(change.updates) ? change.updates : []
|
|
610
|
+
const turnState = typeof change.state === 'string' && change.state ? change.state : null
|
|
611
|
+
if (!entries.length && !updates.length && !turnState) return null
|
|
612
|
+
|
|
613
|
+
const buf = trajectorySlot(sessionId)
|
|
614
|
+
for (const entry of entries) buf.entries.push(entry)
|
|
615
|
+
for (const patch of updates) {
|
|
616
|
+
if (!patch?.id) continue
|
|
617
|
+
const hit = trajectoryEntryOf(buf, patch.id)
|
|
618
|
+
if (hit) applyTrajectoryPatch(hit, patch)
|
|
619
|
+
// 没认领到就先存着(见 state.trajectoryBySession 里 patches 那条):
|
|
620
|
+
// 插件可能是中途起来的——那次调用的 tool/call 没亲眼看见,结果却看见了。
|
|
621
|
+
// 直接丢掉的话,手机上那一行会一直停在「正在跑」。
|
|
622
|
+
else buf.patches.set(patch.id, { ...(buf.patches.get(patch.id) ?? {}), ...patch })
|
|
623
|
+
}
|
|
624
|
+
while (buf.patches.size > TRAJECTORY_MAX_ENTRIES) {
|
|
625
|
+
buf.patches.delete(buf.patches.keys().next().value)
|
|
626
|
+
}
|
|
627
|
+
if (turnState) {
|
|
628
|
+
buf.turns.set(change.turn, { state: turnState, reason: change.reason ?? null })
|
|
629
|
+
buf.currentTurn = change.turn
|
|
630
|
+
}
|
|
631
|
+
// 从尾部留:手机上看的是刚发生的过程,最早的先走。
|
|
632
|
+
if (buf.entries.length > TRAJECTORY_MAX_ENTRIES) {
|
|
633
|
+
const cut = buf.entries.length - TRAJECTORY_MAX_ENTRIES
|
|
634
|
+
buf.entries.splice(0, cut)
|
|
635
|
+
buf.dropped += cut
|
|
636
|
+
}
|
|
637
|
+
pruneTrajectoryTurns(buf)
|
|
638
|
+
trimTrajectory(sessionId)
|
|
639
|
+
buf.seq += 1
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
turn: change.turn ?? null,
|
|
643
|
+
state: turnState,
|
|
644
|
+
reason: turnState ? (change.reason ?? null) : null,
|
|
645
|
+
seq: buf.seq,
|
|
646
|
+
add: entries,
|
|
647
|
+
update: updates,
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 这个会话的轨迹视图:内存那份(刚发生的)+ 会话记录里重放出来的那份(更完整的),
|
|
653
|
+
* 按 id 合成一组一轮。上限 120 条、从尾部留;「这不是全部」由 truncated/note 说出去。
|
|
654
|
+
*
|
|
655
|
+
* 条目是**副本**:GET 接口会往上贴那些没认领到的补丁,不能回头污染重放缓存。
|
|
656
|
+
* 序列号 `seq` 和帧里那个是同一个(客户端据此认出「我漏了几帧」)。
|
|
657
|
+
*/
|
|
658
|
+
trajectoryOf(sessionId) {
|
|
659
|
+
const buf = sessionId ? state.trajectoryBySession[sessionId] : null
|
|
660
|
+
const replay = sessionId && replaySource ? replaySource(sessionId) : null
|
|
661
|
+
const merged = mergeTrajectory(
|
|
662
|
+
buf ? liveTrajectoryTurns(buf) : [],
|
|
663
|
+
replay?.trajectory ?? null,
|
|
664
|
+
)
|
|
665
|
+
if (buf?.patches.size) {
|
|
666
|
+
for (const group of merged) {
|
|
667
|
+
for (const entry of group.entries ?? []) {
|
|
668
|
+
const patch = buf.patches.get(entry.id)
|
|
669
|
+
if (patch) applyTrajectoryPatch(entry, patch)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const capped = capTrajectory(merged, TRAJECTORY_MAX_ENTRIES)
|
|
674
|
+
const cut = capped.dropped > 0
|
|
675
|
+
|| (buf?.dropped ?? 0) > 0
|
|
676
|
+
|| (replay?.trajectoryDropped ?? 0) > 0
|
|
677
|
+
return {
|
|
678
|
+
sessionId: sessionId ?? null,
|
|
679
|
+
seq: buf?.seq ?? 0,
|
|
680
|
+
turns: capped.turns,
|
|
681
|
+
truncated: cut,
|
|
682
|
+
note: cut ? `轨迹只显示最近一段(读取上限:${TRAJECTORY_MAX_ENTRIES} 条)` : null,
|
|
683
|
+
// 读会话记录是后台动作:手机切到「完整」模式那一刻可能还没读完。
|
|
684
|
+
// 空了要说「正在读」,不能让人以为这个会话没有过程。
|
|
685
|
+
loading: replay?.pending === true && !buf,
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
|
|
463
689
|
snapshot() {
|
|
464
690
|
const target = targetSessionId()
|
|
465
691
|
// 内存里记着的那一份(插件在场时看见的)
|
|
@@ -529,6 +755,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
529
755
|
* 每次现读,不缓存:见 setQueueSource 的说明。
|
|
530
756
|
*/
|
|
531
757
|
queued: target ? queueSource(target) : [],
|
|
758
|
+
subagentsRunning: Boolean(
|
|
759
|
+
target && Object.keys(state.subagentRunningBySession[target] ?? {}).length,
|
|
760
|
+
),
|
|
532
761
|
// 同毫秒时用 seq 兜底,顺序才是确定的
|
|
533
762
|
sessions: [...sessions.values()].sort(
|
|
534
763
|
(a, b) => (b.lastActivity - a.lastActivity) || (b.seq - a.seq),
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** 子代理结构清单缓存:只存 id、父级、类型和名称,不存运行记录或正文。 */
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
export function createSubagentCache({ file, ttl = TTL_MS, debounceMs = 500, now = Date.now }) {
|
|
8
|
+
const rows = new Map()
|
|
9
|
+
try {
|
|
10
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
11
|
+
const at = now()
|
|
12
|
+
for (const [parentId, row] of Object.entries(raw ?? {})) {
|
|
13
|
+
if (!row || typeof row.at !== 'number' || at - row.at > ttl || !Array.isArray(row.subagents)) continue
|
|
14
|
+
rows.set(parentId, { subagents: row.subagents, at: row.at })
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// 首次启动或缓存损坏时从空缓存开始。
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let timer = null
|
|
21
|
+
let dirty = false
|
|
22
|
+
function save() {
|
|
23
|
+
timer = null
|
|
24
|
+
if (!dirty) return
|
|
25
|
+
dirty = false
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
28
|
+
const tmp = `${file}.tmp`
|
|
29
|
+
const raw = {}
|
|
30
|
+
for (const [parentId, row] of rows) raw[parentId] = row
|
|
31
|
+
writeFileSync(tmp, JSON.stringify(raw))
|
|
32
|
+
renameSync(tmp, file)
|
|
33
|
+
} catch {
|
|
34
|
+
// 缓存写失败不影响当前运行。
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function schedule() {
|
|
38
|
+
dirty = true
|
|
39
|
+
if (timer) return
|
|
40
|
+
timer = setTimeout(save, debounceMs)
|
|
41
|
+
timer.unref?.()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
get(parentId) {
|
|
46
|
+
const row = rows.get(parentId)
|
|
47
|
+
if (!row) return null
|
|
48
|
+
if (now() - row.at > ttl) {
|
|
49
|
+
rows.delete(parentId)
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
return row.subagents.map((item) => ({ ...item }))
|
|
53
|
+
},
|
|
54
|
+
set(parentId, subagents) {
|
|
55
|
+
if (!parentId || !Array.isArray(subagents)) return
|
|
56
|
+
rows.set(parentId, { subagents: subagents.map((item) => ({ ...item })), at: now() })
|
|
57
|
+
schedule()
|
|
58
|
+
},
|
|
59
|
+
delete(parentId) {
|
|
60
|
+
if (rows.delete(parentId)) schedule()
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|