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/LICENSE +21 -0
- package/README.md +112 -2
- package/assets/icon.svg +32 -0
- package/client.js +169 -0
- package/cordis.patch.yml +12 -0
- package/icon.svg +32 -0
- package/index.js +631 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/nebula.css +283 -0
- package/nebula.html +61 -0
- package/nebula.js +1551 -0
- package/package.json +63 -4
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