@kenz1117/dsh-engram 0.2.0 → 0.3.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/README.md CHANGED
@@ -40,9 +40,13 @@ dsh plugin --profile web add @kenz1117/dsh-engram
40
40
  | `engram_forget` | 遗忘(软删可恢复) |
41
41
  | `engram_review` | 审计单条:来源链、取代链、矛盾、操作日志 |
42
42
  | `engram_stats` | 全库统计与信噪比 |
43
- | `engram_export` | 导出 Markdown/JSON 文件(数据可携带) |
43
+ | `engram_export` | 导出 Markdown / JSON 文件(数据可携带) |
44
44
  | `engram_distill` | 蒸馏:同主题簇合并为高层规律(LLM) |
45
45
 
46
+ ## Web 管理面板
47
+
48
+ 宿主带 webServer 的 profile(web 等)自动挂载 `http://127.0.0.1:3080/engram` 管理页(仅本机回环可访问):统计卡片、按状态/种类/内容过滤、编辑(走取代链)、遗忘/恢复、导出 Markdown/JSON 下载。数据接口在 `/api/engram/*`,写操作校验回环 Origin。headless 等无 webServer 的组合不挂载,其余能力不受影响。
49
+
46
50
  ## 理解实现
47
51
 
48
52
  - **双层分库**:`user.db` 全局共享;`project-<cwd>.db` 按工作目录隔离。
package/lib/index.mjs CHANGED
@@ -374,6 +374,509 @@ async function ingestPreviousTurn(deps) {
374
374
  };
375
375
  }
376
376
  //#endregion
377
+ //#region src/page.ts
378
+ /**
379
+ * /engram 管理页:自包含单文件 HTML(内联 CSS/JS,简体中文 UI)。
380
+ * 页面 JS 全部使用 DOM API 构建用户内容节点(不用 innerHTML 拼接,
381
+ * 防记忆内容 XSS)。@module @kenz1117/dsh-engram/page
382
+ */
383
+ const PAGE_HTML = `<!doctype html>
384
+ <html lang="zh-CN">
385
+ <head>
386
+ <meta charset="utf-8">
387
+ <meta name="viewport" content="width=device-width, initial-scale=1">
388
+ <title>dsh-engram 记忆库</title>
389
+ <style>
390
+ :root { color-scheme: dark; --bg: #14161a; --panel: #1c1f26; --line: #2a2f3a; --text: #e6e8ee; --muted: #8b93a3; --accent: #4f8cff; --ok: #3fb96f; --warn: #d9a13b; --bad: #e05d5d; }
391
+ * { box-sizing: border-box; }
392
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
393
+ header { display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg); z-index: 5; }
394
+ header h1 { font-size: 16px; margin: 0 auto 0 0; }
395
+ .tabs { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
396
+ .tabs button { background: none; border: none; color: var(--muted); padding: 6px 14px; cursor: pointer; font-size: 13px; }
397
+ .tabs button.on { background: var(--accent); color: #fff; }
398
+ button.act { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 13px; }
399
+ button.act:hover { border-color: var(--accent); }
400
+ main { max-width: 980px; margin: 0 auto; padding: 18px 20px 60px; }
401
+ .cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 16px; }
402
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
403
+ .card b { display: block; font-size: 20px; }
404
+ .card span { color: var(--muted); font-size: 12px; }
405
+ .filters { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
406
+ .filters select, .filters input { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px; font-size: 13px; }
407
+ .filters input { flex: 1; min-width: 200px; }
408
+ .item { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; }
409
+ .item .row1 { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; flex-wrap: wrap; }
410
+ .badge { font-size: 11px; padding: 1px 8px; border-radius: 99px; border: 1px solid var(--line); color: var(--muted); }
411
+ .badge.active { color: var(--ok); border-color: var(--ok); }
412
+ .badge.archived { color: var(--warn); border-color: var(--warn); }
413
+ .badge.forgotten { color: var(--bad); border-color: var(--bad); }
414
+ .content { white-space: pre-wrap; word-break: break-word; }
415
+ .meta { color: var(--muted); font-size: 12px; margin-top: 6px; display: flex; gap: 14px; flex-wrap: wrap; }
416
+ .ops { margin-top: 8px; display: flex; gap: 8px; }
417
+ .ops button { font-size: 12px; padding: 3px 10px; }
418
+ .bar { height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; width: 90px; display: inline-block; vertical-align: middle; }
419
+ .bar i { display: block; height: 100%; background: var(--accent); }
420
+ .pager { display: flex; gap: 10px; align-items: center; justify-content: center; margin-top: 14px; color: var(--muted); }
421
+ dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 12px; max-width: 640px; width: 92vw; padding: 18px; }
422
+ dialog::backdrop { background: rgba(0,0,0,.55); }
423
+ dialog h3 { margin: 0 0 10px; font-size: 15px; }
424
+ dialog textarea, dialog input, dialog select { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px; font: inherit; }
425
+ dialog .f { margin-bottom: 10px; }
426
+ dialog label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 4px; }
427
+ dialog pre { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; overflow: auto; font-size: 12px; max-height: 320px; white-space: pre-wrap; }
428
+ .modal-foot { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
429
+ .primary { background: var(--accent); border-color: var(--accent); color: #fff; }
430
+ .empty { text-align: center; color: var(--muted); padding: 40px 0; }
431
+ </style>
432
+ </head>
433
+ <body>
434
+ <header>
435
+ <h1>dsh-engram 记忆库</h1>
436
+ <div class="tabs" id="scopeTabs">
437
+ <button data-scope="user" class="on">用户级</button>
438
+ <button data-scope="project">项目级</button>
439
+ </div>
440
+ <button class="act" id="refresh">刷新</button>
441
+ <button class="act" id="expMd">导出 MD</button>
442
+ <button class="act" id="expJson">导出 JSON</button>
443
+ </header>
444
+ <main>
445
+ <div class="cards" id="cards"></div>
446
+ <div class="filters">
447
+ <select id="fStatus">
448
+ <option value="all">全部状态</option>
449
+ <option value="active">active</option>
450
+ <option value="archived">archived</option>
451
+ <option value="forgotten">forgotten</option>
452
+ </select>
453
+ <select id="fKind">
454
+ <option value="all">全部种类</option>
455
+ <option value="fact">fact</option>
456
+ <option value="preference">preference</option>
457
+ <option value="decision">decision</option>
458
+ <option value="episode">episode</option>
459
+ <option value="skill">skill</option>
460
+ </select>
461
+ <input id="fQ" placeholder="按内容搜索…">
462
+ </div>
463
+ <div id="list"></div>
464
+ <div class="pager">
465
+ <button class="act" id="prev">上一页</button>
466
+ <span id="pageInfo"></span>
467
+ <button class="act" id="next">下一页</button>
468
+ </div>
469
+ </main>
470
+ <dialog id="editDlg">
471
+ <h3>编辑记忆(写入取代链)</h3>
472
+ <div class="f"><label>内容</label><textarea id="eContent" rows="3"></textarea></div>
473
+ <div class="f"><label>种类</label>
474
+ <select id="eKind">
475
+ <option value="fact">fact</option><option value="preference">preference</option>
476
+ <option value="decision">decision</option><option value="episode">episode</option>
477
+ <option value="skill">skill</option>
478
+ </select>
479
+ </div>
480
+ <div class="f"><label>重要性:<span id="eImpV">0.5</span></label>
481
+ <input type="range" id="eImp" min="0" max="1" step="0.05" value="0.5">
482
+ </div>
483
+ <div class="modal-foot">
484
+ <button class="act" id="eCancel">取消</button>
485
+ <button class="act primary" id="eSave">保存(旧条目归档)</button>
486
+ </div>
487
+ </dialog>
488
+ <dialog id="reviewDlg">
489
+ <h3>记忆审计</h3>
490
+ <pre id="rBody"></pre>
491
+ <div class="modal-foot"><button class="act" id="rClose">关闭</button></div>
492
+ </dialog>
493
+ <script>
494
+ (function () {
495
+ 'use strict';
496
+ var state = { scope: 'user', status: 'all', kind: 'all', q: '', limit: 20, offset: 0, total: 0 };
497
+ var $ = function (id) { return document.getElementById(id); };
498
+
499
+ function el(tag, cls, text) {
500
+ var node = document.createElement(tag);
501
+ if (cls) node.className = cls;
502
+ if (text !== undefined) node.textContent = text;
503
+ return node;
504
+ }
505
+ function api(path, opts) {
506
+ return fetch('/api/engram/' + path, opts).then(function (res) {
507
+ return res.json().then(function (body) {
508
+ if (!res.ok) throw new Error(body && body.error ? body.error : ('HTTP ' + res.status));
509
+ return body;
510
+ });
511
+ });
512
+ }
513
+ function fmtTime(ms) { return new Date(ms).toLocaleString('zh-CN', { hour12: false }); }
514
+ function bar(v) {
515
+ var wrap = el('span', 'bar');
516
+ var fill = el('i');
517
+ fill.style.width = Math.round(v * 100) + '%';
518
+ wrap.appendChild(fill);
519
+ return wrap;
520
+ }
521
+ function loadStats() {
522
+ return api('stats').then(function (data) {
523
+ var cards = $('cards');
524
+ cards.textContent = '';
525
+ data.parts.forEach(function (part) {
526
+ var s = part.stats;
527
+ var on = part.scope === state.scope;
528
+ var card = el('div', 'card');
529
+ if (on) card.style.borderColor = 'var(--accent)';
530
+ card.style.cursor = 'pointer';
531
+ card.appendChild(el('b', undefined, String(s.total)));
532
+ card.appendChild(el('span', undefined, part.scope + ' · active ' + s.active + ' · 信噪比 ' + Math.round(s.signalRatio * 100) + '%'));
533
+ card.addEventListener('click', function () { setScope(part.scope); });
534
+ cards.appendChild(card);
535
+ });
536
+ }).catch(function (e) { console.warn(e); });
537
+ }
538
+ function loadList() {
539
+ var qs = 'scope=' + state.scope + '&status=' + state.status + '&kind=' + state.kind
540
+ + '&limit=' + state.limit + '&offset=' + state.offset
541
+ + (state.q ? '&q=' + encodeURIComponent(state.q) : '');
542
+ return api('list?' + qs).then(function (data) {
543
+ state.total = data.total;
544
+ var list = $('list');
545
+ list.textContent = '';
546
+ if (data.records.length === 0) { list.appendChild(el('div', 'empty', '没有符合条件的记忆')); }
547
+ data.records.forEach(function (record) {
548
+ var item = el('div', 'item');
549
+ var row1 = el('div', 'row1');
550
+ row1.appendChild(el('span', 'badge ' + record.status, record.status));
551
+ row1.appendChild(el('span', 'badge', record.kind));
552
+ row1.appendChild(el('span', 'badge', record.scope));
553
+ item.appendChild(row1);
554
+ item.appendChild(el('div', 'content', record.content));
555
+ var meta = el('div', 'meta');
556
+ meta.appendChild(el('span', undefined, '重要性 ' + record.importance.toFixed(2) + ' '));
557
+ meta.appendChild(bar(record.importance));
558
+ meta.appendChild(el('span', undefined, '置信 ' + record.confidence.toFixed(2) + ' '));
559
+ meta.appendChild(bar(record.confidence));
560
+ meta.appendChild(el('span', undefined, '访问 ' + record.accessCount + ' 次'));
561
+ var source = record.sourceSessionId
562
+ ? '来源 ' + record.sourceSessionId.slice(0, 16) + '…' + (record.sourceRound === null ? '' : ' 第' + record.sourceRound + '轮')
563
+ : '显式保存';
564
+ meta.appendChild(el('span', undefined, source));
565
+ meta.appendChild(el('span', undefined, fmtTime(record.createdAt)));
566
+ item.appendChild(meta);
567
+ var ops = el('div', 'ops');
568
+ var reviewBtn = el('button', 'act', '详情');
569
+ reviewBtn.addEventListener('click', function () { openReview(record); });
570
+ var editBtn = el('button', 'act', '编辑');
571
+ editBtn.addEventListener('click', function () { openEdit(record); });
572
+ ops.appendChild(reviewBtn);
573
+ ops.appendChild(editBtn);
574
+ if (record.status === 'active') {
575
+ var forgetBtn = el('button', 'act', '遗忘');
576
+ forgetBtn.addEventListener('click', function () { post('forget', record, '已遗忘(可恢复)'); });
577
+ ops.appendChild(forgetBtn);
578
+ } else {
579
+ var restoreBtn = el('button', 'act', '恢复');
580
+ restoreBtn.addEventListener('click', function () { post('restore', record, '已恢复为 active'); });
581
+ ops.appendChild(restoreBtn);
582
+ }
583
+ item.appendChild(ops);
584
+ list.appendChild(item);
585
+ });
586
+ var page = Math.floor(state.offset / state.limit) + 1;
587
+ var pages = Math.max(1, Math.ceil(state.total / state.limit));
588
+ $('pageInfo').textContent = '第 ' + page + ' / ' + pages + ' 页 · 共 ' + state.total + ' 条';
589
+ $('prev').disabled = state.offset === 0;
590
+ $('next').disabled = state.offset + state.limit >= state.total;
591
+ }).catch(function (e) {
592
+ $('list').textContent = '';
593
+ $('list').appendChild(el('div', 'empty', '加载失败:' + e.message));
594
+ });
595
+ }
596
+ function reload() { loadStats(); loadList(); }
597
+ function setScope(scope) {
598
+ state.scope = scope; state.offset = 0;
599
+ document.querySelectorAll('#scopeTabs button').forEach(function (b) {
600
+ b.classList.toggle('on', b.dataset.scope === scope);
601
+ });
602
+ reload();
603
+ }
604
+ function post(route, record, okText) {
605
+ api(route, {
606
+ method: 'POST',
607
+ headers: { 'content-type': 'application/json' },
608
+ body: JSON.stringify({ id: record.id, scope: record.scope }),
609
+ }).then(function () { reload(); }).catch(function (e) { alert(e.message); });
610
+ }
611
+ function openReview(record) {
612
+ api('review?scope=' + record.scope + '&id=' + encodeURIComponent(record.id)).then(function (view) {
613
+ var r = view.record;
614
+ var lines = [
615
+ '内容: ' + r.content,
616
+ '属性: kind=' + r.kind + ', status=' + r.status + ', importance=' + r.importance + ', confidence=' + r.confidence + ', 访问 ' + r.accessCount + ' 次',
617
+ '来源: ' + (r.sourceSessionId ? r.sourceSessionId + (r.sourceRound === null ? '' : ' 第' + r.sourceRound + '轮') + (r.sourceSeq === null ? '' : ' seq ' + r.sourceSeq) : '显式保存'),
618
+ ];
619
+ if (view.supersededBy.length) lines.push('被谁取代: ' + view.supersededBy.join(', '));
620
+ if (view.supersedes.length) lines.push('取代了谁: ' + view.supersedes.join(', '));
621
+ if (view.contradicts.length) lines.push('矛盾候选: ' + view.contradicts.join(', '));
622
+ if (view.related.length) lines.push('关联: ' + view.related.join(', '));
623
+ if (view.operations.length) {
624
+ lines.push('最近操作:');
625
+ view.operations.forEach(function (op) {
626
+ lines.push(' ' + fmtTime(op.at) + ' ' + op.op + (op.detail ? ' ' + op.detail : ''));
627
+ });
628
+ }
629
+ $('rBody').textContent = lines.join('\\n');
630
+ $('reviewDlg').showModal();
631
+ }).catch(function (e) { alert(e.message); });
632
+ }
633
+ function openEdit(record) {
634
+ $('eContent').value = record.content;
635
+ $('eKind').value = record.kind;
636
+ $('eImp').value = record.importance;
637
+ $('eImpV').textContent = record.importance.toFixed(2);
638
+ $('eSave').onclick = function () {
639
+ api('update', {
640
+ method: 'POST',
641
+ headers: { 'content-type': 'application/json' },
642
+ body: JSON.stringify({
643
+ id: record.id, scope: record.scope,
644
+ content: $('eContent').value, kind: $('eKind').value,
645
+ importance: Number($('eImp').value),
646
+ }),
647
+ }).then(function () { $('editDlg').close(); reload(); })
648
+ .catch(function (e) { alert(e.message); });
649
+ };
650
+ $('editDlg').showModal();
651
+ }
652
+
653
+ document.querySelectorAll('#scopeTabs button').forEach(function (b) {
654
+ b.addEventListener('click', function () { setScope(b.dataset.scope); });
655
+ });
656
+ $('refresh').addEventListener('click', reload);
657
+ $('expMd').addEventListener('click', function () { location.href = '/api/engram/export?scope=' + state.scope + '&format=markdown'; });
658
+ $('expJson').addEventListener('click', function () { location.href = '/api/engram/export?scope=' + state.scope + '&format=json'; });
659
+ $('fStatus').addEventListener('change', function (e) { state.status = e.target.value; state.offset = 0; loadList(); });
660
+ $('fKind').addEventListener('change', function (e) { state.kind = e.target.value; state.offset = 0; loadList(); });
661
+ var qTimer = null;
662
+ $('fQ').addEventListener('input', function (e) {
663
+ clearTimeout(qTimer);
664
+ qTimer = setTimeout(function () { state.q = e.target.value.trim(); state.offset = 0; loadList(); }, 300);
665
+ });
666
+ $('prev').addEventListener('click', function () { state.offset = Math.max(0, state.offset - state.limit); loadList(); });
667
+ $('next').addEventListener('click', function () { state.offset += state.limit; loadList(); });
668
+ $('eImp').addEventListener('input', function (e) { $('eImpV').textContent = Number(e.target.value).toFixed(2); });
669
+ $('eCancel').addEventListener('click', function () { $('editDlg').close(); });
670
+ $('rClose').addEventListener('click', function () { $('reviewDlg').close(); });
671
+
672
+ reload();
673
+ })();
674
+ <\/script>
675
+ </body>
676
+ </html>
677
+ `;
678
+ //#endregion
679
+ //#region src/routes.ts
680
+ /** 回环 peer:IPv4 127/8、IPv6 ::1、IPv4-mapped IPv6。 */
681
+ function isLoopbackPeer(req) {
682
+ const address = req.socket.remoteAddress;
683
+ if (address === void 0) return false;
684
+ return address === "::1" || address.startsWith("127.") || address.startsWith("::ffff:127.");
685
+ }
686
+ /** Host 头必须为回环字面量(防 DNS rebinding:拒绝 `127.0.0.1.attacker.com`)。 */
687
+ function isLoopbackHost(req) {
688
+ const host = req.headers.host;
689
+ if (host === void 0 || host === "") return true;
690
+ const name = host.split(":")[0];
691
+ return name === "localhost" || name === "::1" || name !== void 0 && /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
692
+ }
693
+ /** 写操作的 Origin 必须为回环(防跨站表单/fetch)。缺失视为放行,由 Content-Type 兜底。 */
694
+ function isLoopbackOrigin(origin) {
695
+ if (origin === void 0 || origin === "") return true;
696
+ try {
697
+ const host = new URL(origin).hostname;
698
+ return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
699
+ } catch {
700
+ return false;
701
+ }
702
+ }
703
+ /**
704
+ * 回环守卫:仅放行回环 GET/POST 请求(peer + Host 同时校验)。
705
+ * @returns 是否放行;false = 已拒绝并结束响应。
706
+ */
707
+ function guardLoopback(req, res) {
708
+ if (!(req.method === "GET" || req.method === "POST") || !isLoopbackPeer(req) || !isLoopbackHost(req)) {
709
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
710
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
711
+ return false;
712
+ }
713
+ return true;
714
+ }
715
+ /** 写操作守卫:Origin 回环 + Content-Type JSON。 */
716
+ function guardWrite(req, res) {
717
+ if (!isLoopbackOrigin(req.headers.origin)) {
718
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
719
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
720
+ return false;
721
+ }
722
+ if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
723
+ res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
724
+ res.end(JSON.stringify({ error: "unsupported content-type" }));
725
+ return false;
726
+ }
727
+ return true;
728
+ }
729
+ /** 读取并解析 JSON body(上限 64 KiB,防坏/恶意 body 拖住 handler)。 */
730
+ async function readJsonBody(req) {
731
+ let body = "";
732
+ for await (const chunk of req) {
733
+ body += String(chunk);
734
+ if (body.length > 65536) return null;
735
+ }
736
+ try {
737
+ const parsed = JSON.parse(body === "" ? "{}" : body);
738
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
739
+ } catch {
740
+ return null;
741
+ }
742
+ }
743
+ function json(res, status, body) {
744
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
745
+ res.end(JSON.stringify(body));
746
+ }
747
+ /** 从 URL searchParams 收敛 scope(缺省 user)。 */
748
+ function scopeOf$1(raw, fallback) {
749
+ return raw === "user" || raw === "project" ? raw : fallback;
750
+ }
751
+ /**
752
+ * 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
753
+ * @param ctx - 携带 webServer 服务的宿主上下文。
754
+ */
755
+ function registerEngramRoutes(ctx, deps) {
756
+ ctx.effect(() => ctx.webServer.register({
757
+ kind: "exact",
758
+ path: "/engram",
759
+ handler: async (req, res) => {
760
+ if (!guardLoopback(req, res)) return;
761
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
762
+ res.end(PAGE_HTML);
763
+ }
764
+ }), "dsh-engram: page route");
765
+ ctx.effect(() => ctx.webServer.register({
766
+ kind: "prefix",
767
+ path: "/api/engram",
768
+ handler: async (req, res) => {
769
+ if (!guardLoopback(req, res)) return;
770
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
771
+ const route = url.pathname.slice(12);
772
+ try {
773
+ if (req.method === "GET" && route === "stats") {
774
+ json(res, 200, { parts: await Promise.all(["user", "project"].map(async (scope) => ({
775
+ scope,
776
+ stats: await (await deps.openStore(scope)).stats()
777
+ }))) });
778
+ return;
779
+ }
780
+ if (req.method === "GET" && route === "list") {
781
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
782
+ const status = url.searchParams.get("status");
783
+ const kind = url.searchParams.get("kind");
784
+ const q = url.searchParams.get("q");
785
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
786
+ const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
787
+ const filter = {
788
+ scope,
789
+ ...status !== null && status !== "" && status !== "all" ? { status } : {},
790
+ ...kind !== null && kind !== "" && kind !== "all" ? { kind } : {},
791
+ ...q !== null && q !== "" ? { q } : {},
792
+ limit,
793
+ offset
794
+ };
795
+ json(res, 200, await (await deps.openStore(scope)).list(filter));
796
+ return;
797
+ }
798
+ if (req.method === "GET" && route === "review") {
799
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
800
+ const id = url.searchParams.get("id");
801
+ if (id === null || id === "") {
802
+ json(res, 400, { error: "id required" });
803
+ return;
804
+ }
805
+ const view = await (await deps.openStore(scope)).review(id);
806
+ view === void 0 || view.operations;
807
+ if (view === void 0) {
808
+ json(res, 404, { error: `未找到条目 ${id}` });
809
+ return;
810
+ }
811
+ json(res, 200, view);
812
+ return;
813
+ }
814
+ if (req.method === "GET" && route === "export") {
815
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
816
+ const format = url.searchParams.get("format") === "json" ? "json" : "markdown";
817
+ const data = await (await deps.openStore(scope)).exportAll();
818
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
819
+ const body = format === "json" ? JSON.stringify(data, null, 2) : [
820
+ `# dsh-engram 导出(${scope})`,
821
+ "",
822
+ ...data.records.map((record) => `- [${record.status}/${record.kind}] ${record.content}(id=${record.id},importance ${record.importance})`),
823
+ "",
824
+ "## 关系边",
825
+ ...data.edges.map((edge) => `- ${edge.from} --${edge.type}--> ${edge.to}`),
826
+ ""
827
+ ].join("\n");
828
+ res.writeHead(200, {
829
+ "content-type": format === "json" ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8",
830
+ "content-disposition": `attachment; filename="engram-${scope}-${stamp}.${format}"`
831
+ });
832
+ res.end(body);
833
+ return;
834
+ }
835
+ if (req.method === "POST" && (route === "update" || route === "forget" || route === "restore")) {
836
+ if (!guardWrite(req, res)) return;
837
+ const body = await readJsonBody(req);
838
+ if (body === null || typeof body.id !== "string" || body.id === "") {
839
+ json(res, 400, { error: "id required" });
840
+ return;
841
+ }
842
+ const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
843
+ const store = await deps.openStore(scope);
844
+ if (route === "update") {
845
+ if (typeof body.content !== "string" || body.content.trim() === "") {
846
+ json(res, 400, { error: "content required" });
847
+ return;
848
+ }
849
+ const old = await store.get(body.id);
850
+ if (old === void 0) {
851
+ json(res, 404, { error: `未找到条目 ${body.id}` });
852
+ return;
853
+ }
854
+ json(res, 200, { record: await store.update({
855
+ id: body.id,
856
+ scope,
857
+ kind: typeof body.kind === "string" && [
858
+ "fact",
859
+ "preference",
860
+ "decision",
861
+ "episode",
862
+ "skill"
863
+ ].includes(body.kind) ? body.kind : old.kind,
864
+ content: body.content,
865
+ ...typeof body.importance === "number" && Number.isFinite(body.importance) ? { importance: Math.min(1, Math.max(0, body.importance)) } : {}
866
+ }) });
867
+ return;
868
+ }
869
+ json(res, 200, { record: route === "forget" ? await store.forget(body.id) : await store.restore(body.id) });
870
+ return;
871
+ }
872
+ json(res, 404, { error: "unknown route" });
873
+ } catch (error) {
874
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
875
+ }
876
+ }
877
+ }), "dsh-engram: api routes");
878
+ }
879
+ //#endregion
377
880
  //#region src/store/sqlite.ts
378
881
  /**
379
882
  * EngramStore 的 node:sqlite 实现:节点表 + 边表 + FTS5(unicode61 + 中文 2-gram 预切词)
@@ -703,6 +1206,28 @@ async function openEngramStore(path) {
703
1206
  async topActive(scope, n) {
704
1207
  return sqlTopActive.all(scope, n).map(rowToRecord);
705
1208
  },
1209
+ async list(filter) {
1210
+ const conds = ["scope = ?"];
1211
+ const params = [filter.scope];
1212
+ if (filter.status !== void 0) {
1213
+ conds.push("status = ?");
1214
+ params.push(filter.status);
1215
+ }
1216
+ if (filter.kind !== void 0) {
1217
+ conds.push("kind = ?");
1218
+ params.push(filter.kind);
1219
+ }
1220
+ if (filter.q !== void 0 && filter.q !== "") {
1221
+ conds.push("instr(content, ?) > 0");
1222
+ params.push(filter.q);
1223
+ }
1224
+ const where = conds.join(" AND ");
1225
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE ${where}`).get(...params).n;
1226
+ return {
1227
+ records: db.prepare(`SELECT * FROM nodes WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset).map(rowToRecord),
1228
+ total
1229
+ };
1230
+ },
706
1231
  async review(id) {
707
1232
  const row = getRow(id);
708
1233
  if (row === void 0) return void 0;
@@ -1549,6 +2074,12 @@ function apply(ctx, config = {}) {
1549
2074
  routeOverride: resolved.routeOverride,
1550
2075
  exportDir: `${resolved.dbDir}/exports`
1551
2076
  })) ctx.tools.register(tool);
2077
+ ctx.inject(["webServer"], (webCtx) => {
2078
+ registerEngramRoutes(webCtx, {
2079
+ openStore,
2080
+ exportDir: `${resolved.dbDir}/exports`
2081
+ });
2082
+ });
1552
2083
  if (resolved.injectProfile || resolved.ingest !== "off") ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, payload, next), { prepend: true });
1553
2084
  const runDecay = async () => {
1554
2085
  for (const scope of ["user", "project"]) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kenz1117/dsh-engram",
3
3
  "description": "Cross-session long-term memory for DeepSeek Harness: dual-scope SQLite memory graph, hybrid retrieval, provenance audit, and a knowledge flywheel.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -28,6 +28,7 @@ const LINKED_PACKAGES = [
28
28
  ['@deepseek-ai/dsh-tools', 'packages/core/tools'],
29
29
  ['@deepseek-ai/dsh-system-prompt', 'packages/core/system-prompt'],
30
30
  ['@deepseek-ai/dsh-session', 'packages/core/session'],
31
+ ['@deepseek-ai/dsh-host-webserver', 'packages/host/webserver'],
31
32
  ['@deepseek-ai/dsh-llm', 'packages/llm/llm'],
32
33
  ['@deepseek-ai/dsh-agent', 'packages/core/agent'],
33
34
  ['@deepseek-ai/dsh-scope', 'packages/core/scope'],