@ilaomianyang/dsh-memory-graph 0.10.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/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.en.md +466 -0
- package/README.md +459 -0
- package/client.js +1953 -0
- package/cordis.patch.yml +13 -0
- package/docs/auto-settings.png +0 -0
- package/docs/distill-button.png +0 -0
- package/docs/graph-dark.png +0 -0
- package/docs/graph-light.png +0 -0
- package/docs/session-tab-cards.png +0 -0
- package/docs/session-tab-open.png +0 -0
- package/docs/session-tab.png +0 -0
- package/docs/vault-panel.png +0 -0
- package/eval/REPORT.md +437 -0
- package/eval/questions.json +277 -0
- package/eval/run.mjs +460 -0
- package/eval/seed.mjs +489 -0
- package/icon.svg +26 -0
- package/index.js +243 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -0
- package/src/auto.js +194 -0
- package/src/capture.js +237 -0
- package/src/config.js +280 -0
- package/src/distillapi.js +30 -0
- package/src/distillrun.js +106 -0
- package/src/extract.js +660 -0
- package/src/graph.js +1582 -0
- package/src/graphdata.js +475 -0
- package/src/http.js +32 -0
- package/src/pageapi.js +40 -0
- package/src/privacy.js +78 -0
- package/src/schema.js +323 -0
- package/src/search.js +317 -0
- package/src/tools.js +534 -0
- package/src/vault.js +888 -0
- package/test/auto.mjs +295 -0
- package/test/capture.mjs +264 -0
- package/test/client.mjs +1035 -0
- package/test/distillapi.mjs +212 -0
- package/test/extract.mjs +660 -0
- package/test/graphdata.mjs +295 -0
- package/test/live-supersede.mjs +78 -0
- package/test/live.mjs +156 -0
- package/test/page.mjs +116 -0
- package/test/schema.mjs +256 -0
- package/test/smoke.mjs +428 -0
- package/test/tools.mjs +238 -0
package/src/graphdata.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data behind the Session memory view: what THIS conversation left in the
|
|
3
|
+
* vault, and what it is connected to.
|
|
4
|
+
*
|
|
5
|
+
* Two properties matter more than richness here.
|
|
6
|
+
*
|
|
7
|
+
* **It is honest.** Every edge says something the vault can prove: the session
|
|
8
|
+
* said this episode, the card mentions this entity, this episode's text names
|
|
9
|
+
* that entity, this session's own words would retrieve that card (the same
|
|
10
|
+
* `searchCards` the model calls). Nothing is drawn on a guess, so a sparse graph
|
|
11
|
+
* means the memory really is sparse — which is the useful reading.
|
|
12
|
+
*
|
|
13
|
+
* **It fits a panel.** Nodes are capped per kind and the layout is computed here,
|
|
14
|
+
* on the host, so the browser receives coordinates and draws them: no force
|
|
15
|
+
* simulation in React, no re-layout when the tab is reopened, and one payload
|
|
16
|
+
* small enough to poll. Coordinates are deterministic — same vault, same shape —
|
|
17
|
+
* because the layout is seeded from the node order, not from `Math.random`.
|
|
18
|
+
*/
|
|
19
|
+
import { searchCards } from './search.js'
|
|
20
|
+
import { json } from './http.js'
|
|
21
|
+
|
|
22
|
+
/** The exact path the host serves and the panel fetches. Authenticated by Connection. */
|
|
23
|
+
export const GRAPH_PATH = '/api/memory-graph.graph'
|
|
24
|
+
|
|
25
|
+
export const GRAPH_LIMITS = {
|
|
26
|
+
episodes: 40,
|
|
27
|
+
cards: 60,
|
|
28
|
+
entities: 40,
|
|
29
|
+
foreignCards: 12,
|
|
30
|
+
recall: 8,
|
|
31
|
+
episodePreview: 180,
|
|
32
|
+
cardBody: 320,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WIDTH = 1080
|
|
36
|
+
const HEIGHT = 640
|
|
37
|
+
const PADDING = 56
|
|
38
|
+
// How far the normalisation may stretch one axis relative to the other.
|
|
39
|
+
const STRETCH = 2.6
|
|
40
|
+
|
|
41
|
+
function clip(text, max) {
|
|
42
|
+
const value = String(text ?? '')
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim()
|
|
45
|
+
if (value.length <= max) return value
|
|
46
|
+
return `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function shortId(id) {
|
|
50
|
+
const value = String(id ?? '')
|
|
51
|
+
return value.length <= 8 ? value : value.slice(0, 8)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The project this conversation belongs to: the one its episodes name most
|
|
56
|
+
* often. A session can span projects (a cwd change mid-conversation, or capture
|
|
57
|
+
* running with cross-project memory), so the majority wins and the rest still
|
|
58
|
+
* show up as connected cards.
|
|
59
|
+
*/
|
|
60
|
+
export function dominantProject(episodes) {
|
|
61
|
+
const counts = new Map()
|
|
62
|
+
for (const record of episodes) {
|
|
63
|
+
const project = typeof record?.project === 'string' ? record.project : ''
|
|
64
|
+
if (project === '') continue
|
|
65
|
+
counts.set(project, (counts.get(project) ?? 0) + 1)
|
|
66
|
+
}
|
|
67
|
+
let best = null
|
|
68
|
+
let bestCount = 0
|
|
69
|
+
for (const [project, count] of counts) {
|
|
70
|
+
if (count > bestCount) {
|
|
71
|
+
best = project
|
|
72
|
+
bestCount = count
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return best
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What this session would ask the memory if the user said "and what do you
|
|
80
|
+
* remember about that?". The last few user lines are the best available proxy:
|
|
81
|
+
* they are the part of the conversation the model is actually working on.
|
|
82
|
+
*/
|
|
83
|
+
export function recallQuery(episodes, { turns = 3, maxChars = 1200 } = {}) {
|
|
84
|
+
const lines = []
|
|
85
|
+
for (let i = episodes.length - 1; i >= 0 && lines.length < turns; i--) {
|
|
86
|
+
const user = String(episodes[i]?.user ?? '').trim()
|
|
87
|
+
if (user !== '') lines.push(user)
|
|
88
|
+
}
|
|
89
|
+
const query = lines.join('\n').trim()
|
|
90
|
+
return query.length <= maxChars ? query : query.slice(0, maxChars)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Entity slugs named in a piece of text, matched on the name and its aliases. */
|
|
94
|
+
function entitiesInText(entities, text) {
|
|
95
|
+
const haystack = String(text ?? '').toLowerCase()
|
|
96
|
+
if (haystack === '') return []
|
|
97
|
+
const found = []
|
|
98
|
+
for (const entity of entities) {
|
|
99
|
+
const needles = [entity.name, ...(entity.aliases ?? [])]
|
|
100
|
+
.map((value) => String(value ?? '').trim().toLowerCase())
|
|
101
|
+
.filter((value) => value.length >= 2)
|
|
102
|
+
if (needles.some((needle) => haystack.includes(needle))) found.push(entity.slug)
|
|
103
|
+
}
|
|
104
|
+
return found
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A small deterministic force layout, run once on the host.
|
|
109
|
+
*
|
|
110
|
+
* Repulsion keeps unrelated nodes apart, springs hold the things that are
|
|
111
|
+
* linked together, and the centre pull keeps the whole thing inside the box;
|
|
112
|
+
* the cooling factor makes later iterations settle instead of oscillate. The
|
|
113
|
+
* result is normalised into a fixed box so the browser only has to map it onto
|
|
114
|
+
* the SVG viewBox.
|
|
115
|
+
*/
|
|
116
|
+
export function layoutNodes(nodes, links, { width = WIDTH, height = HEIGHT, padding = PADDING, iterations = 220 } = {}) {
|
|
117
|
+
const count = nodes.length
|
|
118
|
+
if (count === 0) return
|
|
119
|
+
const index = new Map(nodes.map((node, i) => [node.key, i]))
|
|
120
|
+
const pos = nodes.map((node, i) => {
|
|
121
|
+
// Seeded on a ring by kind so the first frames already separate the layers.
|
|
122
|
+
const angle = (i / Math.max(1, count)) * Math.PI * 2
|
|
123
|
+
const radius = node.k === 'session' ? 0 : node.k === 'episode' ? 0.34 : node.k === 'card' ? 0.26 : 0.46
|
|
124
|
+
return {
|
|
125
|
+
x: Math.cos(angle) * radius * width + (i % 3) - 1,
|
|
126
|
+
y: Math.sin(angle) * radius * height + (i % 5) - 2,
|
|
127
|
+
vx: 0,
|
|
128
|
+
vy: 0,
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const edges = []
|
|
133
|
+
for (const link of links) {
|
|
134
|
+
const a = index.get(link.s)
|
|
135
|
+
const b = index.get(link.t)
|
|
136
|
+
if (a === undefined || b === undefined || a === b) continue
|
|
137
|
+
edges.push([a, b])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (let step = 0; step < iterations; step++) {
|
|
141
|
+
const cooling = 1 - step / iterations
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < count; i++) {
|
|
144
|
+
for (let j = i + 1; j < count; j++) {
|
|
145
|
+
let dx = pos[i].x - pos[j].x
|
|
146
|
+
let dy = pos[i].y - pos[j].y
|
|
147
|
+
let squared = dx * dx + dy * dy
|
|
148
|
+
if (squared < 1) {
|
|
149
|
+
dx = ((i % 7) - 3 || 0.5) * 0.5
|
|
150
|
+
dy = ((j % 5) - 2 || 0.5) * 0.5
|
|
151
|
+
squared = dx * dx + dy * dy + 1
|
|
152
|
+
}
|
|
153
|
+
const distance = Math.sqrt(squared)
|
|
154
|
+
const force = Math.min(10, 5200 / squared) * cooling
|
|
155
|
+
const ux = (dx / distance) * force
|
|
156
|
+
const uy = (dy / distance) * force
|
|
157
|
+
pos[i].vx += ux
|
|
158
|
+
pos[i].vy += uy
|
|
159
|
+
pos[j].vx -= ux
|
|
160
|
+
pos[j].vy -= uy
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const [a, b] of edges) {
|
|
165
|
+
const dx = pos[b].x - pos[a].x
|
|
166
|
+
const dy = pos[b].y - pos[a].y
|
|
167
|
+
const distance = Math.hypot(dx, dy) || 1
|
|
168
|
+
const force = (distance - 120) * 0.014 * cooling
|
|
169
|
+
const ux = (dx / distance) * force
|
|
170
|
+
const uy = (dy / distance) * force
|
|
171
|
+
pos[a].vx += ux
|
|
172
|
+
pos[a].vy += uy
|
|
173
|
+
pos[b].vx -= ux
|
|
174
|
+
pos[b].vy -= uy
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const point of pos) {
|
|
178
|
+
point.vx -= point.x * 0.0026 * cooling
|
|
179
|
+
point.vy -= point.y * 0.0026 * cooling
|
|
180
|
+
point.x += Math.max(-26, Math.min(26, point.vx))
|
|
181
|
+
point.y += Math.max(-26, Math.min(26, point.vy))
|
|
182
|
+
point.vx *= 0.6
|
|
183
|
+
point.vy *= 0.6
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let minX = Infinity
|
|
188
|
+
let maxX = -Infinity
|
|
189
|
+
let minY = Infinity
|
|
190
|
+
let maxY = -Infinity
|
|
191
|
+
for (const point of pos) {
|
|
192
|
+
if (point.x < minX) minX = point.x
|
|
193
|
+
if (point.x > maxX) maxX = point.x
|
|
194
|
+
if (point.y < minY) minY = point.y
|
|
195
|
+
if (point.y > maxY) maxY = point.y
|
|
196
|
+
}
|
|
197
|
+
const spanX = Math.max(1, maxX - minX)
|
|
198
|
+
const spanY = Math.max(1, maxY - minY)
|
|
199
|
+
// Fill the box on both axes instead of fitting the smaller one: the canvas is
|
|
200
|
+
// 1080×640, and a star of eight episodes would otherwise sit in a small circle
|
|
201
|
+
// with two thirds of the canvas empty. The distortion is capped so a nearly
|
|
202
|
+
// one-dimensional layout (a long chain) is not smeared flat.
|
|
203
|
+
const base = Math.min((width - padding * 2) / spanX, (height - padding * 2) / spanY)
|
|
204
|
+
const scaleX = Math.min((width - padding * 2) / spanX, base * STRETCH)
|
|
205
|
+
const scaleY = Math.min((height - padding * 2) / spanY, base * STRETCH)
|
|
206
|
+
const offsetX = (width - spanX * scaleX) / 2
|
|
207
|
+
const offsetY = (height - spanY * scaleY) / 2
|
|
208
|
+
nodes.forEach((node, i) => {
|
|
209
|
+
node.x = Math.round((pos[i].x - minX) * scaleX + offsetX)
|
|
210
|
+
node.y = Math.round((pos[i].y - minY) * scaleY + offsetY)
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Build the payload for one Session.
|
|
216
|
+
*
|
|
217
|
+
* `session` is the Session id capture recorded on each episode, so this is a
|
|
218
|
+
* filter over the vault, never a heuristic. A Session with no episodes is a
|
|
219
|
+
* normal answer (capture off, or a conversation that never reached a turn
|
|
220
|
+
* boundary): the payload says so with `empty` and still shows the project the
|
|
221
|
+
* default points at, so the panel can explain itself instead of going blank.
|
|
222
|
+
*/
|
|
223
|
+
export async function buildSessionGraph({ vault, session, project: fallbackProject = null, limits = {} }) {
|
|
224
|
+
const caps = { ...GRAPH_LIMITS, ...limits }
|
|
225
|
+
const wanted = String(session ?? '')
|
|
226
|
+
|
|
227
|
+
const read = wanted === '' ? [] : await vault.readEpisodes({ session: wanted })
|
|
228
|
+
const records = read.map((entry) => entry.record).filter(Boolean)
|
|
229
|
+
const truncatedEpisodes = records.length > caps.episodes
|
|
230
|
+
const episodes = truncatedEpisodes ? records.slice(records.length - caps.episodes) : records
|
|
231
|
+
|
|
232
|
+
const project = dominantProject(records) ?? fallbackProject ?? null
|
|
233
|
+
const allCards = await vault.listCards()
|
|
234
|
+
const allEntities = await vault.listEntities()
|
|
235
|
+
const entityBySlug = new Map(allEntities.map((entity) => [entity.slug, entity]))
|
|
236
|
+
|
|
237
|
+
const query = recallQuery(episodes)
|
|
238
|
+
const recalled = query === '' ? [] : searchCards(allCards, query, { limit: caps.recall, hops: 1 })
|
|
239
|
+
const recalledIds = recalled.map((hit) => hit.card.id)
|
|
240
|
+
|
|
241
|
+
// Cards to draw, in priority order: what this session's own words retrieve
|
|
242
|
+
// (even from another project — that is the cross-project claim), then the
|
|
243
|
+
// rest of its project newest-first, so the cap never drops what the session
|
|
244
|
+
// is actually about.
|
|
245
|
+
const recalledCards = recalled.map((hit) => hit.card)
|
|
246
|
+
const inProject = allCards.filter((card) => project !== null && card.project === project)
|
|
247
|
+
const ordered = []
|
|
248
|
+
const orderedIds = new Set()
|
|
249
|
+
for (const card of [
|
|
250
|
+
...recalledCards,
|
|
251
|
+
...inProject
|
|
252
|
+
.filter((candidate) => !recalledIds.includes(candidate.id))
|
|
253
|
+
.sort((a, b) => String(b.created).localeCompare(String(a.created))),
|
|
254
|
+
]) {
|
|
255
|
+
if (orderedIds.has(card.id)) continue
|
|
256
|
+
orderedIds.add(card.id)
|
|
257
|
+
ordered.push(card)
|
|
258
|
+
}
|
|
259
|
+
const truncatedCards = ordered.length > caps.cards
|
|
260
|
+
const cards = ordered.slice(0, caps.cards)
|
|
261
|
+
|
|
262
|
+
const nodes = []
|
|
263
|
+
const links = []
|
|
264
|
+
const seenLinks = new Set()
|
|
265
|
+
const link = (s, t, k) => {
|
|
266
|
+
if (s === t) return
|
|
267
|
+
const key = `${s}|${t}|${k}`
|
|
268
|
+
if (seenLinks.has(key)) return
|
|
269
|
+
seenLinks.add(key)
|
|
270
|
+
links.push({ s, t, k })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sessionKey = 's:session'
|
|
274
|
+
nodes.push({
|
|
275
|
+
key: sessionKey,
|
|
276
|
+
k: 'session',
|
|
277
|
+
label: '',
|
|
278
|
+
sub: `${records.length}`,
|
|
279
|
+
r: 15,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// --- this conversation's own words
|
|
283
|
+
for (let i = 0; i < episodes.length; i++) {
|
|
284
|
+
const record = episodes[i]
|
|
285
|
+
const entry = truncatedEpisodes ? read[read.length - episodes.length + i] : read[i]
|
|
286
|
+
const key = `e:${entry?.file ?? i}:${entry?.line ?? i}`
|
|
287
|
+
nodes.push({
|
|
288
|
+
key,
|
|
289
|
+
k: 'episode',
|
|
290
|
+
label: `T${record.turn ?? i + 1}`,
|
|
291
|
+
sub: String(record.t ?? '').slice(11, 16),
|
|
292
|
+
text: clip(record.user, caps.episodePreview),
|
|
293
|
+
t: record.t ?? null,
|
|
294
|
+
r: 7,
|
|
295
|
+
})
|
|
296
|
+
link(sessionKey, key, 'said')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- memory cards: the session's own project, plus recalled cards from
|
|
300
|
+
// elsewhere, which keep their own project and are drawn as the bridge they are
|
|
301
|
+
const cardByKey = new Map()
|
|
302
|
+
for (const card of cards) {
|
|
303
|
+
const key = `c:${card.id}`
|
|
304
|
+
const hot = recalledIds.includes(card.id)
|
|
305
|
+
const foreign = project !== null && card.project !== project
|
|
306
|
+
nodes.push({
|
|
307
|
+
key,
|
|
308
|
+
k: 'card',
|
|
309
|
+
label: clip(card.title, 40),
|
|
310
|
+
sub: card.kind,
|
|
311
|
+
id: card.id,
|
|
312
|
+
title: String(card.title ?? ''),
|
|
313
|
+
kind: String(card.kind ?? 'note'),
|
|
314
|
+
project: card.project ?? project ?? '',
|
|
315
|
+
status: card.status ?? 'active',
|
|
316
|
+
confidence: card.confidence ?? 'inferred',
|
|
317
|
+
created: String(card.created ?? '').slice(0, 10),
|
|
318
|
+
validFrom: card.validFrom ?? null,
|
|
319
|
+
body: clip(card.body, caps.cardBody),
|
|
320
|
+
tags: card.tags ?? [],
|
|
321
|
+
entityNames: (card.entities ?? []).map((slug) => entityBySlug.get(slug)?.name ?? slug),
|
|
322
|
+
supersedes: card.supersedes ?? [],
|
|
323
|
+
supersededBy: card.supersededBy ?? null,
|
|
324
|
+
hot,
|
|
325
|
+
foreign,
|
|
326
|
+
r: hot ? 12 : 9,
|
|
327
|
+
})
|
|
328
|
+
cardByKey.set(card.id, key)
|
|
329
|
+
if (hot) link(sessionKey, key, 'recall')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- entities: the ones these cards mention, plus the ones this session named
|
|
333
|
+
const entitySlugs = new Set()
|
|
334
|
+
for (const card of cards) for (const slug of card.entities ?? []) entitySlugs.add(slug)
|
|
335
|
+
for (const record of episodes) {
|
|
336
|
+
for (const slug of entitiesInText(allEntities, `${record.user ?? ''}`)) entitySlugs.add(slug)
|
|
337
|
+
}
|
|
338
|
+
const entityList = [...entitySlugs].filter((slug) => entityBySlug.has(slug)).slice(0, caps.entities)
|
|
339
|
+
const entityKeyBySlug = new Map()
|
|
340
|
+
for (const slug of entityList) {
|
|
341
|
+
const entity = entityBySlug.get(slug)
|
|
342
|
+
const key = `x:${slug}`
|
|
343
|
+
entityKeyBySlug.set(slug, key)
|
|
344
|
+
nodes.push({
|
|
345
|
+
key,
|
|
346
|
+
k: 'entity',
|
|
347
|
+
label: String(entity.name ?? slug),
|
|
348
|
+
sub: entity.type ?? 'concept',
|
|
349
|
+
slug,
|
|
350
|
+
mentions: (entity.mentions ?? []).length,
|
|
351
|
+
r: 7,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const card of cards) {
|
|
356
|
+
const source = cardByKey.get(card.id)
|
|
357
|
+
for (const slug of card.entities ?? []) {
|
|
358
|
+
const target = entityKeyBySlug.get(slug)
|
|
359
|
+
if (target !== undefined) link(source, target, 'mentions')
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (let i = 0; i < episodes.length; i++) {
|
|
364
|
+
const entry = truncatedEpisodes ? read[read.length - episodes.length + i] : read[i]
|
|
365
|
+
const source = `e:${entry?.file ?? i}:${entry?.line ?? i}`
|
|
366
|
+
for (const slug of entitiesInText(allEntities, `${episodes[i].user ?? ''}`)) {
|
|
367
|
+
const target = entityKeyBySlug.get(slug)
|
|
368
|
+
if (target !== undefined) link(source, target, 'named')
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// --- cross-project: a card in another project that hangs off the same entity
|
|
373
|
+
// is the whole point of the entity layer, so it is drawn, but dimmed and capped.
|
|
374
|
+
const selectedIds = new Set(cards.map((card) => card.id))
|
|
375
|
+
const foreign = []
|
|
376
|
+
for (const card of allCards) {
|
|
377
|
+
if (selectedIds.has(card.id)) continue
|
|
378
|
+
if (project !== null && card.project === project) continue
|
|
379
|
+
if (card.status === 'retracted') continue
|
|
380
|
+
if (!(card.entities ?? []).some((slug) => entityKeyBySlug.has(slug))) continue
|
|
381
|
+
foreign.push(card)
|
|
382
|
+
if (foreign.length >= caps.foreignCards) break
|
|
383
|
+
}
|
|
384
|
+
for (const card of foreign) {
|
|
385
|
+
const key = `c:${card.id}`
|
|
386
|
+
nodes.push({
|
|
387
|
+
key,
|
|
388
|
+
k: 'card',
|
|
389
|
+
label: clip(card.title, 40),
|
|
390
|
+
sub: card.kind,
|
|
391
|
+
id: card.id,
|
|
392
|
+
title: String(card.title ?? ''),
|
|
393
|
+
kind: String(card.kind ?? 'note'),
|
|
394
|
+
project: card.project ?? '',
|
|
395
|
+
status: card.status ?? 'active',
|
|
396
|
+
confidence: card.confidence ?? 'inferred',
|
|
397
|
+
created: String(card.created ?? '').slice(0, 10),
|
|
398
|
+
validFrom: card.validFrom ?? null,
|
|
399
|
+
body: clip(card.body, caps.cardBody),
|
|
400
|
+
tags: card.tags ?? [],
|
|
401
|
+
entityNames: (card.entities ?? []).map((slug) => entityBySlug.get(slug)?.name ?? slug),
|
|
402
|
+
supersedes: card.supersedes ?? [],
|
|
403
|
+
supersededBy: card.supersededBy ?? null,
|
|
404
|
+
hot: false,
|
|
405
|
+
foreign: true,
|
|
406
|
+
r: 9,
|
|
407
|
+
})
|
|
408
|
+
cardByKey.set(card.id, key)
|
|
409
|
+
for (const slug of card.entities ?? []) {
|
|
410
|
+
const target = entityKeyBySlug.get(slug)
|
|
411
|
+
if (target !== undefined) link(key, target, 'mentions')
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- a reversal is a shape worth seeing: the replaced card points at its heir
|
|
416
|
+
for (const card of [...cards, ...foreign]) {
|
|
417
|
+
const heir = card.supersededBy ? cardByKey.get(card.supersededBy) : undefined
|
|
418
|
+
if (heir !== undefined) link(cardByKey.get(card.id), heir, 'supersedes')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
while (links.length > caps.links) links.pop()
|
|
422
|
+
layoutNodes(nodes, links)
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
ok: true,
|
|
426
|
+
session: shortId(wanted),
|
|
427
|
+
sessionId: wanted,
|
|
428
|
+
project,
|
|
429
|
+
generatedAt: new Date().toISOString(),
|
|
430
|
+
empty: records.length === 0,
|
|
431
|
+
query,
|
|
432
|
+
counts: {
|
|
433
|
+
episodes: records.length,
|
|
434
|
+
// Local and cross-project cards are counted apart: the header says how
|
|
435
|
+
// much of the picture belongs to this conversation's project and how much
|
|
436
|
+
// arrived through the entity layer.
|
|
437
|
+
cards: project === null ? cards.length : cards.filter((card) => card.project === project).length,
|
|
438
|
+
entities: entityList.length,
|
|
439
|
+
foreign: foreign.length + (project === null ? 0 : cards.filter((card) => card.project !== project).length),
|
|
440
|
+
links: links.length,
|
|
441
|
+
},
|
|
442
|
+
truncated: { episodes: truncatedEpisodes, cards: truncatedCards },
|
|
443
|
+
recall: recalledIds,
|
|
444
|
+
nodes,
|
|
445
|
+
links,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The HTTP handler for {@link GRAPH_PATH}.
|
|
451
|
+
*
|
|
452
|
+
* Connection admits the request before this runs and only an authenticated
|
|
453
|
+
* owner Peer reaches an exact Fetch route, so there is no second auth check
|
|
454
|
+
* here. What is left is input handling: an unknown Session is not an error
|
|
455
|
+
* worth a 500, and a throw must answer with something the panel can show.
|
|
456
|
+
*
|
|
457
|
+
* `json` builds the response by hand rather than with `Response.json`, which
|
|
458
|
+
* only exists on recent Node: the host may run an older one.
|
|
459
|
+
*/
|
|
460
|
+
|
|
461
|
+
export function createGraphHandler({ getVault, getConfig }) {
|
|
462
|
+
return async function handle(request) {
|
|
463
|
+
const url = new URL(request.url)
|
|
464
|
+
const session = url.searchParams.get('session') ?? ''
|
|
465
|
+
if (session === '') return json({ ok: false, reason: 'missing-session' }, 400)
|
|
466
|
+
try {
|
|
467
|
+
const cfg = getConfig()
|
|
468
|
+
const vault = getVault()
|
|
469
|
+
const payload = await buildSessionGraph({ vault, session, project: cfg?.defaultProject ?? null })
|
|
470
|
+
return json(payload)
|
|
471
|
+
} catch (error) {
|
|
472
|
+
return json({ ok: false, reason: 'unavailable', message: String(error?.message ?? error) }, 500)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one response helper both route handlers use.
|
|
3
|
+
*
|
|
4
|
+
* Not `Response.json`: the harness may run on a Node build that predates the
|
|
5
|
+
* static helper, and this is a host-side route, not a browser call — a plain
|
|
6
|
+
* `new Response` with an explicit content type is the least surprising thing to
|
|
7
|
+
* return to Connection.
|
|
8
|
+
*/
|
|
9
|
+
export function json(body, status = 200) {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A page, for the one route that serves the whole-vault graph.
|
|
18
|
+
*
|
|
19
|
+
* `x-frame-options: SAMEORIGIN` because that page is embedded in the app's own
|
|
20
|
+
* panel: same origin only, which also means a page on another origin cannot frame
|
|
21
|
+
* it. No CSP of its own — the page is self-contained and ships its own script.
|
|
22
|
+
*/
|
|
23
|
+
export function html(body, status = 200) {
|
|
24
|
+
return new Response(String(body ?? ''), {
|
|
25
|
+
status,
|
|
26
|
+
headers: {
|
|
27
|
+
'content-type': 'text/html; charset=utf-8',
|
|
28
|
+
'cache-control': 'no-store',
|
|
29
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
}
|
package/src/pageapi.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole-vault graph, served as a page.
|
|
3
|
+
*
|
|
4
|
+
* Why a route and not the file: `graph.html` sits in the vault directory, which
|
|
5
|
+
* is a path on disk the browser has no business reading (and a `file://` URL the
|
|
6
|
+
* app's panel will not frame). This route hands back the same bytes over the
|
|
7
|
+
* authenticated `/api` connection, so the panel can embed it and so a link to it
|
|
8
|
+
* keeps working from another window.
|
|
9
|
+
*
|
|
10
|
+
* Read-only and cheap: it renders from the cards and entity pages already in the
|
|
11
|
+
* vault, writes nothing, and spends nothing.
|
|
12
|
+
*/
|
|
13
|
+
import { html } from './http.js'
|
|
14
|
+
|
|
15
|
+
/** The exact path the host serves and the panel embeds. Authenticated by Connection. */
|
|
16
|
+
export const PAGE_PATH = '/api/memory-graph.page'
|
|
17
|
+
|
|
18
|
+
function failure(error) {
|
|
19
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
20
|
+
const escaped = detail.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c])
|
|
21
|
+
return `<!doctype html><html lang="zh"><meta charset="utf-8"><title>记忆图谱</title>
|
|
22
|
+
<style>body{font:14px/1.6 system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh;color:#333}
|
|
23
|
+
main{max-width:34rem;padding:2rem}code{background:#f4f4f5;padding:.1rem .3rem;border-radius:4px}</style>
|
|
24
|
+
<main><h1>记忆图没能画出来</h1><p><code>${escaped}</code></p>
|
|
25
|
+
<p>The graph could not be rendered. The vault is still on disk; nothing was written.</p></main></html>`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createPageHandler({ getVault }) {
|
|
29
|
+
return async function fetch() {
|
|
30
|
+
const vault = getVault()
|
|
31
|
+
if (!vault || typeof vault.graphPage !== 'function') {
|
|
32
|
+
return html(failure(new Error('vault unavailable')), 500)
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return html(await vault.graphPage())
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return html(failure(error), 500)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/privacy.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-time privacy filter.
|
|
3
|
+
*
|
|
4
|
+
* Every string that is about to enter the vault passes through a redactor.
|
|
5
|
+
* Nothing is written and then cleaned up afterwards: if a rule matches, the
|
|
6
|
+
* secret never reaches the file at all. The audit log records the rule that
|
|
7
|
+
* fired, never the matched text.
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import { DEFAULT_DENY_PATTERNS } from './config.js'
|
|
11
|
+
|
|
12
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
|
|
13
|
+
|
|
14
|
+
function compile(pattern) {
|
|
15
|
+
let flags = 'g'
|
|
16
|
+
let source = String(pattern)
|
|
17
|
+
// JS has no inline flags; accept the common `(?i)` prefix as an instruction.
|
|
18
|
+
if (source.startsWith('(?i)')) {
|
|
19
|
+
source = source.slice(4)
|
|
20
|
+
flags += 'i'
|
|
21
|
+
}
|
|
22
|
+
return new RegExp(source, flags)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {{ redact(text: string, where?: string): { text: string, hits: string[] }, ruleCount: number }}
|
|
27
|
+
*/
|
|
28
|
+
export function createRedactor(privacy) {
|
|
29
|
+
const patterns = []
|
|
30
|
+
if (privacy.useDefaultPatterns !== false) patterns.push(...DEFAULT_DENY_PATTERNS)
|
|
31
|
+
patterns.push(...(privacy.denyPatterns || []))
|
|
32
|
+
|
|
33
|
+
const rules = []
|
|
34
|
+
for (const pattern of patterns) {
|
|
35
|
+
try {
|
|
36
|
+
rules.push({ pattern, re: compile(pattern) })
|
|
37
|
+
} catch {
|
|
38
|
+
// A malformed user rule must not break capture; it simply does not apply.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const home = os.homedir()
|
|
43
|
+
|
|
44
|
+
function redact(text) {
|
|
45
|
+
if (typeof text !== 'string' || text === '') return { text: '', hits: [] }
|
|
46
|
+
let out = text
|
|
47
|
+
const hits = []
|
|
48
|
+
|
|
49
|
+
for (const rule of rules) {
|
|
50
|
+
rule.re.lastIndex = 0
|
|
51
|
+
if (!rule.re.test(out)) continue
|
|
52
|
+
rule.re.lastIndex = 0
|
|
53
|
+
out = out.replace(rule.re, () => '[REDACTED]')
|
|
54
|
+
hits.push(rule.pattern)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (privacy.redactPaths !== false && home.length > 2) {
|
|
58
|
+
const escaped = home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
59
|
+
const re = new RegExp(escaped.replace(/\\\\/g, '[\\\\/]'), 'g')
|
|
60
|
+
if (re.test(out)) {
|
|
61
|
+
re.lastIndex = 0
|
|
62
|
+
out = out.replace(re, '~')
|
|
63
|
+
hits.push('home-path')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (privacy.redactEmails !== false && EMAIL.test(out)) {
|
|
68
|
+
EMAIL.lastIndex = 0
|
|
69
|
+
out = out.replace(EMAIL, '[email]')
|
|
70
|
+
hits.push('email')
|
|
71
|
+
}
|
|
72
|
+
EMAIL.lastIndex = 0
|
|
73
|
+
|
|
74
|
+
return { text: out, hits }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { redact, ruleCount: rules.length }
|
|
78
|
+
}
|