dsh-memory-cloud 0.0.0-stage → 1.0.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/index.js ADDED
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Host half of the Memory Nebula bundle.
3
+ *
4
+ * DSH has no standalone "memory" store: every durable memory is a committed
5
+ * Session event in the append-only session log under $DSH_HOME/sessions. Those
6
+ * logs are concatenated zstd frames wrapping JSONL. This plugin cold-reads all
7
+ * of them, folds each Session into a compact "memory cluster" (title, activity
8
+ * counts, time range and a sampled set of conversation fragments), caches by
9
+ * file mtime, and serves the aggregate to the Client over same-origin HTTP.
10
+ *
11
+ * Nothing here writes to the profile, the session log, or any other plugin's
12
+ * state. It is a read-only projection of history.
13
+ */
14
+ import { readFile, readdir, stat } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+ import { zstdDecompressSync } from 'node:zlib'
17
+
18
+ export const inject = ['webServer']
19
+
20
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
21
+ const LOG_PATTERN = /^session(?:\.[\w-]+)*\.jsonl\.zstd$/
22
+ const RUNTIME_CONTEXT_PREFIX = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.'
23
+ const RUNTIME_FILLERS = [
24
+ 'Time sampled while preparing turn',
25
+ 'Elapsed since the preceding',
26
+ 'Browser time zone for this request',
27
+ 'Interpret otherwise-unqualified dates',
28
+ 'Current the agent harness file policy',
29
+ 'Current agent implementation checkout',
30
+ 'Current Web GUI URL',
31
+ ]
32
+ const SKILL_CATALOG_OPENER = '<system-reminder>'
33
+ const SKILL_CATALOG_MARKER = '<available_skills>'
34
+ // Generic words carry no "this session was about X" signal. Keeping them out
35
+ // stops short chit-chat sessions from linking to everything via "的/我们/the".
36
+ const STOP_WORDS = new Set([
37
+ 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'you', 'are', 'not', 'have', 'can', 'will',
38
+ 'but', 'all', 'any', 'use', 'using', 'used', 'how', 'what', 'when', 'where', 'which', 'who', 'why',
39
+ '一个', '我们', '你们', '他们', '这个', '那个', '什么', '可以', '怎么', '需要', '如果', '已经',
40
+ '没有', '就是', '这样', '那样', '时候', '现在', '一下', '使用', '以及', '通过', '是否', '这些',
41
+ '那些', '然后', '因为', '所以', '但是', '还是', '或者', '一下', '这里', '那里', '当前', '问题',
42
+ '看看', '帮我', '一个', '进行', '相关', '之后', '之前', '出来', '进去', '东西', '内容', '地方',
43
+ '用户', '助手', '工具', '文件', '代码', '插件', '会话', '记忆', '支持', '实现', '添加', '修改',
44
+ 'current', 'the agent harness', 'runtime', 'context', 'snapshot', 'session', 'user', 'assistant',
45
+ ])
46
+ const TOKEN_RE = /[\u4e00-\u9fa5]{2,}|[a-z][a-z0-9_-]{2,}/g
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // zstd frame decoding
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** Decode a run of concatenated zstd frames into UTF-8 JSONL text. */
53
+ function decodeSessionLog(buffer) {
54
+ if (typeof zstdDecompressSync !== 'function') return null
55
+ const offsets = []
56
+ let index = buffer.indexOf(ZSTD_MAGIC, 0)
57
+ while (index !== -1) {
58
+ offsets.push(index)
59
+ index = buffer.indexOf(ZSTD_MAGIC, index + 4)
60
+ }
61
+ if (offsets.length === 0) return null
62
+ const parts = []
63
+ for (let i = 0; i < offsets.length; i += 1) {
64
+ const end = i + 1 < offsets.length ? offsets[i + 1] : buffer.length
65
+ try {
66
+ parts.push(zstdDecompressSync(buffer.subarray(offsets[i], end)))
67
+ } catch {
68
+ // A log that is still being appended can end mid-frame: keep whole frames.
69
+ break
70
+ }
71
+ }
72
+ if (parts.length === 0) return null
73
+ return Buffer.concat(parts).toString('utf8')
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Event text helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Join the readable text blocks of a message content array. */
81
+ function contentText(content) {
82
+ if (typeof content === 'string') return content
83
+ if (!Array.isArray(content)) return ''
84
+ return content
85
+ .flatMap(block => {
86
+ if (block == null) return []
87
+ if (block.type === 'text') return typeof block.text === 'string' ? [block.text] : []
88
+ if (block.type === 'tool-call') return [] // tool calls are not remembered text
89
+ if (block.type === 'tool-result') return contentText(block.content)
90
+ return []
91
+ })
92
+ .filter(value => value.trim() !== '')
93
+ .join('\n')
94
+ .trim()
95
+ }
96
+
97
+ function isInjectedUser(event) {
98
+ const source = event?.data?.source
99
+ if (source != null && typeof source === 'object' && typeof source.kind === 'string') {
100
+ if (source.kind === 'skill-catalog' || source.kind === 'runtime-context') return true
101
+ }
102
+ const text = contentText(event?.data?.content)
103
+ if (text.startsWith(RUNTIME_CONTEXT_PREFIX)) return true
104
+ // DSH prepends runtime-context bookkeeping to some user turns; those lines
105
+ // are injected scaffolding, not a memory a person authored.
106
+ if (RUNTIME_FILLERS.some(marker => text.startsWith(marker))) return true
107
+ const head = text.trimStart()
108
+ return head.startsWith(SKILL_CATALOG_OPENER) && head.includes(SKILL_CATALOG_MARKER)
109
+ }
110
+
111
+ function clip(text, max) {
112
+ const normalized = text.replace(/\s+/g, ' ').trim()
113
+ if (normalized.length <= max) return { text: normalized, truncated: false }
114
+ return { text: `${normalized.slice(0, max).trimEnd()}…`, truncated: true }
115
+ }
116
+
117
+ /** Ignore fragments that carry no real words (bare punctuation, separators). */
118
+ function hasSubstance(text) {
119
+ return /[\u4e00-\u9fa5a-zA-Z0-9]/.test(text)
120
+ }
121
+
122
+ /** Even sampling keeps both the oldest and newest memories of a long Session. */
123
+ function sampleEvenly(items, cap) {
124
+ if (items.length <= cap) return items
125
+ if (cap <= 0) return []
126
+ const out = []
127
+ for (let i = 0; i < cap; i += 1) {
128
+ const index = Math.round((i * (items.length - 1)) / (cap - 1))
129
+ out.push(items[index])
130
+ }
131
+ return out
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Folding one Session log into a memory cluster
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function foldEvents(sessionId, workspaceDir, events, limits, options = {}) {
139
+ const full = options.full === true
140
+ const maxChars = full ? 4000 : limits.maxFragmentChars
141
+ let title = ''
142
+ let createdAt = 0
143
+ let updatedAt = 0
144
+ let realCwd = ''
145
+ let userMessages = 0
146
+ let assistantMessages = 0
147
+ let toolCalls = 0
148
+ let turns = 0
149
+ let eventCount = 0
150
+ let parentSession = null
151
+ const rawFragments = []
152
+ const wordCounts = new Map()
153
+ const toolNames = new Set()
154
+
155
+ for (const event of events) {
156
+ if (event == null || typeof event !== 'object') continue
157
+ // The log's header row carries the real working directory, creation time
158
+ // and the durable fork lineage (parentSession).
159
+ if (event.type === 'session') {
160
+ if (typeof event.cwd === 'string' && event.cwd !== '') realCwd = event.cwd
161
+ if (typeof event.createdAt === 'number' && event.createdAt > 0 && createdAt === 0) createdAt = event.createdAt
162
+ if (typeof event.parentSession === 'string' && event.parentSession !== '') parentSession = event.parentSession
163
+ continue
164
+ }
165
+ eventCount += 1
166
+ const time = typeof event.time === 'number' ? event.time : 0
167
+ if (time > 0) {
168
+ if (createdAt === 0 || time < createdAt) createdAt = time
169
+ if (time > updatedAt) updatedAt = time
170
+ }
171
+ const data = event.data || {}
172
+ switch (event.type) {
173
+ case 'session/title':
174
+ if (typeof data.title === 'string' && data.title.trim() !== '') title = data.title.trim()
175
+ break
176
+ case 'user/message': {
177
+ if (isInjectedUser(event)) break
178
+ const text = contentText(data.content)
179
+ if (text === '' || !hasSubstance(text)) break
180
+ userMessages += 1
181
+ collectWords(text, wordCounts)
182
+ const clipped = clip(text, maxChars)
183
+ rawFragments.push({ kind: 'user', text: clipped.text, truncated: clipped.truncated, time, seq: event.seq })
184
+ break
185
+ }
186
+ case 'assistant/message': {
187
+ const text = contentText(data.message?.content)
188
+ if (text === '' || !hasSubstance(text)) break
189
+ assistantMessages += 1
190
+ collectWords(text, wordCounts)
191
+ const clipped = clip(text, maxChars)
192
+ rawFragments.push({ kind: 'assistant', text: clipped.text, truncated: clipped.truncated, time, seq: event.seq })
193
+ break
194
+ }
195
+ case 'tool/call':
196
+ toolCalls += 1
197
+ if (typeof data.name === 'string') toolNames.add(data.name)
198
+ break
199
+ case 'turn/end':
200
+ turns += 1
201
+ break
202
+ default:
203
+ break
204
+ }
205
+ }
206
+
207
+ // A Session with no user turns yet (only runtime bootstrap) is not a memory.
208
+ if (userMessages === 0 && assistantMessages === 0) return null
209
+
210
+ const fragments = full ? rawFragments : sampleEvenly(rawFragments, limits.maxFragments)
211
+ if (title === '') {
212
+ const firstUser = rawFragments.find(fragment => fragment.kind === 'user')
213
+ title = firstUser ? clip(firstUser.text, 42).text || '未命名记忆' : '未命名记忆'
214
+ }
215
+
216
+ return {
217
+ id: sessionId,
218
+ title: clip(title, 90).text,
219
+ workspace: workspaceDir,
220
+ workspaceLabel: realCwd !== '' ? realCwd : workspaceLabel(workspaceDir),
221
+ createdAt,
222
+ updatedAt,
223
+ counts: {
224
+ events: eventCount,
225
+ user: userMessages,
226
+ assistant: assistantMessages,
227
+ tools: toolCalls,
228
+ turns,
229
+ fragments: rawFragments.length,
230
+ },
231
+ // Compact relation signals. `topics` is capped so the payload stays small.
232
+ topics: topTopics(wordCounts, 18),
233
+ tools: [...toolNames].slice(0, 40),
234
+ parentSession,
235
+ fragments,
236
+ }
237
+ }
238
+
239
+ /** Count meaningful tokens of one message into the Session's word bag. */
240
+ function collectWords(text, into) {
241
+ const lowered = text.toLowerCase()
242
+ const tokens = lowered.match(TOKEN_RE) || []
243
+ for (const token of tokens) {
244
+ if (STOP_WORDS.has(token) || token.length > 20) continue
245
+ into.set(token, (into.get(token) || 0) + 1)
246
+ }
247
+ }
248
+
249
+ /** Highest-count, non-generic tokens = what this Session was actually about. */
250
+ function topTopics(wordCounts, cap) {
251
+ return [...wordCounts.entries()]
252
+ .filter(([, n]) => n >= 2)
253
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
254
+ .slice(0, cap)
255
+ .map(([word, count]) => ({ word, count }))
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Relations between Sessions
260
+ //
261
+ // DSH Sessions are not an unrelated pile: they share a workspace, a fork
262
+ // lineage, the tools they used, and above all the words people typed. We fold
263
+ // those signals into a small weighted graph so the nebula can draw the links.
264
+ // ---------------------------------------------------------------------------
265
+
266
+ function computeRelations(sessions) {
267
+ const byId = new Map(sessions.map(s => [s.id, s]))
268
+ // Document frequency gives rare, specific words more weight than ubiquitous
269
+ // ones ("插件" appears everywhere, "bark" only in one thread).
270
+ const df = new Map()
271
+ for (const s of sessions) {
272
+ for (const { word } of s.topics || []) df.set(word, (df.get(word) || 0) + 1)
273
+ }
274
+ const idf = word => Math.log(1 + sessions.length / (1 + (df.get(word) || 0)))
275
+ const vectors = new Map()
276
+ for (const s of sessions) {
277
+ const vec = new Map()
278
+ for (const { word, count } of s.topics || []) vec.set(word, (1 + Math.log(count)) * idf(word))
279
+ vectors.set(s.id, vec)
280
+ }
281
+ const norm = v => Math.sqrt([...v.values()].reduce((acc, x) => acc + x * x, 0))
282
+
283
+ const candidates = []
284
+ for (let i = 0; i < sessions.length; i += 1) {
285
+ for (let j = i + 1; j < sessions.length; j += 1) {
286
+ const A = sessions[i], B = sessions[j]
287
+ const va = vectors.get(A.id), vb = vectors.get(B.id)
288
+ let dot = 0
289
+ for (const [word, wa] of va) {
290
+ const wb = vb.get(word)
291
+ if (wb !== undefined) dot += wa * wb
292
+ }
293
+ const na = norm(va), nb = norm(vb)
294
+ const cosine = na > 0 && nb > 0 ? dot / (na * nb) : 0
295
+
296
+ const ta = new Set(A.tools || []), tb = new Set(B.tools || [])
297
+ let sharedTools = 0
298
+ for (const t of ta) if (tb.has(t)) sharedTools += 1
299
+ const toolScore = sharedTools / Math.max(1, Math.min(ta.size, tb.size) || 1)
300
+
301
+ const sameWorkspace = A.workspace === B.workspace
302
+ const forked = A.parentSession === B.id || B.parentSession === A.id
303
+ // Sessions worked on in the same period are more likely a continuing thread.
304
+ const gap = Math.abs((A.createdAt || 0) - (B.createdAt || 0))
305
+ const temporal = gap > 0 && gap < 1000 * 60 * 60 * 24 * 7 ? 0.12 : 0
306
+
307
+ let weight = cosine * 0.72 + toolScore * 0.18 + (sameWorkspace ? 0.12 : 0) + temporal
308
+ if (forked) weight += 0.6
309
+ if (weight < 0.16) continue
310
+
311
+ const kinds = []
312
+ if (forked) kinds.push('fork')
313
+ if (sameWorkspace) kinds.push('workspace')
314
+ if (cosine >= 0.2) kinds.push('topic')
315
+ if (sharedTools >= 2) kinds.push('tools')
316
+ if (temporal > 0) kinds.push('time')
317
+ if (kinds.length === 0) kinds.push('weak')
318
+ candidates.push({
319
+ source: A.id, target: B.id,
320
+ weight: Math.round(Math.min(1, weight) * 100) / 100,
321
+ kind: kinds[0],
322
+ kinds,
323
+ shared: sharedWords(A, B, 6),
324
+ })
325
+ }
326
+ }
327
+ candidates.sort((a, b) => b.weight - a.weight)
328
+ // Cap per node as well as globally: a hub must not dominate the picture.
329
+ const perNode = new Map()
330
+ const picked = []
331
+ for (const edge of candidates) {
332
+ const ca = perNode.get(edge.source) || 0
333
+ const cb = perNode.get(edge.target) || 0
334
+ if (ca >= 4 || cb >= 4) continue
335
+ perNode.set(edge.source, ca + 1)
336
+ perNode.set(edge.target, cb + 1)
337
+ picked.push(edge)
338
+ if (picked.length >= 60) break
339
+ }
340
+ // A relation is only meaningful if both endpoints are still in the payload.
341
+ return picked.filter(e => byId.has(e.source) && byId.has(e.target))
342
+ }
343
+
344
+ /** A few concrete shared words, shown in the UI as the reason for a link. */
345
+ function sharedWords(A, B, cap) {
346
+ const wa = new Map((A.topics || []).map(t => [t.word, t.count]))
347
+ const out = []
348
+ for (const { word } of B.topics || []) {
349
+ if (wa.has(word)) out.push(word)
350
+ if (out.length >= cap) break
351
+ }
352
+ return out
353
+ }
354
+
355
+ /** Turn a DSH workspace directory slug into a readable label. */
356
+ function workspaceLabel(dirName) {
357
+ let inner = String(dirName).replace(/^--+/, '').replace(/--+$/, '')
358
+ const drive = /^([A-Za-z])-(.*)$/.exec(inner)
359
+ if (drive !== null) inner = `${drive[1].toUpperCase()}:\\${drive[2]}`
360
+ return inner || String(dirName)
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Disk reader with an mtime cache
365
+ // ---------------------------------------------------------------------------
366
+
367
+ class MemoryReader {
368
+ constructor(directory, limits) {
369
+ this.directory = directory
370
+ this.limits = limits
371
+ this.cache = new Map() // file path -> { mtimeMs, size, cluster, events }
372
+ this.detailCache = new Map() // session id -> { mtimeMs, size, detail }
373
+ this.fileIndex = new Map() // session id -> file descriptor
374
+ }
375
+
376
+ async findLogFiles() {
377
+ const files = []
378
+ let workspaces
379
+ try {
380
+ workspaces = await readdir(this.directory, { withFileTypes: true })
381
+ } catch {
382
+ return files
383
+ }
384
+ for (const workspace of workspaces) {
385
+ if (!workspace.isDirectory()) continue
386
+ const workspacePath = join(this.directory, workspace.name)
387
+ let sessions
388
+ try {
389
+ sessions = await readdir(workspacePath, { withFileTypes: true })
390
+ } catch {
391
+ continue
392
+ }
393
+ for (const session of sessions) {
394
+ if (!session.isDirectory()) continue
395
+ const sessionPath = join(workspacePath, session.name)
396
+ let entries
397
+ try {
398
+ entries = await readdir(sessionPath)
399
+ } catch {
400
+ continue
401
+ }
402
+ const log = entries.find(name => LOG_PATTERN.test(name))
403
+ if (log === undefined) continue
404
+ files.push({ path: join(sessionPath, log), sessionId: session.name, workspaceDir: workspace.name })
405
+ }
406
+ }
407
+ return files
408
+ }
409
+
410
+ async readCluster(file) {
411
+ const info = await stat(file.path).catch(() => null)
412
+ if (info === null) return null
413
+ const cached = this.cache.get(file.path)
414
+ if (cached != null && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
415
+ return cached.cluster
416
+ }
417
+ const text = decodeSessionLog(await readFile(file.path).catch(() => Buffer.alloc(0)))
418
+ if (text === null) return null
419
+ const events = []
420
+ for (const line of text.split('\n')) {
421
+ if (line.trim() === '') continue
422
+ try {
423
+ events.push(JSON.parse(line))
424
+ } catch {
425
+ // A trailing partial line while the Session is still being written.
426
+ }
427
+ }
428
+ const cluster = foldEvents(file.sessionId, file.workspaceDir, events, this.limits)
429
+ // Bound the cache so a profile with hundreds of Sessions cannot grow it forever.
430
+ if (this.cache.size >= this.limits.maxSessions * 2) {
431
+ this.cache.delete(this.cache.keys().next().value)
432
+ }
433
+ this.cache.set(file.path, { mtimeMs: info.mtimeMs, size: info.size, cluster, events })
434
+ this.fileIndex.set(file.sessionId, file)
435
+ return cluster
436
+ }
437
+
438
+ /**
439
+ * Full, untruncated fragments for ONE session. The summary endpoint samples
440
+ * at most `maxFragments`; zooming into a cluster demands the whole memory,
441
+ * so the client fetches this lazily and caches it here by file mtime.
442
+ */
443
+ async sessionDetail(sessionId) {
444
+ if (typeof sessionId !== 'string' || sessionId === '') return null
445
+ let file = this.fileIndex.get(sessionId)
446
+ if (file === undefined) {
447
+ for (const f of await this.findLogFiles()) if (f.sessionId === sessionId) { file = f; break }
448
+ if (file === undefined) return null
449
+ this.fileIndex.set(sessionId, file)
450
+ }
451
+ const info = await stat(file.path).catch(() => null)
452
+ if (info === null) return null
453
+ const cached = this.detailCache.get(sessionId)
454
+ if (cached != null && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.detail
455
+
456
+ let events = this.cache.get(file.path)?.events
457
+ if (events === undefined) {
458
+ const text = decodeSessionLog(await readFile(file.path).catch(() => Buffer.alloc(0)))
459
+ if (text === null) return null
460
+ events = []
461
+ for (const line of text.split('\n')) {
462
+ if (line.trim() === '') continue
463
+ try { events.push(JSON.parse(line)) } catch { /* partial trailing line */ }
464
+ }
465
+ }
466
+ const cluster = foldEvents(file.sessionId, file.workspaceDir, events, this.limits, { full: true })
467
+ if (cluster === null) return null
468
+ const detail = {
469
+ id: cluster.id,
470
+ title: cluster.title,
471
+ workspaceLabel: cluster.workspaceLabel,
472
+ createdAt: cluster.createdAt,
473
+ updatedAt: cluster.updatedAt,
474
+ counts: cluster.counts,
475
+ fragments: cluster.fragments,
476
+ }
477
+ this.detailCache.set(sessionId, { mtimeMs: info.mtimeMs, size: info.size, detail })
478
+ return detail
479
+ }
480
+
481
+ async snapshot() {
482
+ const files = await this.findLogFiles()
483
+ // Newest activity first, then cap so the nebula stays responsive.
484
+ const clusters = []
485
+ for (const file of files) {
486
+ const cluster = await this.readCluster(file)
487
+ if (cluster != null) clusters.push(cluster)
488
+ }
489
+ clusters.sort((a, b) => b.updatedAt - a.updatedAt)
490
+ const capped = clusters.slice(0, this.limits.maxSessions)
491
+
492
+ let totalEvents = 0
493
+ let totalUser = 0
494
+ let totalAssistant = 0
495
+ let totalTools = 0
496
+ let totalTurns = 0
497
+ let totalFragments = 0
498
+ let oldest = Infinity
499
+ let newest = 0
500
+ const workspaces = new Map()
501
+ for (const cluster of capped) {
502
+ totalEvents += cluster.counts.events
503
+ totalUser += cluster.counts.user
504
+ totalAssistant += cluster.counts.assistant
505
+ totalTools += cluster.counts.tools
506
+ totalTurns += cluster.counts.turns
507
+ totalFragments += cluster.fragments.length
508
+ if (cluster.createdAt > 0 && cluster.createdAt < oldest) oldest = cluster.createdAt
509
+ if (cluster.updatedAt > newest) newest = cluster.updatedAt
510
+ if (!workspaces.has(cluster.workspace)) {
511
+ workspaces.set(cluster.workspace, { id: cluster.workspace, label: cluster.workspaceLabel, sessions: 0 })
512
+ }
513
+ workspaces.get(cluster.workspace).sessions += 1
514
+ }
515
+
516
+ const relations = computeRelations(capped)
517
+
518
+ return {
519
+ generatedAt: Date.now(),
520
+ sessions: capped,
521
+ workspaces: [...workspaces.values()].sort((a, b) => b.sessions - a.sessions),
522
+ relations,
523
+ stats: {
524
+ sessions: capped.length,
525
+ events: totalEvents,
526
+ user: totalUser,
527
+ assistant: totalAssistant,
528
+ tools: totalTools,
529
+ turns: totalTurns,
530
+ fragments: totalFragments,
531
+ relations: relations.length,
532
+ oldest: Number.isFinite(oldest) ? oldest : 0,
533
+ newest,
534
+ },
535
+ }
536
+ }
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // HTTP plumbing
541
+ // ---------------------------------------------------------------------------
542
+
543
+ function sendJson(res, status, body) {
544
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
545
+ res.end(JSON.stringify(body))
546
+ }
547
+
548
+ function sendFile(res, contentType, body) {
549
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
550
+ res.end(body)
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Plugin entry
555
+ // ---------------------------------------------------------------------------
556
+
557
+ export function apply(ctx, config) {
558
+ const limits = {
559
+ maxSessions: Number.isFinite(config?.maxSessions) ? Math.max(1, Math.floor(config.maxSessions)) : 400,
560
+ maxFragments: Number.isFinite(config?.maxFragments) ? Math.max(1, Math.floor(config.maxFragments)) : 28,
561
+ maxFragmentChars: Number.isFinite(config?.maxFragmentChars) ? Math.max(40, Math.floor(config.maxFragmentChars)) : 220,
562
+ }
563
+ const sessionsDir = typeof config?.sessionsDir === 'string' && config.sessionsDir !== ''
564
+ ? config.sessionsDir
565
+ : (process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : '')
566
+ const reader = new MemoryReader(sessionsDir, limits)
567
+
568
+ // The DSH /api browser-trust fence does not cover these routes, so the
569
+ // handler checks the Host header itself (DNS-rebinding defense), mirroring
570
+ // the shipped Synapse plugin. The Web GUI is always localhost/127.0.0.1.
571
+ const trustedHosts = new Set([
572
+ 'localhost',
573
+ '127.0.0.1',
574
+ '::1',
575
+ '[::1]',
576
+ ...[...(config?.trustedHosts ?? [])].map(host => String(host).trim().toLowerCase()).filter(Boolean),
577
+ ])
578
+ const hostAllowed = req => {
579
+ const raw = typeof req.headers.host === 'string' ? req.headers.host : ''
580
+ const hostname = raw.replace(/:\d+$/, '').toLowerCase()
581
+ return trustedHosts.has(hostname)
582
+ }
583
+
584
+ const handler = async (req, res) => {
585
+ try {
586
+ if (!hostAllowed(req)) return sendJson(res, 403, { error: 'Untrusted Host' })
587
+ if (req.method !== 'GET' && req.method !== 'HEAD') return sendJson(res, 405, { error: 'Method not allowed' })
588
+ const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
589
+
590
+ if (path === '/memory-cloud/api/memories') {
591
+ const data = await reader.snapshot()
592
+ return sendJson(res, 200, data)
593
+ }
594
+ const detailMatch = /^\/memory-cloud\/api\/session\/([A-Za-z0-9_.-]+)$/.exec(path)
595
+ if (detailMatch !== null) {
596
+ const detail = await reader.sessionDetail(decodeURIComponent(detailMatch[1]))
597
+ if (detail === null) return sendJson(res, 404, { error: 'Session not found' })
598
+ return sendJson(res, 200, detail)
599
+ }
600
+ if (path === '/memory-cloud/' || path === '/memory-cloud/index.html') {
601
+ return sendFile(res, 'text/html; charset=utf-8', await readAsset('nebula.html'))
602
+ }
603
+ if (path === '/memory-cloud/nebula.css') {
604
+ return sendFile(res, 'text/css; charset=utf-8', await readAsset('nebula.css'))
605
+ }
606
+ if (path === '/memory-cloud/nebula.js') {
607
+ return sendFile(res, 'text/javascript; charset=utf-8', await readAsset('nebula.js'))
608
+ }
609
+ return sendJson(res, 404, { error: 'Not found' })
610
+ } catch (error) {
611
+ ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
612
+ return sendJson(res, 500, { error: 'Memory nebula data is temporarily unavailable' })
613
+ }
614
+ }
615
+
616
+ ctx.effect(
617
+ () => ctx.webServer.register({ kind: 'exact', path: '/memory-cloud', handler: (_req, res) => {
618
+ res.writeHead(302, { location: '/memory-cloud/' })
619
+ res.end()
620
+ } }),
621
+ 'memory-cloud: redirect',
622
+ )
623
+ ctx.effect(
624
+ () => ctx.webServer.register({ kind: 'prefix', path: '/memory-cloud', handler }),
625
+ 'memory-cloud: page, assets and api',
626
+ )
627
+ }
628
+
629
+ async function readAsset(name) {
630
+ return readFile(new URL(`./${name}`, import.meta.url), 'utf8')
631
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Memory nebula",
4
+ "description": "Browse every DSH session memory as an interactive, rotatable 3D nebula of conversation clusters."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "记忆星云",
4
+ "description": "把当前 DSH 的全部会话记忆聚合为一幅可旋转、可缩放、可检索的 3D 星云图。"
5
+ }
6
+ }