@kenz1117/dsh-engram 0.2.0 → 0.4.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 管理面板(设置页「记忆库」tab)
47
+
48
+ 宿主带 webServer 的 profile(web 等)会在**设置页**自动出现「记忆库」tab(经 `settings.section` 槽位注册,client 半为 React 组件、随 `lib/client.js` 由宿主模块表装载):统计卡片、按状态/种类/内容过滤、编辑(走取代链)、遗忘/恢复、导出 Markdown/JSON 下载。数据经回环 API `/api/engram/*`(写操作校验回环 Origin)。headless 等无 webServer 的组合不挂载,其余能力不受影响。
49
+
46
50
  ## 理解实现
47
51
 
48
52
  - **双层分库**:`user.db` 全局共享;`project-<cwd>.db` 按工作目录隔离。
package/lib/client.js ADDED
@@ -0,0 +1,3 @@
1
+ window.__ModuleLoader__.load({id:`@kenz1117/dsh-engram`,factory:e=>{var t={exports:{}},n=t.exports;Object.defineProperty(n,Symbol.toStringTag,{value:`Module`});let r=e("react"),i=e("react/jsx-runtime"),a=`@kenz1117/dsh-engram/panel.module.css`;if(typeof document<`u`&&document.querySelector(`style[data-plugin-css=`+JSON.stringify(a)+`]`)===null){let e=document.createElement(`style`);e.dataset.plugin=`@kenz1117/dsh-engram`,e.dataset.pluginCss=a,e.textContent=`.bWwFiW_panel{max-width:980px}.bWwFiW_toolbar{flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:14px;display:flex}.bWwFiW_scopeTabs{border:1px solid var(--line,#2a2f3a);border-radius:8px;margin-right:auto;display:flex;overflow:hidden}.bWwFiW_scopeOn,.bWwFiW_scopeOff{color:var(--muted,#8b93a3);cursor:pointer;background:0 0;border:none;padding:6px 14px;font-size:13px}.bWwFiW_scopeOn{background:var(--accent,#4f8cff);color:#fff}.bWwFiW_button{background:var(--panel,#1c1f26);color:var(--text,#e6e8ee);border:1px solid var(--line,#2a2f3a);cursor:pointer;border-radius:8px;padding:5px 12px;font-size:13px}.bWwFiW_button:hover{border-color:var(--accent,#4f8cff)}.bWwFiW_button:disabled{opacity:.45;cursor:default}.bWwFiW_primary{background:var(--accent,#4f8cff);border-color:var(--accent,#4f8cff);color:#fff}.bWwFiW_cards{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin-bottom:14px;display:grid}.bWwFiW_card{background:var(--panel,#1c1f26);border:1px solid var(--line,#2a2f3a);text-align:left;cursor:pointer;color:var(--text,#e6e8ee);font:inherit;border-radius:10px;padding:10px 14px}.bWwFiW_card b{font-size:20px;display:block}.bWwFiW_card span{color:var(--muted,#8b93a3);font-size:12px}.bWwFiW_filters{flex-wrap:wrap;gap:8px;margin-bottom:14px;display:flex}.bWwFiW_input{background:var(--panel,#1c1f26);color:var(--text,#e6e8ee);border:1px solid var(--line,#2a2f3a);border-radius:8px;padding:6px 10px;font-size:13px}.bWwFiW_filters .bWwFiW_input{flex:1;min-width:160px}.bWwFiW_item{background:var(--panel,#1c1f26);border:1px solid var(--line,#2a2f3a);border-radius:10px;margin-bottom:10px;padding:12px 14px}.bWwFiW_row1{flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:6px;display:flex}.bWwFiW_badge{border:1px solid var(--line,#2a2f3a);color:var(--muted,#8b93a3);border-radius:99px;padding:1px 8px;font-size:11px}.bWwFiW_active{color:var(--ok,#3fb96f);border-color:var(--ok,#3fb96f)}.bWwFiW_archived{color:var(--warn,#d9a13b);border-color:var(--warn,#d9a13b)}.bWwFiW_forgotten{color:var(--bad,#e05d5d);border-color:var(--bad,#e05d5d)}.bWwFiW_content{white-space:pre-wrap;word-break:break-word}.bWwFiW_meta{color:var(--muted,#8b93a3);flex-wrap:wrap;gap:14px;margin-top:6px;font-size:12px;display:flex}.bWwFiW_ops{gap:8px;margin-top:8px;display:flex}.bWwFiW_pager{color:var(--muted,#8b93a3);justify-content:center;align-items:center;gap:10px;margin-top:14px;display:flex}.bWwFiW_empty{text-align:center;color:var(--muted,#8b93a3);padding:40px 0}.bWwFiW_backdrop{z-index:30;background:#0000008c;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.bWwFiW_dialog{background:var(--panel,#1c1f26);color:var(--text,#e6e8ee);border:1px solid var(--line,#2a2f3a);border-radius:12px;width:92vw;max-width:640px;max-height:80vh;padding:18px;overflow:auto}.bWwFiW_dialog h3{margin:0 0 10px;font-size:15px}.bWwFiW_fieldLabel{color:var(--muted,#8b93a3);margin:10px 0 4px;font-size:12px;display:block}.bWwFiW_modalFoot{justify-content:flex-end;gap:8px;margin-top:12px;display:flex}.bWwFiW_review{background:var(--bg,#14161a);border:1px solid var(--line,#2a2f3a);white-space:pre-wrap;border-radius:8px;max-height:320px;padding:10px;font-size:12px;overflow:auto}`,document.head.appendChild(e)}var o={row1:`bWwFiW_row1`,empty:`bWwFiW_empty`,panel:`bWwFiW_panel`,archived:`bWwFiW_archived`,active:`bWwFiW_active`,cards:`bWwFiW_cards`,forgotten:`bWwFiW_forgotten`,content:`bWwFiW_content`,ops:`bWwFiW_ops`,filters:`bWwFiW_filters`,meta:`bWwFiW_meta`,dialog:`bWwFiW_dialog`,card:`bWwFiW_card`,badge:`bWwFiW_badge`,input:`bWwFiW_input`,item:`bWwFiW_item`,scopeOff:`bWwFiW_scopeOff`,modalFoot:`bWwFiW_modalFoot`,review:`bWwFiW_review`,scopeOn:`bWwFiW_scopeOn`,primary:`bWwFiW_primary`,scopeTabs:`bWwFiW_scopeTabs`,fieldLabel:`bWwFiW_fieldLabel`,pager:`bWwFiW_pager`,toolbar:`bWwFiW_toolbar`,button:`bWwFiW_button`,backdrop:`bWwFiW_backdrop`};let s=[`fact`,`preference`,`decision`,`episode`,`skill`];async function c(e,t){let n=await fetch(`/api/engram/${e}`,t),r=await n.json();if(!n.ok)throw Error(r.error??`HTTP ${String(n.status)}`);return r}function l(e){return new Date(e).toLocaleString(`zh-CN`,{hour12:!1})}function u({recordId:e,scope:t}){let[n,a]=(0,r.useState)(null);return(0,r.useEffect)(()=>{let n=!1;return c(`review?scope=${t}&id=${encodeURIComponent(e)}`).then(e=>{if(n)return;let t=e.record,r=[`内容: ${t.content}`,`属性: kind=${t.kind}, status=${t.status}, importance=${t.importance}, confidence=${t.confidence}, 访问 ${t.accessCount} 次`,`来源: ${t.sourceSessionId??`显式保存`}${t.sourceRound===null?``:` 第${String(t.sourceRound)}轮`}`];for(let t of[`supersededBy`,`supersedes`,`contradicts`,`related`]){let n=e[t];n!==void 0&&n.length>0&&r.push(`${t}: ${n.join(`, `)}`)}let i=e.operations;i!==void 0&&i.length>0&&(r.push(`最近操作:`),i.forEach(e=>r.push(` ${l(e.at)} ${e.op}${e.detail?` ${e.detail}`:``}`))),a(r.join(`
2
+ `))}).catch(e=>{n||a(`加载失败:${e.message}`)}),()=>{n=!0}},[e,t]),n===null?null:(0,i.jsx)(`pre`,{className:o.review,children:n})}function d({record:e,onClose:t,onSaved:n}){let[a,l]=(0,r.useState)(e.content),[u,d]=(0,r.useState)(e.kind),[f,p]=(0,r.useState)(e.importance);return(0,i.jsx)(`div`,{className:o.backdrop,onClick:t,children:(0,i.jsxs)(`div`,{className:o.dialog,onClick:e=>e.stopPropagation(),children:[(0,i.jsx)(`h3`,{children:`编辑记忆(写入取代链)`}),(0,i.jsx)(`label`,{className:o.fieldLabel,children:`内容`}),(0,i.jsx)(`textarea`,{className:o.input,rows:3,value:a,onChange:e=>l(e.target.value)}),(0,i.jsx)(`label`,{className:o.fieldLabel,children:`种类`}),(0,i.jsx)(`select`,{className:o.input,value:u,onChange:e=>d(e.target.value),children:s.map(e=>(0,i.jsx)(`option`,{value:e,children:e},e))}),(0,i.jsxs)(`label`,{className:o.fieldLabel,children:[`重要性 `,f.toFixed(2)]}),(0,i.jsx)(`input`,{type:`range`,min:0,max:1,step:.05,value:f,onChange:e=>p(Number(e.target.value))}),(0,i.jsxs)(`div`,{className:o.modalFoot,children:[(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:t,children:`取消`}),(0,i.jsx)(`button`,{type:`button`,className:`${o.button} ${o.primary}`,onClick:()=>{c(`update`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({id:e.id,scope:e.scope,content:a,kind:u,importance:f})}).then(n).catch(e=>{alert(e.message)})},children:`保存(旧条目归档)`})]})]})})}function f(){let[e,t]=(0,r.useState)(`user`),[n,a]=(0,r.useState)(`all`),[f,p]=(0,r.useState)(`all`),[m,h]=(0,r.useState)(``),[g,_]=(0,r.useState)(0),[v,y]=(0,r.useState)(null),[b,x]=(0,r.useState)(null),[S,C]=(0,r.useState)(null),[w,T]=(0,r.useState)(null),[E,D]=(0,r.useState)(null),[O,k]=(0,r.useState)(0),A=(0,r.useCallback)(()=>{k(e=>e+1)},[]);(0,r.useEffect)(()=>{let t=!1,r=new URLSearchParams({scope:e,status:n,kind:f,limit:`20`,offset:String(g)});return m!==``&&r.set(`q`,m),c(`list?${r.toString()}`).then(e=>{t||(y(e),C(null))}).catch(e=>{t||C(e.message)}),c(`stats`).then(e=>{t||x(e.parts)}).catch(()=>{}),()=>{t=!0}},[e,n,f,m,g,O]);let j=(e,t)=>{c(e,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({id:t.id,scope:t.scope})}).then(A).catch(e=>{alert(e.message)})},M=Math.floor(g/20)+1,N=v===null?1:Math.max(1,Math.ceil(v.total/20));return(0,i.jsxs)(`div`,{className:o.panel,children:[(0,i.jsxs)(`div`,{className:o.toolbar,children:[(0,i.jsx)(`div`,{className:o.scopeTabs,children:[`user`,`project`].map(n=>(0,i.jsx)(`button`,{type:`button`,className:e===n?o.scopeOn:o.scopeOff,onClick:()=>{t(n),_(0)},children:n===`user`?`用户级`:`项目级`},n))}),(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:A,children:`刷新`}),(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:()=>{location.href=`/api/engram/export?scope=${e}&format=markdown`},children:`导出 MD`}),(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:()=>{location.href=`/api/engram/export?scope=${e}&format=json`},children:`导出 JSON`})]}),(0,i.jsx)(`div`,{className:o.cards,children:(b??[]).map(n=>(0,i.jsxs)(`button`,{type:`button`,className:o.card,style:n.scope===e?{borderColor:`var(--accent)`}:void 0,onClick:()=>{t(n.scope),_(0)},children:[(0,i.jsx)(`b`,{children:n.stats.total}),(0,i.jsxs)(`span`,{children:[n.scope,` · active `,n.stats.active,` · 信噪比 `,Math.round(n.stats.signalRatio*100),`%`]})]},n.scope))}),(0,i.jsxs)(`div`,{className:o.filters,children:[(0,i.jsxs)(`select`,{className:o.input,value:n,onChange:e=>{a(e.target.value),_(0)},children:[(0,i.jsx)(`option`,{value:`all`,children:`全部状态`}),(0,i.jsx)(`option`,{value:`active`,children:`active`}),(0,i.jsx)(`option`,{value:`archived`,children:`archived`}),(0,i.jsx)(`option`,{value:`forgotten`,children:`forgotten`})]}),(0,i.jsxs)(`select`,{className:o.input,value:f,onChange:e=>{p(e.target.value),_(0)},children:[(0,i.jsx)(`option`,{value:`all`,children:`全部种类`}),s.map(e=>(0,i.jsx)(`option`,{value:e,children:e},e))]}),(0,i.jsx)(`input`,{className:o.input,placeholder:`按内容搜索…`,value:m,onChange:e=>{h(e.target.value.trim()),_(0)}})]}),S!==null&&(0,i.jsx)(`div`,{className:o.empty,children:`加载失败:${S}`}),S===null&&v!==null&&v.records.length===0&&(0,i.jsx)(`div`,{className:o.empty,children:`没有符合条件的记忆`}),(v?.records??[]).map(e=>(0,i.jsxs)(`div`,{className:o.item,children:[(0,i.jsxs)(`div`,{className:o.row1,children:[(0,i.jsx)(`span`,{className:`${o.badge} ${o[e.status]}`,children:e.status}),(0,i.jsx)(`span`,{className:o.badge,children:e.kind}),(0,i.jsx)(`span`,{className:o.badge,children:e.scope})]}),(0,i.jsx)(`div`,{className:o.content,children:e.content}),(0,i.jsxs)(`div`,{className:o.meta,children:[(0,i.jsxs)(`span`,{children:[`重要性 `,e.importance.toFixed(2)]}),(0,i.jsxs)(`span`,{children:[`置信 `,e.confidence.toFixed(2)]}),(0,i.jsxs)(`span`,{children:[`访问 `,e.accessCount,` 次`]}),(0,i.jsx)(`span`,{children:e.sourceSessionId===null?`显式保存`:`来源 ${e.sourceSessionId.slice(0,16)}…${e.sourceRound===null?``:` 第${String(e.sourceRound)}轮`}`}),(0,i.jsx)(`span`,{children:l(e.createdAt)})]}),(0,i.jsxs)(`div`,{className:o.ops,children:[(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:()=>{D(e)},children:`详情`}),(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:()=>{T(e)},children:`编辑`}),e.status===`active`?(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:()=>{j(`forget`,e)},children:`遗忘`}):(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:()=>{j(`restore`,e)},children:`恢复`})]})]},e.id)),(0,i.jsxs)(`div`,{className:o.pager,children:[(0,i.jsx)(`button`,{type:`button`,className:o.button,disabled:g===0,onClick:()=>{_(Math.max(0,g-20))},children:`上一页`}),(0,i.jsxs)(`span`,{children:[`第 `,M,` / `,N,` 页 · 共 `,v?.total??0,` 条`]}),(0,i.jsx)(`button`,{type:`button`,className:o.button,disabled:v===null||g+20>=v.total,onClick:()=>{_(g+20)},children:`下一页`})]}),w!==null&&(0,i.jsx)(d,{record:w,onClose:()=>{T(null)},onSaved:()=>{T(null),A()}}),E!==null&&(0,i.jsx)(`div`,{className:o.backdrop,onClick:()=>{D(null)},children:(0,i.jsxs)(`div`,{className:o.dialog,onClick:e=>e.stopPropagation(),children:[(0,i.jsx)(`h3`,{children:`记忆审计`}),(0,i.jsx)(u,{recordId:E.id,scope:E.scope}),(0,i.jsx)(`div`,{className:o.modalFoot,children:(0,i.jsx)(`button`,{type:`button`,className:o.button,onClick:()=>{D(null)},children:`关闭`})})]})})]})}let p=[`slots`];function m(e){e.slots.inject(`settings.section`,()=>e.slots.register({name:`settings.section`,id:`engram`,order:20,label:()=>`记忆库`},f))}return n.apply=m,n.inject=p,t.exports}});
3
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","names":["useState","styles","useCallback"],"sources":["../src/client/EngramPanel.tsx","../src/client/index.ts"],"sourcesContent":["/**\n * 记忆库设置面板:统计卡片、过滤列表、编辑(取代链)、遗忘/恢复、导出。\n * 数据经回环 API(/api/engram/*)读写;内容节点一律 DOM API 构建(防 XSS)。\n * @module @kenz1117/dsh-engram/client/EngramPanel\n */\n\nimport { useCallback, useEffect, useState } from 'react'\nimport styles from './panel.module.css'\n\n/** 列表/详情用的记忆视图(宿主 /api/engram/list 的行结构)。 */\ninterface MemoryRow {\n readonly id: string\n readonly scope: 'user' | 'project'\n readonly kind: string\n readonly content: string\n readonly importance: number\n readonly confidence: number\n readonly status: 'active' | 'archived' | 'forgotten'\n readonly createdAt: number\n readonly accessCount: number\n readonly sourceSessionId: string | null\n readonly sourceRound: number | null\n}\n\ninterface ListResult {\n readonly records: MemoryRow[]\n readonly total: number\n}\n\ninterface StatsPart {\n readonly scope: 'user' | 'project'\n readonly stats: {\n readonly total: number\n readonly active: number\n readonly archived: number\n readonly forgotten: number\n readonly signalRatio: number\n }\n}\n\nconst KINDS = ['fact', 'preference', 'decision', 'episode', 'skill'] as const\nconst PAGE_SIZE = 20\n\nasync function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(`/api/engram/${path}`, init)\n const body = (await response.json()) as T & { error?: string }\n if (!response.ok) throw new Error(body.error ?? `HTTP ${String(response.status)}`)\n return body\n}\n\nfunction fmtTime(ms: number): string {\n return new Date(ms).toLocaleString('zh-CN', { hour12: false })\n}\n\n/** 审计弹层视图。 */\nfunction ReviewBody({ recordId, scope }: { recordId: string; scope: 'user' | 'project' }): React.ReactElement | null {\n const [text, setText] = useState<string | null>(null)\n useEffect(() => {\n let cancelled = false\n api<Record<string, unknown>>(`review?scope=${scope}&id=${encodeURIComponent(recordId)}`)\n .then((view) => {\n if (cancelled) return\n const record = view.record as MemoryRow\n const lines = [\n `内容: ${record.content}`,\n `属性: kind=${record.kind}, status=${record.status}, importance=${record.importance}, confidence=${record.confidence}, 访问 ${record.accessCount} 次`,\n `来源: ${record.sourceSessionId ?? '显式保存'}${record.sourceRound === null ? '' : ` 第${String(record.sourceRound)}轮`}`,\n ]\n for (const key of ['supersededBy', 'supersedes', 'contradicts', 'related'] as const) {\n const ids = view[key] as string[] | undefined\n if (ids !== undefined && ids.length > 0) lines.push(`${key}: ${ids.join(', ')}`)\n }\n const operations = view.operations as { at: number; op: string; detail: string | null }[] | undefined\n if (operations !== undefined && operations.length > 0) {\n lines.push('最近操作:')\n operations.forEach(op => lines.push(` ${fmtTime(op.at)} ${op.op}${op.detail ? ` ${op.detail}` : ''}`))\n }\n setText(lines.join('\\n'))\n })\n .catch((error: Error) => { if (!cancelled) setText(`加载失败:${error.message}`) })\n return () => { cancelled = true }\n }, [recordId, scope])\n if (text === null) return null\n return <pre className={styles.review}>{text}</pre>\n}\n\n/** 编辑弹层:内容/种类/重要性 → POST update(旧条目归档,取代链保留)。 */\nfunction EditDialog({ record, onClose, onSaved }: {\n record: MemoryRow\n onClose: () => void\n onSaved: () => void\n}): React.ReactElement {\n const [content, setContent] = useState(record.content)\n const [kind, setKind] = useState(record.kind)\n const [importance, setImportance] = useState(record.importance)\n const save = (): void => {\n api('update', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ id: record.id, scope: record.scope, content, kind, importance }),\n })\n .then(onSaved)\n .catch((error: Error) => { alert(error.message) })\n }\n return (\n <div className={styles.backdrop} onClick={onClose}>\n <div className={styles.dialog} onClick={event => event.stopPropagation()}>\n <h3>编辑记忆(写入取代链)</h3>\n <label className={styles.fieldLabel}>内容</label>\n <textarea className={styles.input} rows={3} value={content} onChange={event => setContent(event.target.value)} />\n <label className={styles.fieldLabel}>种类</label>\n <select className={styles.input} value={kind} onChange={event => setKind(event.target.value)}>\n {KINDS.map(option => <option key={option} value={option}>{option}</option>)}\n </select>\n <label className={styles.fieldLabel}>重要性 {importance.toFixed(2)}</label>\n <input type=\"range\" min={0} max={1} step={0.05} value={importance}\n onChange={event => setImportance(Number(event.target.value))} />\n <div className={styles.modalFoot}>\n <button type=\"button\" className={styles.button} onClick={onClose}>取消</button>\n <button type=\"button\" className={`${styles.button} ${styles.primary}`} onClick={save}>保存(旧条目归档)</button>\n </div>\n </div>\n </div>\n )\n}\n\n/** 设置页「记忆库」section 主组件。 */\nexport function EngramSection(): React.ReactElement {\n const [scope, setScope] = useState<'user' | 'project'>('user')\n const [status, setStatus] = useState('all')\n const [kind, setKind] = useState('all')\n const [q, setQ] = useState('')\n const [offset, setOffset] = useState(0)\n const [list, setList] = useState<ListResult | null>(null)\n const [stats, setStats] = useState<StatsPart[] | null>(null)\n const [error, setError] = useState<string | null>(null)\n const [editing, setEditing] = useState<MemoryRow | null>(null)\n const [reviewId, setReviewId] = useState<MemoryRow | null>(null)\n const [reloadTick, setReloadTick] = useState(0)\n\n const reload = useCallback((): void => { setReloadTick(tick => tick + 1) }, [])\n\n useEffect(() => {\n let cancelled = false\n const qs = new URLSearchParams({\n scope, status, kind, limit: String(PAGE_SIZE), offset: String(offset),\n })\n if (q !== '') qs.set('q', q)\n api<ListResult>(`list?${qs.toString()}`)\n .then((data) => { if (!cancelled) { setList(data); setError(null) } })\n .catch((loadError: Error) => { if (!cancelled) setError(loadError.message) })\n api<{ parts: StatsPart[] }>('stats')\n .then((data) => { if (!cancelled) setStats(data.parts) })\n .catch(() => { /* 统计失败不影响列表 */ })\n return () => { cancelled = true }\n }, [scope, status, kind, q, offset, reloadTick])\n\n const act = (route: string, record: MemoryRow): void => {\n api(route, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ id: record.id, scope: record.scope }),\n })\n .then(reload)\n .catch((actError: Error) => { alert(actError.message) })\n }\n\n const page = Math.floor(offset / PAGE_SIZE) + 1\n const pages = list === null ? 1 : Math.max(1, Math.ceil(list.total / PAGE_SIZE))\n\n return (\n <div className={styles.panel}>\n <div className={styles.toolbar}>\n <div className={styles.scopeTabs}>\n {(['user', 'project'] as const).map(option => (\n <button key={option} type=\"button\"\n className={scope === option ? styles.scopeOn : styles.scopeOff}\n onClick={() => { setScope(option); setOffset(0) }}>\n {option === 'user' ? '用户级' : '项目级'}\n </button>\n ))}\n </div>\n <button type=\"button\" className={styles.button} onClick={reload}>刷新</button>\n <button type=\"button\" className={styles.button}\n onClick={() => { location.href = `/api/engram/export?scope=${scope}&format=markdown` }}>导出 MD</button>\n <button type=\"button\" className={styles.button}\n onClick={() => { location.href = `/api/engram/export?scope=${scope}&format=json` }}>导出 JSON</button>\n </div>\n <div className={styles.cards}>\n {(stats ?? []).map(part => (\n <button key={part.scope} type=\"button\" className={styles.card}\n style={part.scope === scope ? { borderColor: 'var(--accent)' } : undefined}\n onClick={() => { setScope(part.scope); setOffset(0) }}>\n <b>{part.stats.total}</b>\n <span>{part.scope} · active {part.stats.active} · 信噪比 {Math.round(part.stats.signalRatio * 100)}%</span>\n </button>\n ))}\n </div>\n <div className={styles.filters}>\n <select className={styles.input} value={status}\n onChange={event => { setStatus(event.target.value); setOffset(0) }}>\n <option value=\"all\">全部状态</option>\n <option value=\"active\">active</option>\n <option value=\"archived\">archived</option>\n <option value=\"forgotten\">forgotten</option>\n </select>\n <select className={styles.input} value={kind}\n onChange={event => { setKind(event.target.value); setOffset(0) }}>\n <option value=\"all\">全部种类</option>\n {KINDS.map(option => <option key={option} value={option}>{option}</option>)}\n </select>\n <input className={styles.input} placeholder=\"按内容搜索…\" value={q}\n onChange={event => { setQ(event.target.value.trim()); setOffset(0) }} />\n </div>\n {error !== null && <div className={styles.empty}>{`加载失败:${error}`}</div>}\n {error === null && list !== null && list.records.length === 0 && (\n <div className={styles.empty}>没有符合条件的记忆</div>\n )}\n {(list?.records ?? []).map(record => (\n <div key={record.id} className={styles.item}>\n <div className={styles.row1}>\n <span className={`${styles.badge} ${styles[record.status]}`}>{record.status}</span>\n <span className={styles.badge}>{record.kind}</span>\n <span className={styles.badge}>{record.scope}</span>\n </div>\n <div className={styles.content}>{record.content}</div>\n <div className={styles.meta}>\n <span>重要性 {record.importance.toFixed(2)}</span>\n <span>置信 {record.confidence.toFixed(2)}</span>\n <span>访问 {record.accessCount} 次</span>\n <span>{record.sourceSessionId === null\n ? '显式保存'\n : `来源 ${record.sourceSessionId.slice(0, 16)}…${record.sourceRound === null ? '' : ` 第${String(record.sourceRound)}轮`}`}</span>\n <span>{fmtTime(record.createdAt)}</span>\n </div>\n <div className={styles.ops}>\n <button type=\"button\" className={styles.button}\n onClick={() => { setReviewId(record) }}>详情</button>\n <button type=\"button\" className={styles.button}\n onClick={() => { setEditing(record) }}>编辑</button>\n {record.status === 'active'\n ? <button type=\"button\" className={styles.button}\n onClick={() => { act('forget', record) }}>遗忘</button>\n : <button type=\"button\" className={styles.button}\n onClick={() => { act('restore', record) }}>恢复</button>}\n </div>\n </div>\n ))}\n <div className={styles.pager}>\n <button type=\"button\" className={styles.button} disabled={offset === 0}\n onClick={() => { setOffset(Math.max(0, offset - PAGE_SIZE)) }}>上一页</button>\n <span>第 {page} / {pages} 页 · 共 {list?.total ?? 0} 条</span>\n <button type=\"button\" className={styles.button}\n disabled={list === null || offset + PAGE_SIZE >= list.total}\n onClick={() => { setOffset(offset + PAGE_SIZE) }}>下一页</button>\n </div>\n {editing !== null && (\n <EditDialog record={editing} onClose={() => { setEditing(null) }} onSaved={() => { setEditing(null); reload() }} />\n )}\n {reviewId !== null && (\n <div className={styles.backdrop} onClick={() => { setReviewId(null) }}>\n <div className={styles.dialog} onClick={event => event.stopPropagation()}>\n <h3>记忆审计</h3>\n <ReviewBody recordId={reviewId.id} scope={reviewId.scope} />\n <div className={styles.modalFoot}>\n <button type=\"button\" className={styles.button}\n onClick={() => { setReviewId(null) }}>关闭</button>\n </div>\n </div>\n </div>\n )}\n </div>\n )\n}\n","/**\n * dsh-engram client half: 注册设置页「记忆库」section。\n * 数据经回环 API(/api/engram/*)读取与操作,与 host 半的路由对齐。\n * @module @kenz1117/dsh-engram/client\n */\n\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only: ctx.slots(renderer 的 SlotMap 渲染接线)。\nimport type {} from '@deepseek-ai/dsh-client-ui-renderer/client'\n// Type-only: settings.section 槽位的 SlotMap merge(ui-settings 契约)。\nimport type {} from '@deepseek-ai/dsh-client-ui-settings/client'\nimport { EngramSection } from './EngramPanel.tsx'\n\n/** 必需服务:slots 渲染接线。 */\nexport const inject = ['slots']\n\n/**\n * Client plugin body: 设置页注册「记忆库」section(order 20,排在\n * general/models/plugins 之后)。\n * @param ctx - client root context.\n */\nexport function apply(ctx: Context): void {\n ctx.slots.inject('settings.section', () => ctx.slots.register({\n name: 'settings.section',\n id: 'engram',\n order: 20,\n label: () => '记忆库',\n }, EngramSection))\n}\n"],"mappings":"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"}
@@ -374,6 +374,198 @@ async function ingestPreviousTurn(deps) {
374
374
  };
375
375
  }
376
376
  //#endregion
377
+ //#region src/routes.ts
378
+ /** 回环 peer:IPv4 127/8、IPv6 ::1、IPv4-mapped IPv6。 */
379
+ function isLoopbackPeer(req) {
380
+ const address = req.socket.remoteAddress;
381
+ if (address === void 0) return false;
382
+ return address === "::1" || address.startsWith("127.") || address.startsWith("::ffff:127.");
383
+ }
384
+ /** Host 头必须为回环字面量(防 DNS rebinding:拒绝 `127.0.0.1.attacker.com`)。 */
385
+ function isLoopbackHost(req) {
386
+ const host = req.headers.host;
387
+ if (host === void 0 || host === "") return true;
388
+ const name = host.split(":")[0];
389
+ return name === "localhost" || name === "::1" || name !== void 0 && /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
390
+ }
391
+ /** 写操作的 Origin 必须为回环(防跨站表单/fetch)。缺失视为放行,由 Content-Type 兜底。 */
392
+ function isLoopbackOrigin(origin) {
393
+ if (origin === void 0 || origin === "") return true;
394
+ try {
395
+ const host = new URL(origin).hostname;
396
+ return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
397
+ } catch {
398
+ return false;
399
+ }
400
+ }
401
+ /**
402
+ * 回环守卫:仅放行回环 GET/POST 请求(peer + Host 同时校验)。
403
+ * @returns 是否放行;false = 已拒绝并结束响应。
404
+ */
405
+ function guardLoopback(req, res) {
406
+ if (!(req.method === "GET" || req.method === "POST") || !isLoopbackPeer(req) || !isLoopbackHost(req)) {
407
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
408
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
409
+ return false;
410
+ }
411
+ return true;
412
+ }
413
+ /** 写操作守卫:Origin 回环 + Content-Type JSON。 */
414
+ function guardWrite(req, res) {
415
+ if (!isLoopbackOrigin(req.headers.origin)) {
416
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
417
+ res.end(JSON.stringify({ error: "forbidden: loopback only" }));
418
+ return false;
419
+ }
420
+ if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
421
+ res.writeHead(415, { "content-type": "application/json; charset=utf-8" });
422
+ res.end(JSON.stringify({ error: "unsupported content-type" }));
423
+ return false;
424
+ }
425
+ return true;
426
+ }
427
+ /** 读取并解析 JSON body(上限 64 KiB,防坏/恶意 body 拖住 handler)。 */
428
+ async function readJsonBody(req) {
429
+ let body = "";
430
+ for await (const chunk of req) {
431
+ body += String(chunk);
432
+ if (body.length > 65536) return null;
433
+ }
434
+ try {
435
+ const parsed = JSON.parse(body === "" ? "{}" : body);
436
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+ function json(res, status, body) {
442
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
443
+ res.end(JSON.stringify(body));
444
+ }
445
+ /** 从 URL searchParams 收敛 scope(缺省 user)。 */
446
+ function scopeOf$1(raw, fallback) {
447
+ return raw === "user" || raw === "project" ? raw : fallback;
448
+ }
449
+ /**
450
+ * 注册 /engram 页面与 /api/engram/* 接口(effect 由调用方持有,disposer 可逆)。
451
+ * @param ctx - 携带 webServer 服务的宿主上下文。
452
+ */
453
+ function registerEngramRoutes(ctx, deps) {
454
+ ctx.effect(() => ctx.webServer.register({
455
+ kind: "prefix",
456
+ path: "/api/engram",
457
+ handler: async (req, res) => {
458
+ if (!guardLoopback(req, res)) return;
459
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
460
+ const route = url.pathname.slice(12);
461
+ try {
462
+ if (req.method === "GET" && route === "stats") {
463
+ json(res, 200, { parts: await Promise.all(["user", "project"].map(async (scope) => ({
464
+ scope,
465
+ stats: await (await deps.openStore(scope)).stats()
466
+ }))) });
467
+ return;
468
+ }
469
+ if (req.method === "GET" && route === "list") {
470
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
471
+ const status = url.searchParams.get("status");
472
+ const kind = url.searchParams.get("kind");
473
+ const q = url.searchParams.get("q");
474
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
475
+ const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
476
+ const filter = {
477
+ scope,
478
+ ...status !== null && status !== "" && status !== "all" ? { status } : {},
479
+ ...kind !== null && kind !== "" && kind !== "all" ? { kind } : {},
480
+ ...q !== null && q !== "" ? { q } : {},
481
+ limit,
482
+ offset
483
+ };
484
+ json(res, 200, await (await deps.openStore(scope)).list(filter));
485
+ return;
486
+ }
487
+ if (req.method === "GET" && route === "review") {
488
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
489
+ const id = url.searchParams.get("id");
490
+ if (id === null || id === "") {
491
+ json(res, 400, { error: "id required" });
492
+ return;
493
+ }
494
+ const view = await (await deps.openStore(scope)).review(id);
495
+ view === void 0 || view.operations;
496
+ if (view === void 0) {
497
+ json(res, 404, { error: `未找到条目 ${id}` });
498
+ return;
499
+ }
500
+ json(res, 200, view);
501
+ return;
502
+ }
503
+ if (req.method === "GET" && route === "export") {
504
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
505
+ const format = url.searchParams.get("format") === "json" ? "json" : "markdown";
506
+ const data = await (await deps.openStore(scope)).exportAll();
507
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
508
+ const body = format === "json" ? JSON.stringify(data, null, 2) : [
509
+ `# dsh-engram 导出(${scope})`,
510
+ "",
511
+ ...data.records.map((record) => `- [${record.status}/${record.kind}] ${record.content}(id=${record.id},importance ${record.importance})`),
512
+ "",
513
+ "## 关系边",
514
+ ...data.edges.map((edge) => `- ${edge.from} --${edge.type}--> ${edge.to}`),
515
+ ""
516
+ ].join("\n");
517
+ res.writeHead(200, {
518
+ "content-type": format === "json" ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8",
519
+ "content-disposition": `attachment; filename="engram-${scope}-${stamp}.${format}"`
520
+ });
521
+ res.end(body);
522
+ return;
523
+ }
524
+ if (req.method === "POST" && (route === "update" || route === "forget" || route === "restore")) {
525
+ if (!guardWrite(req, res)) return;
526
+ const body = await readJsonBody(req);
527
+ if (body === null || typeof body.id !== "string" || body.id === "") {
528
+ json(res, 400, { error: "id required" });
529
+ return;
530
+ }
531
+ const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
532
+ const store = await deps.openStore(scope);
533
+ if (route === "update") {
534
+ if (typeof body.content !== "string" || body.content.trim() === "") {
535
+ json(res, 400, { error: "content required" });
536
+ return;
537
+ }
538
+ const old = await store.get(body.id);
539
+ if (old === void 0) {
540
+ json(res, 404, { error: `未找到条目 ${body.id}` });
541
+ return;
542
+ }
543
+ json(res, 200, { record: await store.update({
544
+ id: body.id,
545
+ scope,
546
+ kind: typeof body.kind === "string" && [
547
+ "fact",
548
+ "preference",
549
+ "decision",
550
+ "episode",
551
+ "skill"
552
+ ].includes(body.kind) ? body.kind : old.kind,
553
+ content: body.content,
554
+ ...typeof body.importance === "number" && Number.isFinite(body.importance) ? { importance: Math.min(1, Math.max(0, body.importance)) } : {}
555
+ }) });
556
+ return;
557
+ }
558
+ json(res, 200, { record: route === "forget" ? await store.forget(body.id) : await store.restore(body.id) });
559
+ return;
560
+ }
561
+ json(res, 404, { error: "unknown route" });
562
+ } catch (error) {
563
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
564
+ }
565
+ }
566
+ }), "dsh-engram: api routes");
567
+ }
568
+ //#endregion
377
569
  //#region src/store/sqlite.ts
378
570
  /**
379
571
  * EngramStore 的 node:sqlite 实现:节点表 + 边表 + FTS5(unicode61 + 中文 2-gram 预切词)
@@ -703,6 +895,28 @@ async function openEngramStore(path) {
703
895
  async topActive(scope, n) {
704
896
  return sqlTopActive.all(scope, n).map(rowToRecord);
705
897
  },
898
+ async list(filter) {
899
+ const conds = ["scope = ?"];
900
+ const params = [filter.scope];
901
+ if (filter.status !== void 0) {
902
+ conds.push("status = ?");
903
+ params.push(filter.status);
904
+ }
905
+ if (filter.kind !== void 0) {
906
+ conds.push("kind = ?");
907
+ params.push(filter.kind);
908
+ }
909
+ if (filter.q !== void 0 && filter.q !== "") {
910
+ conds.push("instr(content, ?) > 0");
911
+ params.push(filter.q);
912
+ }
913
+ const where = conds.join(" AND ");
914
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE ${where}`).get(...params).n;
915
+ return {
916
+ records: db.prepare(`SELECT * FROM nodes WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset).map(rowToRecord),
917
+ total
918
+ };
919
+ },
706
920
  async review(id) {
707
921
  const row = getRow(id);
708
922
  if (row === void 0) return void 0;
@@ -1549,6 +1763,12 @@ function apply(ctx, config = {}) {
1549
1763
  routeOverride: resolved.routeOverride,
1550
1764
  exportDir: `${resolved.dbDir}/exports`
1551
1765
  })) ctx.tools.register(tool);
1766
+ ctx.inject(["webServer"], (webCtx) => {
1767
+ registerEngramRoutes(webCtx, {
1768
+ openStore,
1769
+ exportDir: `${resolved.dbDir}/exports`
1770
+ });
1771
+ });
1552
1772
  if (resolved.injectProfile || resolved.ingest !== "off") ctx.on("agent/pre-step", (payload, next) => preStep(ctx, openStore, resolved, embedder, payload, next), { prepend: true });
1553
1773
  const runDecay = async () => {
1554
1774
  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.4.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -10,12 +10,13 @@
10
10
  "url": "git+https://github.com/kenz1117/dsh-engram.git"
11
11
  },
12
12
  "type": "module",
13
- "main": "./lib/index.mjs",
14
- "types": "./lib/index.d.mts",
13
+ "main": "./lib/index.js",
15
14
  "exports": {
16
15
  ".": {
17
- "types": "./lib/index.d.mts",
18
- "default": "./lib/index.mjs"
16
+ "default": "./lib/index.js"
17
+ },
18
+ "./client": {
19
+ "default": "./lib/client.js"
19
20
  },
20
21
  "./src/*": "./src/*",
21
22
  "./package.json": "./package.json"
@@ -28,6 +29,10 @@
28
29
  "dsh": {
29
30
  "bundle": {
30
31
  "patch": "./cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "inject": [],
35
+ "platform": "web"
31
36
  }
32
37
  },
33
38
  "scripts": {
@@ -54,6 +59,9 @@
54
59
  },
55
60
  "devDependencies": {
56
61
  "@types/node": "^26.4.0",
62
+ "@types/react": "^18.3.12",
63
+ "@types/react-dom": "^18.3.1",
64
+ "lightningcss": "^1.32.0",
57
65
  "tsdown": "^0.22.14",
58
66
  "typescript": "^5.9.3",
59
67
  "vitest": "^4.1.11"
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Vendored tsdown client-bundle factory for OUT-OF-TREE (fork) UI plugins.
3
+ *
4
+ * The DSH monorepo publishes this shared preset at packages/client/tsdown.client.ts
5
+ * and expects in-tree packages to import it via a relative path. A standalone fork
6
+ * (or a fork cloned into packages/community/<name>) cannot resolve that shared file,
7
+ * so per the cookbook contract ("That preset is not published today, so a package
8
+ * outside this repository has to reproduce the same output format itself") this
9
+ * repository carries its own copy. Keep it in sync with the monorepo factory when
10
+ * the platform seed changes.
11
+ *
12
+ * Emits a closure-factory artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
13
+ * and resolves externals through the injected require (loader module table —
14
+ * cordis DI entities, no globals, no import map). CSS Modules are compiled by
15
+ * lightningcss inside the bundle: importing `x.module.css` yields the
16
+ * hashed class map, and the css text auto-injects a <style data-plugin="<id>">
17
+ * tag at factory execution (the loader removes plugin-owned tags on unload).
18
+ * The virtual loader registers each real stylesheet as a watch dependency.
19
+ */
20
+ import { readFile } from 'node:fs/promises'
21
+ import { existsSync } from 'node:fs'
22
+ import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import type { UserConfig } from 'tsdown'
25
+ import { transform } from 'lightningcss'
26
+
27
+ /**
28
+ * The host's eight-entry platform seed, copied verbatim from packages/client/web/src/platform.ts.
29
+ * These are the specifiers the loader module table answers at runtime, so the browser
30
+ * bundle keeps them external (require) instead of inlining. If the monorepo seed grows,
31
+ * mirror it here.
32
+ */
33
+ const PLATFORM_MODULES = [
34
+ 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis',
35
+ '@deepseek-ai/dsh-client-store',
36
+ '@deepseek-ai/dsh-client-ui-slots',
37
+ '@deepseek-ai/dsh-client-ui-primitives',
38
+ ] as const
39
+
40
+ /**
41
+ * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
42
+ * (which requires @tsdown/css). The suffix matters: tsdown's guard matches ids
43
+ * ending in `.css`, so the virtual id must not.
44
+ */
45
+ const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
46
+ const CSS_VIRTUAL_SUFFIX = '.mjs'
47
+
48
+ /**
49
+ * Wire/type layers a client bundle may inline: browser-safe contracts
50
+ * with no runtime identity to share (no Symbol/instanceof/singleton state).
51
+ * Everything else under @deepseek-ai/* is either a module-table entry
52
+ * (external) or a leak the purity gate rejects.
53
+ */
54
+ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
55
+
56
+ /**
57
+ * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
58
+ * would read them as plugin packages. They carry no cross-plugin runtime
59
+ * identity to share — the framework itself is a platform module (external),
60
+ * while these are ordinary libraries a browser bundle inlines.
61
+ */
62
+ const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
63
+
64
+ /** Generated descriptor/codec contribution with no shared runtime identity. */
65
+ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
66
+
67
+ /**
68
+ * Workspace mode replaces an empty config array with the root defaults. A
69
+ * falsey entry instead removes this package before entry resolution.
70
+ */
71
+ const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
72
+
73
+ /** Externals resolved from the loader module table: the platform seed entries. */
74
+ export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES]
75
+
76
+ // Repository root for sourcemap rebasing: this file lives at <repo>/scripts, so
77
+ // one level up is the fork root (the monorepo original walks up two levels
78
+ // because it sits at packages/client/).
79
+ const REPOSITORY_ROOT = fileURLToPath(new URL('..', import.meta.url))
80
+
81
+ /** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */
82
+ function browserSourcePath(source: string, sourcemapPath: string): string {
83
+ if (!source.startsWith('.')) return source
84
+ const physicalSource = resolvePath(dirname(sourcemapPath), source)
85
+ const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
86
+ return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
87
+ }
88
+
89
+ /**
90
+ * Build the tsdown config for one UI plugin package: the node-half lib build
91
+ * plus the browser client bundle. Client packages emit both halves during the
92
+ * Client pass by default; packages needed for Host reflection may opt into the
93
+ * earlier Host pass. A package-level tsdown.config.ts REPLACES the root
94
+ * workspace layout, so the lib half must be restated here — dropping it leaves
95
+ * the package without lib/index.js and the host Loader cannot import its node
96
+ * half.
97
+ * @param id - plugin id (package name), stamped into the __ModuleLoader__.load
98
+ * handoff and onto the injected style tags.
99
+ * @param libEntry - node-half entries, spelled at the call site so the
100
+ * package-invariants gate can see `lib/types/invariant.js` in each package's
101
+ * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
102
+ * @param options - phase placement, lib overrides, and companion Node configs.
103
+ * @returns ENV-selected tsdown config for the current build face.
104
+ */
105
+ export function clientBundle(
106
+ id: string,
107
+ libEntry: readonly string[],
108
+ options: ClientBundleOptions = {},
109
+ ): BuildFaceConfig {
110
+ const lib = clientLibraryConfig(id, libEntry, options.lib)
111
+ return ({ env }) => {
112
+ const face = buildFace(env?.DSH_BUILD_FACE)
113
+ const client = clientConfig(id, face === undefined
114
+ ? 'src/client/index.ts'
115
+ : 'lib/types/client/index.js')
116
+ const node = [lib, ...(options.companions ?? [])]
117
+ if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
118
+ if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
119
+ return [...node, client]
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Build a Client-only Node library during the Client pass.
125
+ * @param id - Package name used in tsdown diagnostics.
126
+ * @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
127
+ * @returns ENV-selected tsdown config for the Client build face.
128
+ */
129
+ export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
130
+ const lib = clientLibraryConfig(id, libEntry)
131
+ return clientOnly([lib])
132
+ }
133
+
134
+ /**
135
+ * Select arbitrary package-local configs only during the Client pass.
136
+ * @param configs - Node-side configs emitted after Client tsc.
137
+ * @returns ENV-selected tsdown config for the Client build face.
138
+ */
139
+ export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
140
+ return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
141
+ ? [SKIP_WORKSPACE_BUILD]
142
+ : [...configs]
143
+ }
144
+
145
+ interface ClientBundleOptions {
146
+ /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
147
+ readonly hostPhase?: boolean
148
+ /** Additional Node-side configs emitted alongside the package library. */
149
+ readonly companions?: readonly UserConfig[]
150
+ /** Overrides for the package's primary Node-side library config. */
151
+ readonly lib?: UserConfig
152
+ }
153
+
154
+ type BuildFace = 'host' | 'client' | undefined
155
+
156
+ type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
157
+
158
+ function buildFace(value: unknown): BuildFace {
159
+ if (value === undefined || value === 'host' || value === 'client') return value
160
+ throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
161
+ }
162
+
163
+ function clientLibraryConfig(
164
+ id: string,
165
+ libEntry: readonly string[],
166
+ overrides: UserConfig = {},
167
+ ): UserConfig {
168
+ return {
169
+ name: id,
170
+ entry: [...libEntry],
171
+ outDir: 'lib',
172
+ format: ['esm'],
173
+ platform: 'node',
174
+ target: 'es2024',
175
+ fixedExtension: false,
176
+ dts: false,
177
+ clean: false,
178
+ ...overrides,
179
+ }
180
+ }
181
+
182
+ function clientConfig(id: string, entry: string): UserConfig {
183
+ return {
184
+ name: `${id}/client`,
185
+ entry: { client: entry },
186
+ // Browser bundle lands next to the node half (single lib/ artifact dir;
187
+ // the entryFileNames pin keeps it exactly lib/client.js). clean must stay
188
+ // off — a default clean would wipe the node-half output emitted above.
189
+ outDir: 'lib',
190
+ format: 'cjs',
191
+ platform: 'browser',
192
+ // Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
193
+ dts: false,
194
+ // Plugin code is fetched outside Vite's module graph, so its own bundle
195
+ // must carry the TS/TSX mapping consumed by browser profiling tools.
196
+ sourcemap: true,
197
+ // DSH Store 的固定 Commit 审查对运行时文件有 256 KiB 单文件硬上限;
198
+ // 未压缩 bundle 含内联 CSS/双语字典远超此值,浏览器分发面统一压缩。
199
+ // (DSH Store fixed-commit review caps a single runtime file at 256 KiB;
200
+ // the uncompressed bundle inlines CSS and bilingual dictionaries, so ship
201
+ // the browser artifact minified.)
202
+ minify: true,
203
+ clean: false,
204
+ external: [...CLIENT_EXTERNALS],
205
+ // Browser bundles inline node-idiom deps (zustand/immer read
206
+ // process.env.NODE_ENV; zustand's esm build also probes
207
+ // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
208
+ // EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
209
+ // needs the substitutions here or the factory throws ReferenceError at
210
+ // boot / the build gate reds. Both keys honor the build's NODE_ENV so a
211
+ // dev build keeps the dev-branch semantics; artifacts default to production.
212
+ // The bare `import.meta.env` key is required alongside the precise MODE
213
+ // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
214
+ // the truthiness probe would otherwise survive as an empty import.meta.
215
+ define: {
216
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
217
+ 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
218
+ 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
219
+ },
220
+ // tsdown auto-externalizes package dependencies; anything NOT in the
221
+ // loader module table must inline instead (wire/type layers, zod, clsx —
222
+ // every non-shared dep). A require() the table cannot answer is a
223
+ // guaranteed runtime throw, so the rule is the table list itself: no
224
+ // opinion for table entries (external above wins), bundle everything else.
225
+ noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
226
+ plugins: [{
227
+ // Bundle purity gate (build-time mirror of the module-edge rules):
228
+ // platform seed entries stay external, inline-safe wire layers inline,
229
+ // and every other @deepseek-ai value import is a build error — a
230
+ // cross-plugin value import either inlines a duplicate runtime instance
231
+ // or requires a specifier the frozen module table cannot answer.
232
+ // Cross-plugin collaboration goes through cordis services instead.
233
+ name: 'dsh-client-bundle-purity',
234
+ resolveId(source: string) {
235
+ if (!source.startsWith('@deepseek-ai/')) return null
236
+ if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
237
+ if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
238
+ if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
239
+ throw new Error(
240
+ `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
241
+ + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
242
+ )
243
+ },
244
+ }, {
245
+ name: 'dsh-css-modules-inline',
246
+ resolveId(source: string, importer: string | undefined) {
247
+ if (!source.endsWith('.module.css')) return null
248
+ const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
249
+ return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
250
+ },
251
+ async load(virtualId: string) {
252
+ if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
253
+ const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
254
+ // The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
255
+ this.addWatchFile(fileId)
256
+ const source = await readFile(fileId)
257
+ const { code, exports: cssExports } = transform({
258
+ filename: fileId,
259
+ code: source,
260
+ cssModules: { pattern: '[hash]_[local]' },
261
+ minify: true,
262
+ })
263
+ const classMap: Record<string, string> = {}
264
+ for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
265
+ // One <style data-plugin> per module file; idempotent under re-evaluation.
266
+ return [
267
+ `const css = ${JSON.stringify(code.toString())};`,
268
+ `const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
269
+ 'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
270
+ ' const tag = document.createElement(\'style\');',
271
+ ` tag.dataset.plugin = ${JSON.stringify(id)};`,
272
+ ' tag.dataset.pluginCss = tagId;',
273
+ ' tag.textContent = css;',
274
+ ' document.head.appendChild(tag);',
275
+ '}',
276
+ `export default ${JSON.stringify(classMap)};`,
277
+ ].join('\n')
278
+ },
279
+ }],
280
+ outputOptions: {
281
+ entryFileNames: 'client.js',
282
+ // The map is served from /plugins/<scoped-package>/client.js.map. The
283
+ // browser resolves its local sources back into URLs that mirror the
284
+ // /packages/<group>/<package>/src directories; sourcesContent keeps them usable
285
+ // without exposing that tree as an HTTP route.
286
+ sourcemapPathTransform: browserSourcePath,
287
+ banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
288
+ footer: 'return module.exports; } });',
289
+ intro: 'var module = { exports: {} }; var exports = module.exports;',
290
+ },
291
+ }
292
+ }
293
+
294
+ /** Resolve an emitted JS asset import against its source-tree counterpart. */
295
+ function sourceAssetPath(source: string, importer: string): string {
296
+ const emitted = resolvePath(dirname(importer), source)
297
+ if (existsSync(emitted)) return emitted
298
+ const marker = `${sep}lib${sep}types${sep}`
299
+ const boundary = emitted.indexOf(marker)
300
+ if (boundary < 0) return emitted
301
+ return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
302
+ }
@@ -28,6 +28,9 @@ 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'],
32
+ ['@deepseek-ai/dsh-client-ui-settings', 'packages/client/ui-settings'],
33
+ ['@deepseek-ai/dsh-client-ui-renderer', 'packages/client/ui-renderer'],
31
34
  ['@deepseek-ai/dsh-llm', 'packages/llm/llm'],
32
35
  ['@deepseek-ai/dsh-agent', 'packages/core/agent'],
33
36
  ['@deepseek-ai/dsh-scope', 'packages/core/scope'],
package/lib/index.d.mts DELETED
@@ -1,49 +0,0 @@
1
- import z from "@deepseek-ai/schemastery";
2
- import { Context } from "@deepseek-ai/cordis";
3
- //#region src/config.d.ts
4
- /** 自动摄取档位:off 关闭;light 只读用户消息(每轮≤2 条);eager 用户+助手消息(每轮≤5 条)。 */
5
- type IngestModeConfig = 'off' | 'light' | 'eager';
6
- /** 插件配置。 */
7
- interface EngramConfig {
8
- /** 两个 SQLite 分库与嵌入模型缓存的根目录;默认 `~/.dsh/engram`。 */
9
- dbDir?: string;
10
- /** 会话开始是否注入用户级画像摘要;默认 true。 */
11
- injectProfile?: boolean;
12
- /** 画像注入的最大条数;默认 8。 */
13
- profileTopN?: number;
14
- /** 嵌入模型缓存目录;默认 `<dbDir>/models`。 */
15
- modelCacheDir?: string;
16
- /** 嵌入模型下载端点;默认 huggingface.co,网络受限环境配镜像(如 https://hf-mirror.com)。 */
17
- hfEndpoint?: string;
18
- /** 自动摄取档位;默认 off(显式开启才写库)。 */
19
- ingest?: IngestModeConfig;
20
- /** 蒸馏/摄取覆盖路由的 provider;必须与 model 成对提供。 */
21
- provider?: string;
22
- /** 蒸馏/摄取覆盖路由的 model;必须与 provider 成对提供。 */
23
- model?: string;
24
- /** 衰减:最近访问超过该天数才可能被归档;默认 30。 */
25
- decayAfterDays?: number;
26
- /** 衰减:importance 低于该值才可能被归档;默认 0.3。 */
27
- decayImportanceBelow?: number;
28
- }
29
- /** Schemastery 校验面(cordis.yml 读取时校验)。 */
30
- declare const Config: z<EngramConfig>;
31
- //#endregion
32
- //#region src/index.d.ts
33
- /** Cordis 插件名(loader 诊断与注入 source 使用)。 */
34
- declare const name = "dsh-engram";
35
- /** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
36
- declare const inject: string[];
37
- /** 会话开始注入的画像渲染:top-N 高重要性 user 记忆一行一条。 */
38
- declare function renderProfile(topN: readonly {
39
- kind: string;
40
- content: string;
41
- }[]): string;
42
- /**
43
- * 插件体:预热分库与嵌入器,注册 9 个工具、画像注入、自动摄取与衰减调度。
44
- * @param ctx - host 上下文。
45
- * @param config - cordis.yml 传入的可选配置;非法值在加载时 loud 失败。
46
- */
47
- declare function apply(ctx: Context, config?: EngramConfig): void;
48
- //#endregion
49
- export { Config, type EngramConfig, apply, inject, name, renderProfile };