@ilaomianyang/dsh-memory-graph 0.11.0 → 0.12.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/client.js CHANGED
@@ -34,6 +34,13 @@ const DISTILL_API = '/api/memory-graph.distill'
34
34
  */
35
35
  const PANEL_ID = 'memory-graph'
36
36
  const PAGE_API = '/api/memory-graph.page'
37
+ /**
38
+ * Version and self-update. One path, split by method: GET reports what is
39
+ * installed and whether the registry has something newer, POST asks the host to
40
+ * install it. Both answers carry their own `message`, so this page never has to
41
+ * invent wording for a state the host already described.
42
+ */
43
+ const UPDATE_API = '/api/memory-graph.update'
37
44
 
38
45
  const ZH = (() => {
39
46
  try {
@@ -373,6 +380,95 @@ const SECTIONS = [
373
380
  label: t('记到哪个项目', 'Project'),
374
381
  hint: t('自动蒸馏出的卡片归属哪个项目。', 'Which project automatic cards are filed under.'),
375
382
  },
383
+ {
384
+ path: ['auto', 'catchUp'],
385
+ type: 'boolean',
386
+ label: t('追平模式', 'Catch up'),
387
+ hint: t(
388
+ '只要还攒着一整批未蒸馏的情节,就按下面自己的节奏一批批蒸下去,直到追平;日常的「定时蒸」「攒够就蒸」不受影响。导入过历史、或者很久没开的时候用它。',
389
+ 'While a full batch of undistilled episodes is still waiting, keep distilling batch after batch on its own pace below until the backlog drains; the two rules above keep working as usual. Meant for imported histories and long gaps.',
390
+ ),
391
+ },
392
+ {
393
+ path: ['auto', 'catchUpEveryMinutes'],
394
+ type: 'number',
395
+ min: 1,
396
+ max: 1440,
397
+ step: 1,
398
+ unit: t('分钟', 'min'),
399
+ label: t('追平间隔', 'Catch-up gap'),
400
+ hint: t(
401
+ '追平模式下每批之间隔多少分钟(默认 10)。这条节奏独立于上面的「最短间隔」。',
402
+ 'Minutes between catch-up batches (default 10). This pace is independent of the minimum gap above.',
403
+ ),
404
+ },
405
+ {
406
+ path: ['auto', 'maxBatchesPerDay'],
407
+ type: 'number',
408
+ min: 0,
409
+ max: 200,
410
+ step: 1,
411
+ unit: t('批', 'batches'),
412
+ label: t('每天最多几批', 'Daily batch cap'),
413
+ hint: t(
414
+ '一天最多自动蒸几批,0 = 不限(默认 6)。这是唯一给自动花钱设上限的旋钮。',
415
+ 'Most automatic batches in one calendar day; 0 lifts the cap (default 6). The only knob that caps what automatic runs can spend.',
416
+ ),
417
+ },
418
+ ],
419
+ },
420
+ {
421
+ id: 'update',
422
+ title: t('版本与更新', 'Version & updates'),
423
+ hint: t(
424
+ '检查更新只发一次 GET,问的是 npm 上有没有新版本,不带任何记忆库内容或用户信息。安装走宿主自己的 pluginManager,和 dsh plugin 是同一条路。',
425
+ 'A check is one GET against the registry — no vault contents and no user data in it. Installing goes through the host\'s own pluginManager, the same path dsh plugin takes.',
426
+ ),
427
+ fields: [
428
+ {
429
+ path: ['update', 'enabled'],
430
+ type: 'boolean',
431
+ label: t('检查更新', 'Check for updates'),
432
+ hint: t(
433
+ '定期问 npm 上有没有新版本,只查不装。关掉之后不再联网查版本,页面也不会自动查,「检查更新」按钮一起变灰。',
434
+ 'Periodically asks npm whether a newer version exists, and only asks. Off means no network checks and no automatic read on this page; the Check button greys out with it.',
435
+ ),
436
+ },
437
+ {
438
+ path: ['update', 'checkEveryHours'],
439
+ type: 'number',
440
+ min: 1,
441
+ max: 720,
442
+ step: 1,
443
+ unit: t('小时', 'hours'),
444
+ label: t('多久查一次', 'Check every'),
445
+ hint: t(
446
+ '查到的结果保持这么久,期间打开设置页不再重复请求。',
447
+ 'A result stays good for this long, so reopening this page does not ask again.',
448
+ ),
449
+ visible: (value) => getPath(value, ['update', 'enabled']) !== false,
450
+ },
451
+ {
452
+ path: ['update', 'autoInstall'],
453
+ type: 'boolean',
454
+ label: t('自动安装新版本', 'Install updates automatically'),
455
+ hint: t(
456
+ '启动时发现有新版本就装(装完要重启 Harness 才生效);link: 本地安装会拒绝而不是覆盖。',
457
+ 'Installs a newer version at startup; the running process still needs a restart. A link: install is refused rather than overwritten.',
458
+ ),
459
+ visible: (value) => getPath(value, ['update', 'enabled']) !== false,
460
+ },
461
+ {
462
+ path: ['update', 'registry'],
463
+ type: 'text',
464
+ placeholder: t('留空 = 宿主的源', 'empty = the harness registry'),
465
+ label: t('用哪个 npm 源', 'Registry'),
466
+ hint: t(
467
+ '留空就先用 Harness 配的源,再退到 registry.npmjs.org。',
468
+ "Empty means the harness's own registry first, then registry.npmjs.org.",
469
+ ),
470
+ visible: (value) => getPath(value, ['update', 'enabled']) !== false,
471
+ },
376
472
  ],
377
473
  },
378
474
  {
@@ -748,6 +844,63 @@ const CSS = `
748
844
  cursor: pointer;
749
845
  }
750
846
  .mg-btn-ghost:disabled { opacity: 0.55; cursor: default; }
847
+ /* The version strip inside the update section: header, then this line, then the
848
+ fields. Not a card and not an mg-* class — it is not a config knob and the
849
+ suite reads mg-card / mg-row / mg-btn to mean knobs and the save bar. */
850
+ .mgu-strip {
851
+ display: flex;
852
+ flex-direction: column;
853
+ gap: 6px;
854
+ padding: 2px 14px 10px;
855
+ border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
856
+ }
857
+ .mgu-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
858
+ .mgu-main { flex: 1 1 auto; min-width: 0; }
859
+ .mgu-label { display: flex; align-items: center; gap: 6px; font-weight: 500; }
860
+ .mgu-hint { color: var(--dsw-alias-label-tertiary, #808080); font-size: 11.5px; margin-top: 2px; }
861
+ .mgu-tag {
862
+ font-size: 10.5px;
863
+ font-weight: 500;
864
+ padding: 1px 6px;
865
+ border-radius: 999px;
866
+ background: var(--dsw-alias-interactive-bg-hover, rgba(59, 110, 240, 0.14));
867
+ color: var(--dsw-alias-brand-primary, #3b6ef0);
868
+ }
869
+ .mgu-tag[data-kind="muted"] {
870
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
871
+ color: var(--dsw-alias-label-secondary, #5a5a5a);
872
+ }
873
+ .mgu-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
874
+ .mgu-check {
875
+ height: 28px;
876
+ padding: 0 12px;
877
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
878
+ border-radius: 7px;
879
+ background: transparent;
880
+ color: inherit;
881
+ font: inherit;
882
+ font-size: 12.5px;
883
+ cursor: pointer;
884
+ }
885
+ .mgu-check:disabled { opacity: 0.55; cursor: default; }
886
+ .mgu-go {
887
+ height: 28px;
888
+ padding: 0 14px;
889
+ border: none;
890
+ border-radius: 7px;
891
+ background: var(--dsw-alias-brand-primary, #3b6ef0);
892
+ color: var(--dsw-alias-label-primary-foreground, #fff);
893
+ font: inherit;
894
+ font-size: 12.5px;
895
+ font-weight: 500;
896
+ cursor: pointer;
897
+ }
898
+ .mgu-go:disabled { opacity: 0.55; cursor: default; }
899
+ .mgu-msg { font-size: 11.5px; }
900
+ .mgu-msg[data-kind="ok"] { color: var(--dsw-alias-state-success-primary, #1a7f37); }
901
+ .mgu-msg[data-kind="error"] { color: var(--dsw-alias-state-error-primary, #c62828); }
902
+ .mgu-msg[data-kind="busy"] { color: var(--dsw-alias-label-tertiary, #808080); }
903
+ .mgu-msg[data-kind="info"] { color: var(--dsw-alias-label-secondary, #5a5a5a); }
751
904
  /* The whole-vault panel: a bar, then the served page filling the rest. */
752
905
  .mgv-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; }
753
906
  .mgv-panel-bar {
@@ -1070,6 +1223,142 @@ function createPage(ctx, React) {
1070
1223
 
1071
1224
  const dirtyCount = ALL_FIELDS.filter((field) => isDirty(field)).length
1072
1225
 
1226
+ // ---- version & updates ------------------------------------------------
1227
+ //
1228
+ // The host owns every decision here: whether a check is possible, what is
1229
+ // installed, whether it may install itself. This page only asks, prints the
1230
+ // answer the host gave, and keeps its own errors local — a settings page that
1231
+ // blanks out because one route is missing or slow is worse than one that says
1232
+ // which route failed.
1233
+
1234
+ const [updateInfo, setUpdateInfo] = useState(null)
1235
+ const [updateError, setUpdateError] = useState('')
1236
+ const [checking, setChecking] = useState(false)
1237
+ const [applying, setApplying] = useState(false)
1238
+ const [updateNote, setUpdateNote] = useState(null)
1239
+ const updateMounted = useRef(true)
1240
+ const updateTimers = useRef([])
1241
+
1242
+ const stopUpdateTimers = () => {
1243
+ for (const timer of updateTimers.current) clearTimeout(timer)
1244
+ updateTimers.current = []
1245
+ }
1246
+
1247
+ /** One GET. `manual` is the 检查更新 button: it asks the host to re-check the registry. */
1248
+ const readUpdate = (manual) => {
1249
+ if (manual === true) setChecking(true)
1250
+ const send = typeof fetch === 'function' ? fetch : null
1251
+ if (!send) {
1252
+ setChecking(false)
1253
+ setUpdateError(
1254
+ '这一页拿不到 fetch,版本只能手动查:npm view @ilaomianyang/dsh-memory-graph version · ' +
1255
+ 'No fetch in this page, so read the version from the shell instead',
1256
+ )
1257
+ return
1258
+ }
1259
+ let pending
1260
+ try {
1261
+ pending = send(apiUrl(manual === true ? `${UPDATE_API}?force=1` : UPDATE_API), {
1262
+ method: 'GET',
1263
+ headers: { accept: 'application/json' },
1264
+ credentials: 'same-origin',
1265
+ cache: 'no-store',
1266
+ })
1267
+ } catch (error) {
1268
+ setChecking(false)
1269
+ setUpdateError(`读不到版本信息:${String((error && error.message) || error)}`)
1270
+ return
1271
+ }
1272
+ Promise.resolve(pending)
1273
+ .then((response) =>
1274
+ response
1275
+ .json()
1276
+ .catch(() => null)
1277
+ .then((body) => ({ response, body })),
1278
+ )
1279
+ .then(({ response, body }) => {
1280
+ if (!updateMounted.current) return
1281
+ setChecking(false)
1282
+ if (!response.ok || !body || body.ok !== true) {
1283
+ const why = (body && (body.error || body.message)) || `HTTP ${response.status}`
1284
+ setUpdateError(String(why))
1285
+ return
1286
+ }
1287
+ setUpdateError('')
1288
+ setUpdateInfo(body)
1289
+ })
1290
+ .catch((failure) => {
1291
+ if (!updateMounted.current) return
1292
+ setChecking(false)
1293
+ setUpdateError(
1294
+ `连不上宿主:${String((failure && failure.message) || failure)} · Could not reach the host`,
1295
+ )
1296
+ })
1297
+ }
1298
+
1299
+ /** One POST, then three GETs: the host finishes the install behind the answer. */
1300
+ const applyUpdate = () => {
1301
+ if (applying) return
1302
+ stopUpdateTimers()
1303
+ setApplying(true)
1304
+ setUpdateNote({ kind: 'busy', text: '正在更新,别关这一页… · Updating; keep this page open' })
1305
+ const send = typeof fetch === 'function' ? fetch : null
1306
+ if (!send) {
1307
+ setApplying(false)
1308
+ setUpdateNote({ kind: 'error', text: '这一页拿不到 fetch,更新只能手动做 · No fetch here; update by hand' })
1309
+ return
1310
+ }
1311
+ let pending
1312
+ try {
1313
+ pending = send(apiUrl(UPDATE_API), {
1314
+ method: 'POST',
1315
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1316
+ credentials: 'same-origin',
1317
+ body: '{}',
1318
+ })
1319
+ } catch (error) {
1320
+ setApplying(false)
1321
+ setUpdateNote({ kind: 'error', text: `更新没能执行:${String((error && error.message) || error)}` })
1322
+ return
1323
+ }
1324
+ Promise.resolve(pending)
1325
+ .then((response) =>
1326
+ response
1327
+ .json()
1328
+ .catch(() => null)
1329
+ .then((body) => ({ response, body })),
1330
+ )
1331
+ .then(({ response, body }) => {
1332
+ if (!updateMounted.current) return
1333
+ setApplying(false)
1334
+ setUpdateNote(describeUpdate(response, body))
1335
+ // 2s / 4s / 8s after the answer: the install is written behind it, so the
1336
+ // version line catches up without the user pressing anything.
1337
+ for (const wait of [2000, 4000, 8000]) {
1338
+ updateTimers.current.push(setTimeout(() => readUpdate(false), wait))
1339
+ }
1340
+ })
1341
+ .catch((failure) => {
1342
+ if (!updateMounted.current) return
1343
+ setApplying(false)
1344
+ setUpdateNote({
1345
+ kind: 'error',
1346
+ text: `连不上宿主:${String((failure && failure.message) || failure)} · Could not reach the host`,
1347
+ })
1348
+ })
1349
+ }
1350
+
1351
+ useEffect(() => {
1352
+ updateMounted.current = true
1353
+ readUpdate(false)
1354
+ return () => {
1355
+ updateMounted.current = false
1356
+ stopUpdateTimers()
1357
+ }
1358
+ // Read once when the page opens; every later read is a button or a poll.
1359
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1360
+ }, [])
1361
+
1073
1362
  // ---- states that are not the form ------------------------------------
1074
1363
 
1075
1364
  const statePanel = (title, lines) =>
@@ -1224,6 +1513,102 @@ function createPage(ctx, React) {
1224
1513
 
1225
1514
  const visibleFields = (section) => section.fields.filter((field) => !field.visible || field.visible(shownValue))
1226
1515
 
1516
+ // ---- the version strip ------------------------------------------------
1517
+ //
1518
+ // Drawn inside the `update` section, between its header and its rows: the
1519
+ // page already names the section, so this is one line — version, two buttons,
1520
+ // and whatever the host last said. It writes no config path, and the suite
1521
+ // reads `mg-card`/`mg-row`/`mg-btn` to mean "a knob" and "the save bar", so
1522
+ // this keeps its own `mgu-*` names and its own buttons.
1523
+
1524
+ const updateBlock = () => {
1525
+ const info = updateInfo
1526
+ const link = Boolean(info && info.installKind === 'link')
1527
+ const working = applying || Boolean(info && info.busy === true)
1528
+ // The host already decided both questions: `canCheck` is off when update
1529
+ // checks are disabled in the settings, `canInstall` is on only when there is
1530
+ // a newer version, the install came from the registry, and nothing else is
1531
+ // running. Asking it again here would only invent a second opinion.
1532
+ const canCheck = !info || info.canCheck !== false
1533
+ const canInstall = Boolean(info) && info.canInstall === true
1534
+ const blockedBehind = Boolean(info) && info.behind === true && info.canInstall !== true && !link && !working
1535
+ const versionText = info && info.current ? String(info.current) : checking ? '…' : '—'
1536
+
1537
+ const hints = []
1538
+ if (info) {
1539
+ if (link) {
1540
+ hints.push(
1541
+ '本地 link: 安装,自动更新会覆盖你的开发目录 · installed as a link:, so updating would overwrite your working copy',
1542
+ )
1543
+ } else if (info.installKind === 'registry') {
1544
+ hints.push('从 npm registry 安装 · installed from the npm registry')
1545
+ } else {
1546
+ hints.push('安装方式未知 · install kind unknown')
1547
+ }
1548
+ // "Cannot update itself" is only true when there is something to install
1549
+ // and the host still said no — busy means wait, not unable.
1550
+ if (working) hints.push('正在安装,装完再看这里 · installing now; this line updates when it lands')
1551
+ else if (blockedBehind) hints.push('这个安装不能自动更新 · this installation cannot update itself')
1552
+ if (info.registry) hints.push(`源 registry:${info.registry}`)
1553
+ if (info.checkedAt) hints.push(`上次检查 ${info.checkedAt}`)
1554
+ } else if (updateError === '') {
1555
+ hints.push('正在读版本… · reading the version…')
1556
+ }
1557
+
1558
+ const status = updateError
1559
+ ? { kind: 'error', text: updateError }
1560
+ : updateNote || (info && info.message ? { kind: 'info', text: String(info.message) } : null)
1561
+
1562
+ return h(
1563
+ 'div',
1564
+ { className: 'mgu-strip', 'data-update': updateError ? 'error' : info ? 'ready' : 'reading' },
1565
+ h(
1566
+ 'div',
1567
+ { className: 'mgu-line' },
1568
+ h(
1569
+ 'div',
1570
+ { className: 'mgu-main' },
1571
+ h(
1572
+ 'div',
1573
+ { className: 'mgu-label' },
1574
+ h('span', null, '当前版本 · Current version'),
1575
+ info && info.behind === true ? h('span', { className: 'mgu-tag' }, '有新版本 · behind') : null,
1576
+ info && info.behind === false
1577
+ ? h('span', { className: 'mgu-tag', 'data-kind': 'muted' }, '已是最新 · up to date')
1578
+ : null,
1579
+ ),
1580
+ hints.length > 0 ? h('div', { className: 'mgu-hint' }, hints.join(' · ')) : null,
1581
+ ),
1582
+ h(
1583
+ 'div',
1584
+ { className: 'mgu-actions' },
1585
+ h('span', { className: 'mg-code' }, versionText),
1586
+ h(
1587
+ 'button',
1588
+ {
1589
+ type: 'button',
1590
+ className: 'mgu-check',
1591
+ disabled: checking || working || !canCheck,
1592
+ onClick: () => readUpdate(true),
1593
+ },
1594
+ checking ? '检查中… · Checking…' : '检查更新 · Check',
1595
+ ),
1596
+ h(
1597
+ 'button',
1598
+ {
1599
+ type: 'button',
1600
+ className: 'mgu-go',
1601
+ disabled: !canInstall || working || checking || updateError !== '',
1602
+ onClick: () => applyUpdate(),
1603
+ },
1604
+ working ? '更新中… · Updating…' : '更新 · Update',
1605
+ ),
1606
+ ),
1607
+ ),
1608
+ status ? h('div', { className: 'mgu-msg', 'data-kind': status.kind }, status.text) : null,
1609
+ )
1610
+ }
1611
+
1227
1612
  return h(
1228
1613
  'div',
1229
1614
  { className: 'mg-page' },
@@ -1254,6 +1639,10 @@ function createPage(ctx, React) {
1254
1639
  h('div', { className: 'mg-card-t' }, section.title),
1255
1640
  section.hint ? h('div', { className: 'mg-card-d' }, section.hint) : null,
1256
1641
  ),
1642
+ // The version strip belongs to this section's header area, not to the
1643
+ // field table: it is not a config path. It reads and writes nothing the
1644
+ // form owns, so it sits between the header and the rows.
1645
+ section.id === 'update' ? updateBlock() : null,
1257
1646
  h('div', { className: 'mg-rows' }, visibleFields(section).map(row)),
1258
1647
  ),
1259
1648
  ),
@@ -1367,6 +1756,49 @@ function apiUrl(path) {
1367
1756
  return path
1368
1757
  }
1369
1758
 
1759
+ /**
1760
+ * The POST answer as one line, Chinese first and the English after it — the same
1761
+ * style the rest of the plugin writes user-facing text in.
1762
+ *
1763
+ * The four `application` values are the host's contract and they are not
1764
+ * interchangeable: `restart-required` means the files changed but the running
1765
+ * process did not, so the line must ask for a restart instead of claiming the
1766
+ * new version is already live.
1767
+ */
1768
+ function describeUpdate(response, body) {
1769
+ const version = body && body.version ? String(body.version) : ''
1770
+ const at = version === '' ? '' : ` ${version}`
1771
+ if (response && response.ok === true && body && body.ok === true) {
1772
+ const application = String(body.application || '')
1773
+ if (application === 'restart-required') {
1774
+ return {
1775
+ kind: 'ok',
1776
+ text: `已更新到${at},需要重启 Harness 生效 · Updated to${at}; restart DeepSeek Harness to apply it`,
1777
+ }
1778
+ }
1779
+ if (application === 'applied') {
1780
+ return { kind: 'ok', text: `已更新到${at},已生效 · Updated to${at}, live now` }
1781
+ }
1782
+ if (application === 'cancelled') {
1783
+ return { kind: 'error', text: '更新已取消,什么都没改 · Update cancelled; nothing was changed' }
1784
+ }
1785
+ if (application === 'failed') {
1786
+ return {
1787
+ kind: 'error',
1788
+ text: `更新到${at}失败 · Update to${at} failed${body.error ? `:${body.error}` : ''}`,
1789
+ }
1790
+ }
1791
+ const said = body.message || body.error
1792
+ return { kind: 'ok', text: said ? String(said) : `更新已执行${at} · Update ran${at}` }
1793
+ }
1794
+ const said = body && (body.message || body.error)
1795
+ const status = response && response.status ? `HTTP ${response.status}` : ''
1796
+ return {
1797
+ kind: 'error',
1798
+ text: said ? String(said) : `更新没能执行(${status})· The update did not run (${status})`,
1799
+ }
1800
+ }
1801
+
1370
1802
  function fillOf(node) {
1371
1803
  if (node.k === 'session') return '#111318'
1372
1804
  if (node.k === 'episode') return EPISODE_FILL
Binary file
package/index.js CHANGED
@@ -30,6 +30,7 @@ import { createDistillHandler, DISTILL_PATH } from './src/distillapi.js'
30
30
  import { createPageHandler, PAGE_PATH } from './src/pageapi.js'
31
31
  import { createDistillRunner } from './src/distillrun.js'
32
32
  import { createAutoDistiller, registerAutoDistill } from './src/auto.js'
33
+ import { createUpdateChecker, createUpdateHandler, UPDATE_PATH } from './src/update.js'
33
34
  import { extractorServices } from './src/extract.js'
34
35
 
35
36
  export const name = 'memory-graph'
@@ -133,7 +134,7 @@ export function apply(ctx, config) {
133
134
  })
134
135
 
135
136
  ctx.effect(() => {
136
- const disposers = registerTools(ctx, { cfg, vault, redactor, runner })
137
+ const disposers = registerTools(ctx, { cfg, vault, redactor, runner, getUpdate: () => updates.peek() })
137
138
  log('info', `${disposers.length} memory tools registered`)
138
139
  return () => {
139
140
  for (const dispose of disposers) {
@@ -146,6 +147,32 @@ export function apply(ctx, config) {
146
147
  }
147
148
  })
148
149
 
150
+ // Version and updates. `pluginManager` is read opportunistically: a profile
151
+ // without it (a headless composition, a test harness) still reports the version
152
+ // it is running, it just cannot install anything.
153
+ const pluginManager = () => {
154
+ try {
155
+ return ctx.get?.('pluginManager')
156
+ } catch {
157
+ return undefined
158
+ }
159
+ }
160
+ const updates = createUpdateChecker({ cfg, log, getPluginManager: pluginManager })
161
+
162
+ ctx.effect(() => {
163
+ // One check at startup — it is cached for `update.checkEveryHours` after this,
164
+ // so the cost is one request per day, not one per page load. With
165
+ // `autoInstall` on, a newer version is installed here, before any turn runs:
166
+ // the one moment where swapping the plugin cannot interrupt a conversation.
167
+ updates
168
+ .onStart()
169
+ .then((result) => {
170
+ if (result && typeof result.behind === 'boolean' && result.behind) log('info', `有新版本 ${result.latest}(当前 ${result.current})`)
171
+ })
172
+ .catch(() => {})
173
+ return () => {}
174
+ })
175
+
149
176
  // The automatic distiller: a tick that decides, never a timer that fires a run.
150
177
  // Both triggers (a threshold of waiting episodes, and "every N minutes") default
151
178
  // to off and are live Settings fields, so the ticker runs whether or not they
@@ -221,9 +248,21 @@ export function apply(ctx, config) {
221
248
  requestBody: 'buffered',
222
249
  fetch: createPageHandler({ getVault: () => vault }),
223
250
  })
224
- log('info', `session memory view served at ${GRAPH_PATH}, distill at ${DISTILL_PATH}, vault graph page at ${PAGE_PATH}`)
251
+ // Version and updates. GET is a read (and the only method the page needs on
252
+ // load); POST is the one verb that can install a package, so a prefetch or a
253
+ // link preview can never trigger it.
254
+ const disposeUpdate = registry.register({
255
+ path: UPDATE_PATH,
256
+ methods: ['GET', 'POST'],
257
+ requestBody: 'buffered',
258
+ fetch: createUpdateHandler({ getChecker: () => updates }),
259
+ })
260
+ log(
261
+ 'info',
262
+ `session memory view served at ${GRAPH_PATH}, distill at ${DISTILL_PATH}, vault graph page at ${PAGE_PATH}, update at ${UPDATE_PATH}`,
263
+ )
225
264
  return () => {
226
- for (const dispose of [disposeRoute, disposeDistill, disposePage]) {
265
+ for (const dispose of [disposeRoute, disposeDistill, disposePage, disposeUpdate]) {
227
266
  try {
228
267
  dispose?.()
229
268
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilaomianyang/dsh-memory-graph",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "author": "ISKING <admin@isking.cc>",
6
6
  "repository": {
@@ -48,7 +48,7 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node test/smoke.mjs && node test/schema.mjs && node test/client.mjs && node test/capture.mjs && node test/tools.mjs && node test/extract.mjs && node test/graphdata.mjs && node test/page.mjs && node test/distillapi.mjs && node test/auto.mjs && node test/history.mjs && node eval/run.mjs",
51
+ "test": "node test/smoke.mjs && node test/schema.mjs && node test/client.mjs && node test/capture.mjs && node test/tools.mjs && node test/extract.mjs && node test/graphdata.mjs && node test/page.mjs && node test/distillapi.mjs && node test/auto.mjs && node test/history.mjs && node test/update.mjs && node eval/run.mjs",
52
52
  "eval": "node eval/run.mjs",
53
53
  "live": "node test/live.mjs",
54
54
  "live:supersede": "node test/live-supersede.mjs",