@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +466 -0
  4. package/README.md +459 -0
  5. package/client.js +1953 -0
  6. package/cordis.patch.yml +13 -0
  7. package/docs/auto-settings.png +0 -0
  8. package/docs/distill-button.png +0 -0
  9. package/docs/graph-dark.png +0 -0
  10. package/docs/graph-light.png +0 -0
  11. package/docs/session-tab-cards.png +0 -0
  12. package/docs/session-tab-open.png +0 -0
  13. package/docs/session-tab.png +0 -0
  14. package/docs/vault-panel.png +0 -0
  15. package/eval/REPORT.md +437 -0
  16. package/eval/questions.json +277 -0
  17. package/eval/run.mjs +460 -0
  18. package/eval/seed.mjs +489 -0
  19. package/icon.svg +26 -0
  20. package/index.js +243 -0
  21. package/locale/en.json +6 -0
  22. package/locale/zh.json +6 -0
  23. package/package.json +77 -0
  24. package/src/auto.js +194 -0
  25. package/src/capture.js +237 -0
  26. package/src/config.js +280 -0
  27. package/src/distillapi.js +30 -0
  28. package/src/distillrun.js +106 -0
  29. package/src/extract.js +660 -0
  30. package/src/graph.js +1582 -0
  31. package/src/graphdata.js +475 -0
  32. package/src/http.js +32 -0
  33. package/src/pageapi.js +40 -0
  34. package/src/privacy.js +78 -0
  35. package/src/schema.js +323 -0
  36. package/src/search.js +317 -0
  37. package/src/tools.js +534 -0
  38. package/src/vault.js +888 -0
  39. package/test/auto.mjs +295 -0
  40. package/test/capture.mjs +264 -0
  41. package/test/client.mjs +1035 -0
  42. package/test/distillapi.mjs +212 -0
  43. package/test/extract.mjs +660 -0
  44. package/test/graphdata.mjs +295 -0
  45. package/test/live-supersede.mjs +78 -0
  46. package/test/live.mjs +156 -0
  47. package/test/page.mjs +116 -0
  48. package/test/schema.mjs +256 -0
  49. package/test/smoke.mjs +428 -0
  50. package/test/tools.mjs +238 -0
@@ -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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[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
+ }