dsh-memory-cloud 1.0.1 → 1.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 CHANGED
@@ -15,8 +15,8 @@ DSH 没有独立的记忆库:每一条持久记忆都是 `$DSH_HOME/sessions`
15
15
  - **星云**(默认):不规则絮状气体云(无球核、无硬边界,参考 NASA 星系云团),团间用细电弧连接;电流靠**沿线明暗渐变**表现传递,暗区纯黑、余晖自然衰减。
16
16
  - **神经元**:细胞体 + 递归分叉的树状树突 + 突触终扣 + 弯曲轴突,共用同一套信号波与流速。
17
17
  - **规模映射真实数据**:云团凝聚核数、光斑数、纤维/尘埃密度、记忆星数量、树突主干数与分叉深度,全部按该会话的实际碎片数/事件数归一化缩放。
18
- - **分级细节(LOD)**:缩放即钻取,每放大一级就多露出一层记忆——
19
- | 等级 | 徽章 | 触发缩放 | 显示内容 |
18
+ - **实时记忆流**:插件订阅 Host 的 `session/event`(提交后追加流),通过 SSE 把"记忆正在写入 / 正在被读取"推到页面。右下角 HUD 显示写入/读取计数、事件速率与滚动活动流;画布上同步出现**写入涟漪向外扩散、读取涟漪向内收拢**、新生记忆星,以及被激活星云团持续数秒的热晕。对话开始时若该会话还没落盘,会先长出一个**临时星云团**,落盘后自动并入正式数据。工具调用类噪声事件按 600ms 合并,避免刷屏。
19
+ - **分级细节(LOD)**:缩放即钻取,每放大一级就多露出一层记忆—— | 等级 | 徽章 | 触发缩放 | 显示内容 |
20
20
  |---|---|---|---|
21
21
  | 0 | 全景 | < 1.5× | 云团 + 会话标题 |
22
22
  | 1 | 星团 | ≥ 1.5× | 标题 + 记忆条数/对话数/工具数 |
@@ -106,6 +106,14 @@ trustedHosts: [] # 额外允许访问插件路由的 Host
106
106
  { "id", "title", "workspaceLabel", "createdAt", "updatedAt", "counts", "fragments": [{ "kind", "text", "truncated", "time", "seq" }] }
107
107
  ```
108
108
 
109
+ `GET /memory-cloud/api/live` —— **SSE 实时记忆活动流**(`text/event-stream`,15 秒心跳,连接建立时回放最近 24 条):
110
+
111
+ ```json
112
+ { "seq", "sessionId", "sessionTitle", "kind": "write" | "read", "type", "text", "tool", "time" }
113
+ ```
114
+
115
+ `kind` 语义:`write` = 一条记忆刚被提交写入会话日志(用户消息、助手消息、会话标题);`read` = 一条记忆正被读取/引用(工具调用与结果、上下文注入、压缩整理、轮次结束)。
116
+
109
117
  插件对会话日志与 profile **只读**,不写入任何状态;唯一的持久化是浏览器 `localStorage` 里的皮肤选择。
110
118
 
111
119
  ## 许可
package/index.js CHANGED
@@ -17,6 +17,133 @@ import { zstdDecompressSync } from 'node:zlib'
17
17
 
18
18
  export const inject = ['webServer']
19
19
 
20
+ // Events that constitute a memory access. `session/event` is the post-commit
21
+ // append feed, so every item we forward is already durable in the session log.
22
+ // "write" = a memory being stored; "read" = a memory being consulted.
23
+ const LIVE_EVENT_KINDS = new Map([
24
+ ['user/message', 'write'],
25
+ ['assistant/message', 'write'],
26
+ ['session/title', 'write'],
27
+ ['agent/inbox/spliced', 'read'],
28
+ ['request/context', 'read'],
29
+ ['tool/call', 'read'],
30
+ ['tool/result', 'read'],
31
+ ['compaction/summary', 'read'],
32
+ ['turn/end', 'read'],
33
+ ])
34
+
35
+ /**
36
+ * Broadcast hub for live memory activity. One SSE connection per open nebula
37
+ * page; the registration is an effect so plugin unload tears every socket,
38
+ * listener and timer down cleanly.
39
+ */
40
+ class LiveHub {
41
+ constructor() {
42
+ this.clients = new Set()
43
+ this.recent = [] // replay buffer so a fresh page sees the last burst
44
+ this.seq = 0
45
+ this.heartbeat = null
46
+ this.lastSent = new Map() // "session:kind:type:tool" -> timestamp
47
+ }
48
+
49
+ add(res) {
50
+ const client = { res, alive: true }
51
+ this.clients.add(client)
52
+ if (this.clients.size === 1) {
53
+ this.heartbeat = setInterval(() => {
54
+ for (const c of this.clients) if (c.alive) c.res.write(': ping\n\n')
55
+ }, 15000)
56
+ }
57
+ // Initial handshake + replay of the last few events.
58
+ res.write(`event: hello\ndata: ${JSON.stringify({ clients: this.clients.size })}\n\n`)
59
+ for (const item of this.recent) res.write(`data: ${JSON.stringify(item)}\n\n`)
60
+ const close = () => this.remove(client)
61
+ res.on('close', close)
62
+ res.on('error', close)
63
+ return () => this.remove(client)
64
+ }
65
+
66
+ remove(client) {
67
+ client.alive = false
68
+ this.clients.delete(client)
69
+ if (this.clients.size === 0 && this.heartbeat !== null) {
70
+ clearInterval(this.heartbeat)
71
+ this.heartbeat = null
72
+ }
73
+ }
74
+
75
+ publish(item) {
76
+ // A single agent step can emit dozens of tool/call + tool/result rows.
77
+ // Coalesce identical chatter inside a short window so the feed shows the
78
+ // shape of activity rather than drowning in duplicates.
79
+ const key = `${item.sessionId}:${item.kind}:${item.type}:${item.tool || ''}`
80
+ const now = Date.now()
81
+ const noisy = item.type === 'tool/call' || item.type === 'tool/result'
82
+ if (noisy) {
83
+ const last = this.lastSent.get(key) || 0
84
+ if (now - last < 600) return
85
+ this.lastSent.set(key, now)
86
+ if (this.lastSent.size > 200) this.lastSent.clear()
87
+ }
88
+ item.seq = ++this.seq
89
+ this.recent.push(item)
90
+ if (this.recent.length > 24) this.recent.shift()
91
+ const line = `data: ${JSON.stringify(item)}\n\n`
92
+ for (const c of this.clients) {
93
+ if (!c.alive) continue
94
+ try { c.res.write(line) } catch { this.remove(c) }
95
+ }
96
+ }
97
+
98
+ dispose() {
99
+ if (this.heartbeat !== null) clearInterval(this.heartbeat)
100
+ this.heartbeat = null
101
+ for (const c of this.clients) {
102
+ c.alive = false
103
+ try { c.res.end() } catch { /* already gone */ }
104
+ }
105
+ this.clients.clear()
106
+ }
107
+ }
108
+
109
+ /** Fold one raw committed session event into a compact live activity item. */
110
+ function liveItemFromEvent(session, event) {
111
+ const kind = LIVE_EVENT_KINDS.get(event?.type)
112
+ if (kind === undefined) return null
113
+ const data = event.data || {}
114
+ const sessionId = typeof session?.id === 'string' ? session.id : ''
115
+ if (sessionId === '') return null
116
+ const text = livePreview(event, data)
117
+ const title = typeof session?.title === 'string' ? session.title : ''
118
+ return {
119
+ sessionId,
120
+ sessionTitle: title,
121
+ kind,
122
+ type: event.type,
123
+ text,
124
+ tool: typeof data.name === 'string' ? data.name : undefined,
125
+ time: typeof event.time === 'number' ? event.time : Date.now(),
126
+ }
127
+ }
128
+
129
+ function livePreview(event, data) {
130
+ let content
131
+ switch (event.type) {
132
+ case 'user/message': content = data.content; break
133
+ case 'assistant/message': content = data.message?.content; break
134
+ case 'tool/call': return typeof data.name === 'string' ? `调用 ${data.name}` : ''
135
+ case 'tool/result': return '工具返回'
136
+ case 'turn/end': return '一轮对话完成'
137
+ case 'compaction/summary': return '上下文压缩 / 记忆整理'
138
+ case 'request/context': return '读取运行时上下文'
139
+ default: content = null
140
+ }
141
+ if (content == null) return event.type === 'session/title' && typeof data.title === 'string' ? data.title : ''
142
+ const s = contentText(content)
143
+ if (s.startsWith(RUNTIME_CONTEXT_PREFIX)) return '' // injected scaffolding
144
+ return s.replace(/\s+/g, ' ').slice(0, 90)
145
+ }
146
+
20
147
  const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
21
148
  const LOG_PATTERN = /^session(?:\.[\w-]+)*\.jsonl\.zstd$/
22
149
  const RUNTIME_CONTEXT_PREFIX = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.'
@@ -564,6 +691,26 @@ export function apply(ctx, config) {
564
691
  ? config.sessionsDir
565
692
  : (process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : '')
566
693
  const reader = new MemoryReader(sessionsDir, limits)
694
+ const live = new LiveHub()
695
+ // Title is not on the Session object for every event; keep a small map so the
696
+ // HUD can label the cluster being written to.
697
+ const liveTitles = new Map()
698
+ ctx.on('session/event', (session, event) => {
699
+ try {
700
+ if (event?.type === 'session/title' && typeof event.data?.title === 'string') {
701
+ liveTitles.set(session.id, event.data.title)
702
+ }
703
+ const item = liveItemFromEvent(session, event)
704
+ if (item === null) return
705
+ if (!item.sessionTitle) item.sessionTitle = liveTitles.get(item.sessionId) || ''
706
+ // Ignore empty scaffolding previews (runtime context, empty assistant).
707
+ if (item.type !== 'tool/call' && item.type !== 'tool/result' && item.text === '') return
708
+ live.publish(item)
709
+ } catch (error) {
710
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
711
+ }
712
+ })
713
+ ctx.effect(() => () => live.dispose(), 'memory-cloud: live hub')
567
714
 
568
715
  // The DSH /api browser-trust fence does not cover these routes, so the
569
716
  // handler checks the Host header itself (DNS-rebinding defense), mirroring
@@ -587,6 +734,19 @@ export function apply(ctx, config) {
587
734
  if (req.method !== 'GET' && req.method !== 'HEAD') return sendJson(res, 405, { error: 'Method not allowed' })
588
735
  const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
589
736
 
737
+ if (path === '/memory-cloud/api/live') {
738
+ res.writeHead(200, {
739
+ 'content-type': 'text/event-stream; charset=utf-8',
740
+ 'cache-control': 'no-cache, no-store',
741
+ connection: 'keep-alive',
742
+ 'x-accel-buffering': 'no',
743
+ })
744
+ res.write(': connected\n\n')
745
+ const release = live.add(res)
746
+ req.on('close', release)
747
+ req.on('error', release)
748
+ return
749
+ }
590
750
  if (path === '/memory-cloud/api/memories') {
591
751
  const data = await reader.snapshot()
592
752
  return sendJson(res, 200, data)
package/nebula.css CHANGED
@@ -132,6 +132,73 @@ body {
132
132
  .legend-item .count { margin-left: auto; color: var(--mc-muted); opacity: .8; font-variant-numeric: tabular-nums; }
133
133
  .legend-item.dim { opacity: .38; }
134
134
 
135
+ /* ---------------------------------------------------------------------------
136
+ Live memory activity panel
137
+ --------------------------------------------------------------------------- */
138
+ .live-panel {
139
+ position: fixed; z-index: 6; right: calc(20px + var(--mc-safe-r));
140
+ bottom: calc(76px + var(--mc-safe-b)); width: 248px;
141
+ background: var(--mc-panel); border: 1px solid var(--mc-line); border-radius: 11px;
142
+ backdrop-filter: blur(14px); box-shadow: 0 10px 32px rgba(0,0,0,.38);
143
+ overflow: hidden;
144
+ }
145
+ .live-head {
146
+ display: flex; align-items: center; gap: 8px; padding: 9px 11px;
147
+ border-bottom: 1px solid var(--mc-line);
148
+ }
149
+ .live-dot {
150
+ width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
151
+ background: #6fe0b0; box-shadow: 0 0 9px #6fe0b0;
152
+ animation: livePulse 2s ease-in-out infinite;
153
+ }
154
+ .live-panel[data-connected="false"] .live-dot { background: #d8a25a; box-shadow: 0 0 9px #d8a25a; animation: none; }
155
+ .live-panel[data-active="true"] .live-dot { animation-duration: .55s; }
156
+ @keyframes livePulse { 0%,100% { opacity: .45; transform: scale(.82) } 50% { opacity: 1; transform: scale(1.15) } }
157
+ .live-title { font-size: 11.5px; font-weight: 650; color: var(--mc-fg); letter-spacing: .4px; }
158
+ .live-toggle {
159
+ margin-left: auto; width: 20px; height: 20px; border: 1px solid var(--mc-line);
160
+ border-radius: 5px; background: transparent; color: var(--mc-muted); cursor: pointer;
161
+ font-size: 13px; line-height: 1;
162
+ }
163
+ .live-toggle:hover { color: var(--mc-fg); }
164
+ .live-panel.collapsed #live-body { display: none; }
165
+ .live-metrics {
166
+ display: flex; gap: 10px; flex-wrap: wrap; padding: 8px 11px 5px;
167
+ font-size: 10px; color: var(--mc-muted);
168
+ }
169
+ .live-metrics b { color: var(--mc-fg); font-size: 12px; font-variant-numeric: tabular-nums; }
170
+ .live-metrics .live-rate { margin-left: auto; }
171
+ .live-feed { max-height: 148px; overflow-y: auto; padding: 2px 11px 9px; scrollbar-width: none; }
172
+ .live-feed::-webkit-scrollbar { display: none; }
173
+ .live-row {
174
+ display: flex; align-items: baseline; gap: 7px; padding: 4px 0;
175
+ border-bottom: 1px solid rgba(150,170,255,.07); font-size: 10.5px; line-height: 1.45;
176
+ animation: liveIn .35s ease-out both;
177
+ }
178
+ .live-row:last-child { border-bottom: 0; }
179
+ @keyframes liveIn { from { opacity: 0; transform: translateY(-5px) } to { opacity: 1; transform: none } }
180
+ .live-tag { flex: 0 0 auto; font-size: 9px; font-weight: 700; letter-spacing: .3px; padding: 1px 5px; border-radius: 4px; }
181
+ .live-row.write .live-tag { color: #9fe0ff; background: rgba(90,170,255,.18); }
182
+ .live-row.write { border-left: 2px solid #6fc8ff; padding-left: 6px; }
183
+ .live-row.write .live-text { color: #cfe4ff; }
184
+ .live-row.read .live-tag { color: #e0c59a; background: rgba(220,180,100,.16); }
185
+ .live-text { color: var(--mc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
186
+ .live-text b { color: var(--mc-fg); font-weight: 550; }
187
+ .live-empty { padding: 8px 0; font-size: 10.5px; color: var(--mc-muted); opacity: .7; }
188
+
189
+ @media (max-width: 820px) {
190
+ .live-panel {
191
+ right: auto; left: calc(12px + var(--mc-safe-l));
192
+ bottom: calc(70px + var(--mc-safe-b)); width: min(220px, 58vw);
193
+ }
194
+ .live-feed { max-height: 88px; }
195
+ }
196
+ @media (max-width: 420px) {
197
+ .live-panel { width: min(190px, 54vw); }
198
+ .live-metrics { gap: 7px; font-size: 9px; }
199
+ .live-feed { max-height: 70px; }
200
+ }
201
+
135
202
  .stats {
136
203
  position: fixed; left: calc(20px + var(--mc-safe-l)); bottom: calc(20px + var(--mc-safe-b)); z-index: 4;
137
204
  display: flex; flex-wrap: wrap; gap: 8px 18px; max-width: 62vw;
package/nebula.html CHANGED
@@ -41,6 +41,22 @@
41
41
 
42
42
  <div id="relation-key" class="relation-key" aria-label="关联图例"></div>
43
43
 
44
+ <section id="live-panel" class="live-panel" aria-label="实时记忆活动">
45
+ <header class="live-head">
46
+ <span class="live-dot" aria-hidden="true"></span>
47
+ <span class="live-title">实时记忆流</span>
48
+ <button id="live-toggle" class="live-toggle" type="button" aria-pressed="true" title="收起/展开">−</button>
49
+ </header>
50
+ <div id="live-body">
51
+ <div class="live-metrics">
52
+ <span><b id="live-writes">0</b> 写入</span>
53
+ <span><b id="live-reads">0</b> 读取</span>
54
+ <span class="live-rate"><b id="live-rate">0</b> 事件/分</span>
55
+ </div>
56
+ <div id="live-feed" class="live-feed" aria-live="polite"></div>
57
+ </div>
58
+ </section>
59
+
44
60
  <div id="stats" class="stats" aria-label="统计信息"></div>
45
61
 
46
62
  <div id="tooltip" class="tooltip" hidden></div>
package/nebula.js CHANGED
@@ -16,6 +16,12 @@
16
16
  legend: document.getElementById('legend'),
17
17
  relationKey: document.getElementById('relation-key'),
18
18
  stats: document.getElementById('stats'),
19
+ livePanel: document.getElementById('live-panel'),
20
+ liveFeed: document.getElementById('live-feed'),
21
+ liveWrites: document.getElementById('live-writes'),
22
+ liveReads: document.getElementById('live-reads'),
23
+ liveRate: document.getElementById('live-rate'),
24
+ liveToggle: document.getElementById('live-toggle'),
19
25
  skinButtons: [...document.querySelectorAll('.skin-btn')],
20
26
  tooltip: document.getElementById('tooltip'),
21
27
  detail: document.getElementById('detail'),
@@ -386,6 +392,17 @@
386
392
  w: 0, h: 0, dpr: 1,
387
393
  time: 0,
388
394
  quality: 1, // 1 = full crest sampling; 0.5 under sustained load
395
+ // --- live memory activity ---
396
+ live: {
397
+ connected: false,
398
+ writes: 0,
399
+ reads: 0,
400
+ pulses: [], // expanding rings on the target cloud
401
+ newborns: [], // fresh stars that fade into the cloud
402
+ times: [], // timestamps for the events/minute readout
403
+ lastBurst: 0,
404
+ liveClusters: new Map(), // sessions not on disk yet -> ephemeral nebula
405
+ },
389
406
  isMobile: false,
390
407
  // Two-finger pinch state, kept separate from the single-finger drag.
391
408
  pinch: null, // { startDist, startZoom }
@@ -531,6 +548,223 @@
531
548
  : `${data.stats.sessions} 个会话记忆 · ${data.stats.fragments} 颗记忆星 · 拖拽以探索`
532
549
  if (state.isMobile) el.search.placeholder = '搜索记忆'
533
550
  updateHint()
551
+ connectLive()
552
+ }
553
+
554
+ // -------------------------------------------------------------------------
555
+ // Live memory activity
556
+ //
557
+ // `session/event` is the post-commit append feed, so each SSE item is a
558
+ // memory that was JUST written (or a memory being read back as context).
559
+ // A brand-new session has no cluster on disk yet, so we grow a small
560
+ // ephemeral cloud for it and retire it once the log appears on the next
561
+ // snapshot. This makes an active conversation visibly bloom.
562
+ // -------------------------------------------------------------------------
563
+
564
+ function escapeLive(s) {
565
+ return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
566
+ }
567
+
568
+ function connectLive() {
569
+ if (typeof EventSource === 'undefined') return
570
+ let source
571
+ try { source = new EventSource('/memory-cloud/api/live') } catch { return }
572
+ source.onopen = () => {
573
+ state.live.connected = true
574
+ el.livePanel.dataset.connected = 'true'
575
+ }
576
+ source.onerror = () => {
577
+ state.live.connected = false
578
+ el.livePanel.dataset.connected = 'false'
579
+ }
580
+ source.onmessage = event => {
581
+ let item
582
+ try { item = JSON.parse(event.data) } catch { return }
583
+ if (!item || !item.sessionId) return
584
+ onLiveItem(item)
585
+ }
586
+ }
587
+
588
+ function clusterForLive(item) {
589
+ let cluster = state.clusters.find(c => c.session.id === item.sessionId)
590
+ if (cluster) return cluster
591
+ // Not yet on disk: create a temporary nebula at a free spot.
592
+ let ephemeral = state.live.liveClusters.get(item.sessionId)
593
+ if (!ephemeral) {
594
+ const idx = state.live.liveClusters.size
595
+ const rand = rng(hash(item.sessionId) ^ 0x51ab)
596
+ const angle = idx * 2.399 + rand()
597
+ const radius = 150 + rand() * 90
598
+ const center = [Math.cos(angle) * radius, (rand() - 0.5) * 170, Math.sin(angle) * radius]
599
+ const cloud = buildCloud(center, hash(item.sessionId) ^ 0x77, 0.8, 0.25)
600
+ ephemeral = {
601
+ session: {
602
+ id: item.sessionId,
603
+ title: item.sessionTitle || '正在进行的对话',
604
+ counts: { events: 0, user: 0, assistant: 0, tools: 0, turns: 0, fragments: 0 },
605
+ fragments: [], topics: [],
606
+ },
607
+ color: PALETTE[(idx + 3) % PALETTE.length],
608
+ center, cloud, stars: [], ephemeral: true,
609
+ dendrites: buildDendrites(center, hash(item.sessionId) ^ 0x2c91, 0.35),
610
+ core: null, weight: 0.25,
611
+ lobeCenters: cloud.lobeCenters,
612
+ }
613
+ // Reuse the normal hit-test core so tapping the new cloud works.
614
+ const core = {
615
+ kind: 'core', cluster: ephemeral, isClusterCore: true, fragment: null,
616
+ pos: center.slice(), size: 2.2, phase: 0, color: ephemeral.color,
617
+ }
618
+ ephemeral.core = core
619
+ state.stars.push(core)
620
+ state.live.liveClusters.set(item.sessionId, ephemeral)
621
+ state.clusters.push(ephemeral)
622
+ }
623
+ if (item.sessionTitle && ephemeral.session.title === '正在进行的对话') {
624
+ ephemeral.session.title = item.sessionTitle
625
+ }
626
+ return ephemeral
627
+ }
628
+
629
+ function onLiveItem(item) {
630
+ const now = performance.now()
631
+ const cluster = clusterForLive(item)
632
+ cluster.lastLiveAt = now
633
+ const isWrite = item.kind === 'write'
634
+ if (isWrite) state.live.writes += 1
635
+ else state.live.reads += 1
636
+ state.live.times.push(now)
637
+ if (state.live.times.length > 120) state.live.times.shift()
638
+
639
+ // Expanding ring on the target cloud: write rings ripple outward, read
640
+ // rings ripple inward (memory being consulted / pulled back).
641
+ state.live.pulses.push({
642
+ cluster, born: now, kind: isWrite ? 'write' : 'read',
643
+ color: isWrite ? [150, 215, 255] : [235, 195, 130],
644
+ })
645
+ if (state.live.pulses.length > 26) state.live.pulses.shift()
646
+
647
+ if (isWrite) {
648
+ const lobe = cluster.cloud.lobeCenters[
649
+ Math.floor(Math.random() * cluster.cloud.lobeCenters.length)] || cluster.center
650
+ state.live.newborns.push({
651
+ cluster, born: now,
652
+ pos: [lobe[0] + (Math.random() - 0.5) * 16, lobe[1] + (Math.random() - 0.5) * 12, lobe[2] + (Math.random() - 0.5) * 16],
653
+ color: cluster.color,
654
+ phase: Math.random() * Math.PI * 2,
655
+ })
656
+ if (state.live.newborns.length > 60) state.live.newborns.shift()
657
+ cluster.session.counts.fragments += 1
658
+ }
659
+
660
+ state.live.lastBurst = now
661
+ el.livePanel.dataset.active = 'true'
662
+ el.liveWrites.textContent = String(state.live.writes)
663
+ el.liveReads.textContent = String(state.live.reads)
664
+ appendLiveRow(item, cluster)
665
+ updateLiveRate(now)
666
+ }
667
+
668
+ function appendLiveRow(item, cluster) {
669
+ if (el.liveFeed.querySelector('.live-empty')) el.liveFeed.innerHTML = ''
670
+ const row = document.createElement('div')
671
+ row.className = `live-row ${item.kind}`
672
+ const label = item.kind === 'write' ? '写入' : '读取'
673
+ const body = item.text || item.tool || item.type || ''
674
+ const title = cluster.session.title || '会话'
675
+ row.innerHTML = `<span class="live-tag">${label}</span><span class="live-text" title="${escapeLive(body)}"><b>${escapeLive(title.slice(0, 14))}</b> ${escapeLive(body.slice(0, 60))}</span>`
676
+ el.liveFeed.prepend(row)
677
+ while (el.liveFeed.children.length > 14) el.liveFeed.lastChild.remove()
678
+ }
679
+
680
+ function updateLiveRate(now) {
681
+ const recent = state.live.times.filter(t => now - t < 60000)
682
+ el.liveRate.textContent = String(recent.length)
683
+ }
684
+
685
+ /** Draw the live rings and newborn stars on top of the scene. */
686
+ function drawLiveEffects() {
687
+ const now = performance.now()
688
+ const L = state.live
689
+ ctx.save()
690
+ ctx.globalCompositeOperation = 'lighter'
691
+
692
+ // A soft, lingering aura on any cloud touched in the last few seconds:
693
+ // the ring is brief, but the cluster itself keeps a "hot" edge so an
694
+ // active conversation is visible even between events.
695
+ for (const cluster of state.clusters) {
696
+ const last = cluster.lastLiveAt || 0
697
+ const heat = Math.max(0, 1 - (now - last) / 3200)
698
+ if (heat <= 0) continue
699
+ const pr = project(cluster.center)
700
+ const sprite = softSprite(cluster.color)
701
+ const d = 130 * pr.scale * (0.8 + heat * 0.5)
702
+ ctx.globalAlpha = heat * 0.16
703
+ ctx.drawImage(sprite, pr.x - d / 2, pr.y - d / 2, d, d)
704
+ }
705
+ ctx.globalAlpha = 1
706
+
707
+ for (let i = L.pulses.length - 1; i >= 0; i -= 1) {
708
+ const p = L.pulses[i]
709
+ const age = (now - p.born) / 1400
710
+ if (age >= 1) { L.pulses.splice(i, 1); continue }
711
+ const pr = project(p.cluster.center)
712
+ const baseR = 8 * pr.scale
713
+ const grow = p.kind === 'write' ? age : 1 - age
714
+ const r = Math.max(2, baseR + grow * 40 * pr.scale)
715
+ ctx.beginPath()
716
+ ctx.arc(pr.x, pr.y, r, 0, Math.PI * 2)
717
+ ctx.strokeStyle = rgba(p.color, (1 - age) * 0.5)
718
+ ctx.lineWidth = 1.2
719
+ ctx.stroke()
720
+ }
721
+ for (let i = L.newborns.length - 1; i >= 0; i -= 1) {
722
+ const s = L.newborns[i]
723
+ const age = (now - s.born) / 6000
724
+ if (age >= 1) { L.newborns.splice(i, 1); continue }
725
+ const pr = project(s.pos)
726
+ const twinkle = 0.75 + Math.sin(now * 0.004 + s.phase) * 0.25
727
+ const r = Math.max(0.8, (1.4 + (1 - age) * 1.6) * pr.scale * twinkle)
728
+ const sprite = softSprite([255, 255, 255])
729
+ const d = r * 7
730
+ ctx.globalAlpha = (1 - age) * 0.85
731
+ ctx.drawImage(sprite, pr.x - d / 2, pr.y - d / 2, d, d)
732
+ ctx.globalAlpha = 1
733
+ ctx.beginPath()
734
+ ctx.arc(pr.x, pr.y, r, 0, Math.PI * 2)
735
+ ctx.fillStyle = '#ffffff'
736
+ ctx.fill()
737
+ }
738
+ ctx.restore()
739
+ // Idle the activity dot shortly after the last event.
740
+ if (now - L.lastBurst > 1200) el.livePanel.dataset.active = 'false'
741
+ }
742
+
743
+ /** Fold an on-disk snapshot back in: drop ephemeral clouds now on disk. */
744
+ function refreshFromSnapshot() {
745
+ fetch('/memory-cloud/api/memories', { headers: { accept: 'application/json' } })
746
+ .then(r => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
747
+ .then(data => {
748
+ const ids = new Set((data.sessions || []).map(s => s.id))
749
+ for (const [id, cluster] of state.live.liveClusters) {
750
+ if (!ids.has(id)) continue
751
+ state.live.liveClusters.delete(id)
752
+ const ci = state.clusters.indexOf(cluster)
753
+ if (ci >= 0) state.clusters.splice(ci, 1)
754
+ // Remove its core from the flat hit-test list.
755
+ const si = state.stars.indexOf(cluster.core)
756
+ if (si >= 0) state.stars.splice(si, 1)
757
+ }
758
+ if (state.stats) {
759
+ state.stats.sessions = data.stats.sessions
760
+ state.stats.fragments = data.stats.fragments
761
+ buildStats(state.stats)
762
+ el.subtitle.textContent = state.isMobile
763
+ ? `${data.stats.sessions} 个会话 · ${data.stats.fragments} 颗记忆星 · 单指旋转 / 双指缩放`
764
+ : `${data.stats.sessions} 个会话记忆 · ${data.stats.fragments} 颗记忆星 · 拖拽以探索`
765
+ }
766
+ })
767
+ .catch(() => { /* transient; next tick retries */ })
534
768
  }
535
769
 
536
770
  const SKIN_KEY = 'dsh-memory-cloud:skin:v1'
@@ -632,7 +866,7 @@
632
866
  const q = state.query.trim().toLowerCase()
633
867
  if (q === '') return true
634
868
  if (cluster.session.title.toLowerCase().includes(q)) return true
635
- if (cluster.session.workspaceLabel.toLowerCase().includes(q)) return true
869
+ if (cluster.session.workspaceLabel && cluster.session.workspaceLabel.toLowerCase().includes(q)) return true
636
870
  return (cluster.session.fragments || []).some(f => f.text.toLowerCase().includes(q))
637
871
  }
638
872
 
@@ -1206,6 +1440,7 @@
1206
1440
  drawRelationArcs()
1207
1441
  drawNebulaStars(projected)
1208
1442
  }
1443
+ drawLiveEffects()
1209
1444
 
1210
1445
  // Captions only make sense once a cluster fills the view.
1211
1446
  if (lod >= 2) drawFragmentCaptions(lod)
@@ -1530,9 +1765,18 @@
1530
1765
  else if (window.parent !== window) window.parent.postMessage({ source: 'dsh-memory-cloud', type: 'close' }, location.origin)
1531
1766
  }
1532
1767
  })
1768
+ el.liveToggle.addEventListener('click', () => {
1769
+ const collapsed = el.livePanel.classList.toggle('collapsed')
1770
+ el.liveToggle.textContent = collapsed ? '+' : '−'
1771
+ el.liveToggle.setAttribute('aria-pressed', String(!collapsed))
1772
+ })
1773
+ el.liveFeed.innerHTML = '<div class="live-empty">等待对话记忆写入…</div>'
1774
+ el.livePanel.dataset.connected = 'false'
1533
1775
  // The nebula is dark-only by design; no theme messages are exchanged.
1534
1776
  window.addEventListener('resize', resize)
1535
1777
  window.addEventListener('orientationchange', () => window.setTimeout(resize, 250))
1778
+ // Periodically reconcile ephemeral live clouds with the on-disk snapshot.
1779
+ window.setInterval(refreshFromSnapshot, 15000)
1536
1780
 
1537
1781
  // -------------------------------------------------------------------------
1538
1782
  // Boot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-cloud",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Visualize every DSH session memory as an interactive 3D nebula, with a neuron-signal skin and zoom-driven detail levels.",
6
6
  "license": "MIT",