@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
package/eval/seed.mjs ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Eval fixture: a throwaway vault built with the real vault writer.
3
+ *
4
+ * node eval/seed.mjs build it, print a summary, keep the directory
5
+ *
6
+ * Shape of the fixture (this is the ground truth the questions are written
7
+ * against, not the other way round):
8
+ * - 3 projects: memory-graph, blog-engine, infra
9
+ * - 38 cards over all 5 kinds
10
+ * - 7 entities shared by two or more projects (the cross-project bridges)
11
+ * - 4 supersede chains, 1 retracted card
12
+ *
13
+ * The first two chains (抽取, 监控) existed from the start. The other two
14
+ * (告警渠道, 证书) were added when the paired history/current test was written:
15
+ * a pair needs an old card and a new card on the same topic, and two chains do
16
+ * not make four pairs. Both new chains sit on themes no earlier question asks
17
+ * about, so the original 25 questions are re-measured on the larger corpus but
18
+ * their answers did not move.
19
+ * - mixed Chinese/English titles, real-looking bodies of one to three lines
20
+ *
21
+ * Card ids are fixed instead of generated so questions.json can name them; the
22
+ * date inside an id is decorative. Ages are kept in `ageDays` and turned into
23
+ * `created` timestamps at seed time, so recency weighting stays identical
24
+ * however long after this file was written the eval runs.
25
+ */
26
+ import fsp from 'node:fs/promises'
27
+ import os from 'node:os'
28
+ import path from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+
31
+ import { resolveConfig } from '../src/config.js'
32
+ import { createRedactor } from '../src/privacy.js'
33
+ import { createVault } from '../src/vault.js'
34
+
35
+ const DAY = 24 * 60 * 60 * 1000
36
+
37
+ /** Cards as they are handed to vault.saveCard, oldest first. */
38
+ export const FIXTURE_CARDS = [
39
+ /* ------------------------------------------------------------ memory-graph */
40
+ {
41
+ id: 'c-20260305-0001',
42
+ project: 'memory-graph',
43
+ kind: 'decision',
44
+ ageDays: 242,
45
+ title: '记忆存储用纯文本而不是 SQLite',
46
+ body: 'SQLite 要处理锁、迁移和二进制合并,出了事只能写脚本去挖。纯文本可以直接 grep、直接读、直接进 git,坏了也能手工修。',
47
+ entities: ['memory-graph', 'vault格式', 'sqlite'],
48
+ confidence: 'user_stated',
49
+ },
50
+ {
51
+ id: 'c-20260412-0002',
52
+ project: 'memory-graph',
53
+ kind: 'decision',
54
+ ageDays: 204,
55
+ title: '卡片层和情节层分开:episodes 只追加,cards 蒸馏',
56
+ body: '原始情节一行一轮,谁都不许改;从情节里抽出来的记忆单独存成带 kind 和日期的卡片。分开之后可以重新蒸馏,也不会因为一次抽取失误毁掉原始记录。',
57
+ entities: ['memory-graph', 'vault格式', '蒸馏'],
58
+ confidence: 'user_stated',
59
+ },
60
+ {
61
+ id: 'c-20260520-0003',
62
+ project: 'memory-graph',
63
+ kind: 'preference',
64
+ ageDays: 166,
65
+ title: '所有 DSH 插件零依赖、不要构建步骤',
66
+ body: '装到哪个 profile 都能直接 node 跑起来,不想要 bundler,也不想要 node_modules。宁可多写两百行,也不引一个包。',
67
+ entities: ['dsh插件', '零依赖', 'esm'],
68
+ confidence: 'user_stated',
69
+ },
70
+ {
71
+ id: 'c-20260618-0004',
72
+ project: 'memory-graph',
73
+ kind: 'fact',
74
+ ageDays: 137,
75
+ title: '中文按二元组切词,英文按空白切词',
76
+ body: 'tokenize 里中文没有词典,直接滑窗取 bigram;英文和数字按非字母数字切分。这样“记忆图谱”会出“记忆”“忆图”“图谱”三个 token。',
77
+ entities: ['memory-graph', 'tokenize', '中文分词'],
78
+ confidence: 'verified',
79
+ },
80
+ {
81
+ id: 'c-20260701-0005',
82
+ project: 'memory-graph',
83
+ kind: 'open_question',
84
+ ageDays: 124,
85
+ title: '实体页要不要做别名合并?',
86
+ body: '现在 slugify 之后“vault 格式”和“vault格式”是两页。合并本身不难,但规则写歪了会把两个不相干的东西并到一起,暂时没定。',
87
+ entities: ['entities索引', 'memory-graph'],
88
+ confidence: 'inferred',
89
+ },
90
+ {
91
+ id: 'c-20260715-0006',
92
+ project: 'memory-graph',
93
+ kind: 'decision',
94
+ ageDays: 110,
95
+ title: '抽取模型默认关闭',
96
+ body: '第一版打算默认开启自动蒸馏,每轮对话结束就跑一次抽取。',
97
+ entities: ['抽取器', 'deepseek'],
98
+ confidence: 'inferred',
99
+ },
100
+ {
101
+ id: 'c-20260802-0007',
102
+ project: 'memory-graph',
103
+ kind: 'decision',
104
+ ageDays: 92,
105
+ title: '抽取改成显式调用 memory_distill,不再自动蒸馏',
106
+ body: '自动蒸馏会在用户不知情的时候烧 API 费用,而且大部分轮次根本没有值得记的东西。改成只有用户显式调用 memory_distill 才跑抽取。',
107
+ entities: ['抽取器', 'deepseek', 'dsh插件'],
108
+ confidence: 'user_stated',
109
+ supersedes: ['c-20260715-0006'],
110
+ },
111
+ {
112
+ id: 'c-20260820-0008',
113
+ project: 'memory-graph',
114
+ kind: 'fact',
115
+ ageDays: 74,
116
+ title: '写入前必须过 redactor,凭证一律替换',
117
+ body: 'sk- / ghp_ / 私钥块这些形状在落盘之前就替换成 [REDACTED],不许写进 vault。',
118
+ entities: ['隐私边界', 'vault格式'],
119
+ confidence: 'user_stated',
120
+ },
121
+ {
122
+ id: 'c-20260901-0009',
123
+ project: 'memory-graph',
124
+ kind: 'note',
125
+ ageDays: 62,
126
+ title: 'vault 目录结构笔记',
127
+ body: 'episodes/ 放原始 jsonl,cards/YYYY-MM/ 放卡片,entities/ 一实体一页,audit/ 是写入流水。没有数据库文件,全部是能 diff 的文本。',
128
+ entities: ['vault格式', 'memory-graph'],
129
+ confidence: 'verified',
130
+ },
131
+ {
132
+ id: 'c-20260918-0010',
133
+ project: 'memory-graph',
134
+ kind: 'decision',
135
+ ageDays: 45,
136
+ title: '检索用 BM25 加实体单跳扩展',
137
+ body: '关键词走 BM25,实体命中给一个固定加分,然后沿共现实体做一跳扩展。不用向量检索,因为漂移了没法解释为什么召回这张卡。',
138
+ entities: ['检索', 'memory-graph', 'vault格式'],
139
+ confidence: 'user_stated',
140
+ },
141
+ {
142
+ id: 'c-20261005-0011',
143
+ project: 'memory-graph',
144
+ kind: 'fact',
145
+ ageDays: 28,
146
+ title: 'retracted 和 superseded 是降权不是删除',
147
+ body: 'retracted 权重归零但文件还在磁盘上;superseded 打 0.4 的折扣,旧方案还能查到,只是压在新方案下面。',
148
+ entities: ['检索', 'vault格式', '隐私边界'],
149
+ confidence: 'user_stated',
150
+ },
151
+ {
152
+ id: 'c-20261020-0012',
153
+ project: 'memory-graph',
154
+ kind: 'open_question',
155
+ ageDays: 13,
156
+ title: '跨项目 recall 的评测集怎么造',
157
+ body: '要有一批只有靠共享实体才能带出来的问题,否则测的都是关键词匹配。人工标 25 条左右,按题型分组看分数。',
158
+ entities: ['评测', 'memory-graph', '检索'],
159
+ confidence: 'inferred',
160
+ },
161
+ {
162
+ id: 'c-20261029-0013',
163
+ project: 'memory-graph',
164
+ kind: 'preference',
165
+ ageDays: 4,
166
+ title: '报告里不要用 emoji,表格用 markdown',
167
+ body: '汇报和文档一律纯文本,表格用 markdown 管道,重点用粗体而不是表情符号。',
168
+ entities: ['写作偏好', '文档'],
169
+ confidence: 'user_stated',
170
+ },
171
+
172
+ /* ------------------------------------------------------------- blog-engine */
173
+ {
174
+ id: 'c-20260310-0014',
175
+ project: 'blog-engine',
176
+ kind: 'decision',
177
+ ageDays: 237,
178
+ title: '静态站点生成器自己写,零依赖',
179
+ body: 'Hugo 和 Eleventy 都试过,为了两个自定义行为要改主题源码,还不如自己写一个两百行的 generator。只用 node 内置模块。',
180
+ entities: ['零依赖', 'blog-engine', 'node-esm'],
181
+ confidence: 'user_stated',
182
+ },
183
+ {
184
+ id: 'c-20260408-0015',
185
+ project: 'blog-engine',
186
+ kind: 'fact',
187
+ ageDays: 208,
188
+ title: '文章用 Markdown + frontmatter,日期写 YYYY-MM-DD',
189
+ body: '每篇一个 .md,头部 frontmatter 放 title/date/tags/draft。日期格式统一 YYYY-MM-DD,不做本地化。',
190
+ entities: ['markdown', 'blog-engine'],
191
+ confidence: 'verified',
192
+ },
193
+ {
194
+ id: 'c-20260512-0016',
195
+ project: 'blog-engine',
196
+ kind: 'decision',
197
+ ageDays: 174,
198
+ title: '评论系统从 Disqus 换成自建 sqlite',
199
+ body: 'Disqus 加载慢还带追踪。改成一个小服务,评论存 sqlite,和站点部署在同一台机器上。',
200
+ entities: ['sqlite', 'blog-engine', '评论'],
201
+ confidence: 'user_stated',
202
+ },
203
+ {
204
+ id: 'c-20260602-0017',
205
+ project: 'blog-engine',
206
+ kind: 'open_question',
207
+ ageDays: 153,
208
+ title: '旧文章要不要加 canonical 指向掘金',
209
+ body: '同一篇在掘金也发,重复内容怕被降权。加 canonical 会不会把本站的权重也让出去,还没想清楚。',
210
+ entities: ['seo', 'blog-engine'],
211
+ confidence: 'inferred',
212
+ },
213
+ {
214
+ id: 'c-20260708-0018',
215
+ project: 'blog-engine',
216
+ kind: 'fact',
217
+ ageDays: 117,
218
+ title: '部署靠 GitHub Actions,push 到 main 就 build',
219
+ body: 'workflow 里 npm ci、build、rsync 到 VPS。没有别的部署方式,也不打算上 Vercel。',
220
+ entities: ['github-actions', 'blog-engine', '部署'],
221
+ confidence: 'verified',
222
+ },
223
+ {
224
+ id: 'c-20260811-0019',
225
+ project: 'blog-engine',
226
+ kind: 'preference',
227
+ ageDays: 83,
228
+ title: '博客写作偏好:先结论后论证,少用形容词',
229
+ body: '开头两句把结论说完,后面再展开。形容词和副词能删就删,宁可干一点。',
230
+ entities: ['写作偏好', 'blog-engine'],
231
+ confidence: 'user_stated',
232
+ },
233
+ {
234
+ id: 'c-20260905-0020',
235
+ project: 'blog-engine',
236
+ kind: 'decision',
237
+ ageDays: 58,
238
+ title: '封面图用 Cloudflare Images,不做本地压缩',
239
+ body: '本地 sharp 压缩要维护依赖,不同尺寸还得自己写。Cloudflare Images 一个 URL 参数搞定,按量付费也不贵。',
240
+ entities: ['cloudflare', 'blog-engine'],
241
+ confidence: 'user_stated',
242
+ },
243
+ {
244
+ id: 'c-20260922-0021',
245
+ project: 'blog-engine',
246
+ kind: 'note',
247
+ ageDays: 41,
248
+ title: '英文站放在 /en 子路径,不搞子域名',
249
+ body: '子域名要单独配证书和 DNS,子路径直接复用现有部署。hreflang 里两个都指回主域。',
250
+ entities: ['i18n', 'blog-engine', 'seo'],
251
+ confidence: 'inferred',
252
+ },
253
+ {
254
+ id: 'c-20261011-0022',
255
+ project: 'blog-engine',
256
+ kind: 'fact',
257
+ ageDays: 22,
258
+ title: '每篇文章目标 1200 字,超了就拆成两篇',
259
+ body: '超过 1200 字阅读完成率明显掉。宁可拆成两篇互相链接。',
260
+ entities: ['写作偏好', 'blog-engine'],
261
+ confidence: 'user_stated',
262
+ },
263
+ {
264
+ id: 'c-20261024-0023',
265
+ project: 'blog-engine',
266
+ kind: 'decision',
267
+ ageDays: 9,
268
+ title: 'RSS 输出全文,不做摘要',
269
+ body: '全文 RSS 对读者更友好,也省得维护摘要逻辑。带着 canonical 一起输出。',
270
+ entities: ['rss', 'blog-engine'],
271
+ confidence: 'user_stated',
272
+ },
273
+ {
274
+ id: 'c-20261101-0024',
275
+ project: 'blog-engine',
276
+ kind: 'open_question',
277
+ ageDays: 1,
278
+ title: '要不要把 blog engine 抽成 DSH 插件',
279
+ body: '现在 generator 是个独立脚本,抽成插件就能在会话里直接发文。但插件要零依赖,可能会把 generator 写胖。',
280
+ entities: ['dsh插件', 'blog-engine', 'esm'],
281
+ confidence: 'inferred',
282
+ },
283
+
284
+ /* ------------------------------------------------------------------- infra */
285
+ {
286
+ id: 'c-20260320-0025',
287
+ project: 'infra',
288
+ kind: 'decision',
289
+ ageDays: 227,
290
+ title: '备份用 restic 到 B2,每天凌晨跑',
291
+ body: 'restic 增量加密,仓库放 Backblaze B2。保留策略 7 天 / 4 周 / 12 月。',
292
+ entities: ['备份', 'infra', 'restic'],
293
+ confidence: 'user_stated',
294
+ },
295
+ {
296
+ id: 'c-20260425-0026',
297
+ project: 'infra',
298
+ kind: 'fact',
299
+ ageDays: 191,
300
+ title: '主 VPS 是 Hetzner 2C4G,跑 docker compose',
301
+ body: 'CPX11,两个 vCPU 四个 G。所有服务一个 compose 文件起,不搞 swarm 也不搞 k8s。',
302
+ entities: ['infra', 'hetzner', 'docker'],
303
+ confidence: 'verified',
304
+ },
305
+ {
306
+ id: 'c-20260530-0027',
307
+ project: 'infra',
308
+ kind: 'decision',
309
+ ageDays: 156,
310
+ title: '监控从 Zabbix 换到 Uptime Kuma',
311
+ body: 'Zabbix 太重,模板和维护成本都高。先用 Uptime Kuma 做外部拨测。',
312
+ entities: ['监控', 'infra'],
313
+ confidence: 'user_stated',
314
+ },
315
+ {
316
+ id: 'c-20260706-0028',
317
+ project: 'infra',
318
+ kind: 'decision',
319
+ ageDays: 119,
320
+ title: '监控换成 Prometheus + Grafana,Uptime Kuma 只留外部拨测',
321
+ body: 'Uptime Kuma 只有 up/down,看不出趋势。指标全量进 Prometheus,面板用 Grafana,Kuma 只负责从外网拨。',
322
+ entities: ['监控', 'infra', 'prometheus'],
323
+ confidence: 'user_stated',
324
+ supersedes: ['c-20260530-0027'],
325
+ },
326
+ {
327
+ id: 'c-20260809-0029',
328
+ project: 'infra',
329
+ kind: 'preference',
330
+ ageDays: 85,
331
+ title: '每个服务都要能单机 docker compose up 起来',
332
+ body: '不允许出现只在一个环境能跑的服务。本地 compose 和线上是同一份文件,差异只在 .env。',
333
+ entities: ['docker', 'infra', '零依赖'],
334
+ confidence: 'user_stated',
335
+ },
336
+ {
337
+ id: 'c-20260912-0030',
338
+ project: 'infra',
339
+ kind: 'open_question',
340
+ ageDays: 51,
341
+ title: '要不要给 infra 抽一个统一 CLI',
342
+ body: '现在每件事都是散着的 shell 脚本。想收成一个 node 写的 esm CLI,零依赖直接跑,但还没到非做不可的程度。',
343
+ entities: ['infra', '零依赖', 'esm'],
344
+ confidence: 'inferred',
345
+ },
346
+ {
347
+ id: 'c-20261002-0031',
348
+ project: 'infra',
349
+ kind: 'fact',
350
+ ageDays: 31,
351
+ title: 'deepseek API 账单告警设在 50 美元',
352
+ body: '月度用量超 50 美元就发通知。主要消耗是记忆图谱的抽取和几个自动化脚本。',
353
+ entities: ['deepseek', 'infra', '成本'],
354
+ confidence: 'user_stated',
355
+ },
356
+ {
357
+ id: 'c-20261016-0032',
358
+ project: 'infra',
359
+ kind: 'note',
360
+ ageDays: 17,
361
+ title: 'GitHub Actions nightly 备份 workflow 在 03:00 UTC 跑',
362
+ body: '备份任务本身也在 Actions 里,跑 restic backup 然后 check。失败会开一个 issue。',
363
+ entities: ['github-actions', 'infra', '备份'],
364
+ confidence: 'verified',
365
+ },
366
+ {
367
+ id: 'c-20261027-0033',
368
+ project: 'infra',
369
+ kind: 'fact',
370
+ ageDays: 6,
371
+ title: '内网走 tailscale,不暴露公网端口',
372
+ body: '除了 80/443 之外没有对公网开的端口。ssh 和数据库都只在 tailnet 里。',
373
+ entities: ['tailscale', 'infra', '安全'],
374
+ confidence: 'verified',
375
+ },
376
+ {
377
+ id: 'c-20261030-0034',
378
+ project: 'infra',
379
+ kind: 'decision',
380
+ ageDays: 3,
381
+ title: '日志集中到 Loki,保留 14 天',
382
+ body: '从 docker logs 改成 promtail 推到 Loki。14 天够排障了,再长就用冷备。',
383
+ entities: ['loki', 'infra', '日志'],
384
+ confidence: 'user_stated',
385
+ },
386
+ {
387
+ id: 'c-20261102-0035',
388
+ project: 'infra',
389
+ kind: 'preference',
390
+ ageDays: 0,
391
+ title: '告警只发手机,不进邮箱',
392
+ body: '邮箱里的告警等于没告警。全部走手机推送,非工作时间的低优先级告警静音。',
393
+ entities: ['告警', 'infra'],
394
+ confidence: 'user_stated',
395
+ supersedes: ['c-20260110-0036'],
396
+ },
397
+ {
398
+ id: 'c-20260110-0036',
399
+ project: 'infra',
400
+ kind: 'decision',
401
+ ageDays: 296,
402
+ title: '告警先发邮件,Slack 只做抄送',
403
+ body: '早期所有告警都往邮箱里塞,重要的再手动抄一份到 Slack 频道。一天几十封,最后全都不看了。',
404
+ entities: ['告警', 'infra', '邮箱'],
405
+ confidence: 'inferred',
406
+ },
407
+ {
408
+ id: 'c-20260105-0037',
409
+ project: 'infra',
410
+ kind: 'note',
411
+ ageDays: 301,
412
+ title: '证书手动 renew,到期前手工跑一次',
413
+ body: '用 certbot 手动签,日历里记着到期提醒,忘了就过期,浏览器直接报红。',
414
+ entities: ['证书', 'infra', 'https'],
415
+ confidence: 'inferred',
416
+ },
417
+ {
418
+ id: 'c-20260128-0038',
419
+ project: 'infra',
420
+ kind: 'decision',
421
+ ageDays: 278,
422
+ title: '证书交给 caddy 自动续期',
423
+ body: 'caddy 自己管签发和续期,不再手动跑 certbot,也不用再记日历。',
424
+ entities: ['证书', 'infra', 'caddy'],
425
+ confidence: 'user_stated',
426
+ supersedes: ['c-20260105-0037'],
427
+ },
428
+ ]
429
+
430
+ /** The one card whose advice was wrong and was taken back. */
431
+ export const RETRACTED = { id: 'c-20260820-0008', reason: '规则写歪了,误伤正常文本,先撤回' }
432
+
433
+ /**
434
+ * Build the fixture vault.
435
+ * @param {string} [dir] target directory; a fresh temp dir when omitted
436
+ * @returns {Promise<{ dir: string, vault: object, cards: object[], stats: object }>}
437
+ */
438
+ export async function seed(dir) {
439
+ const target = dir ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'memgraph-eval-')))
440
+ await fsp.mkdir(target, { recursive: true })
441
+
442
+ const cfg = resolveConfig({ vaultDir: target })
443
+ const redactor = createRedactor(cfg.privacy)
444
+ const vault = createVault(cfg, redactor)
445
+
446
+ const now = Date.now()
447
+ // Oldest first, so every supersede chain is written after the card it retires.
448
+ const ordered = [...FIXTURE_CARDS].sort((a, b) => b.ageDays - a.ageDays)
449
+ for (const { ageDays, ...card } of ordered) {
450
+ await vault.saveCard({ ...card, created: new Date(now - ageDays * DAY).toISOString() })
451
+ }
452
+ await vault.retractCard(RETRACTED.id, RETRACTED.reason)
453
+ await vault.regenerateReadme()
454
+
455
+ const cards = await vault.listCards({ includeRetracted: true })
456
+ const stats = await vault.stats()
457
+ return { dir: target, vault, cards, stats }
458
+ }
459
+
460
+ /** Shared entities that appear in cards from two or more projects. */
461
+ export function bridgeEntities(cards) {
462
+ const projectsByEntity = new Map()
463
+ for (const card of cards) {
464
+ for (const slug of card.entities ?? []) {
465
+ if (!projectsByEntity.has(slug)) projectsByEntity.set(slug, new Set())
466
+ projectsByEntity.get(slug).add(card.project)
467
+ }
468
+ }
469
+ return [...projectsByEntity.entries()]
470
+ .filter(([, projects]) => projects.size >= 2)
471
+ .map(([slug, projects]) => ({ slug, projects: [...projects].sort() }))
472
+ .sort((a, b) => (a.slug > b.slug ? 1 : -1))
473
+ }
474
+
475
+ /* Allow `node eval/seed.mjs` as a standalone command. */
476
+ const invokedDirectly = process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false
477
+ if (invokedDirectly) {
478
+ const { dir, cards, stats } = await seed()
479
+ const bridges = bridgeEntities(cards)
480
+ const ages = FIXTURE_CARDS.map((card) => card.ageDays)
481
+ console.log(`vault: ${dir}`)
482
+ console.log(`cards: ${stats.cards.total} (${Object.entries(stats.cards.byKind).map(([k, n]) => `${k} ${n}`).join(', ')})`)
483
+ console.log(`status: active ${stats.cards.active}, superseded ${stats.cards.superseded}, retracted ${stats.cards.retracted}`)
484
+ console.log(`projects: ${stats.projects.join(', ')}`)
485
+ console.log(`entities: ${stats.entities}`)
486
+ console.log(`ages: ${Math.min(...ages)}..${Math.max(...ages)} days before the run`)
487
+ console.log(`bridges: ${bridges.length} shared by 2+ projects`)
488
+ for (const bridge of bridges) console.log(` ${bridge.slug} -> ${bridge.projects.join(', ')}`)
489
+ }
package/icon.svg ADDED
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
2
+ <rect width="64" height="64" rx="14" fill="#101820"/>
3
+ <g stroke="#3d5166" stroke-width="1.6">
4
+ <path d="M18 21 L32 14 L46 22"/>
5
+ <path d="M18 21 L22 35"/>
6
+ <path d="M46 22 L42 36"/>
7
+ <path d="M22 35 L42 36"/>
8
+ <path d="M22 35 L32 46"/>
9
+ <path d="M42 36 L32 46"/>
10
+ </g>
11
+ <path d="M18 21 L32 46" stroke="#6ee7b7" stroke-width="1.2" stroke-dasharray="3 3" opacity="0.75"/>
12
+ <g fill="#e8eef5">
13
+ <circle cx="32" cy="14" r="4"/>
14
+ <circle cx="46" cy="22" r="3.2"/>
15
+ <circle cx="18" cy="21" r="3.2"/>
16
+ <circle cx="22" cy="35" r="2.6"/>
17
+ <circle cx="42" cy="36" r="2.6"/>
18
+ </g>
19
+ <circle cx="32" cy="46" r="4.6" fill="#6ee7b7"/>
20
+ <circle cx="32" cy="46" r="2" fill="#101820"/>
21
+ <g stroke="#6ee7b7" stroke-width="1.4" stroke-linecap="round" opacity="0.9">
22
+ <path d="M52 46 L56 46"/>
23
+ <path d="M52 52 L58 52"/>
24
+ <path d="M52 58 L55 58"/>
25
+ </g>
26
+ </svg>