dsh-memory-ledger 0.0.0-stage → 1.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1
4
+
5
+ ### Fixed
6
+
7
+ - **左侧栏面板在冷启动后显示「读取失败: HTTP 404」。** 三条面板路由原先在 `apply()` 里用
8
+ `ctx.get('webServer')` 探测一次;DSH 全新启动时 `webServer` 还没就绪,取到 `undefined`,
9
+ 路由被静默跳过(所以只有热重载后才正常)。改用 `ctx.inject(['webServer'], cb)`:
10
+ 服务就绪后注册,服务变化时自动重载。无 web server 的 headless profile 仍照常工作。
11
+
12
+ ## 1.0.0
13
+
14
+ - 首次发布。
15
+ - 用户级 / 项目级两级台账,每轮注入提示词段落。
16
+ - 硬约束段(pinned 与 `constraintKeys` 的设定)优先级高于默认署名与风格。
17
+ - `memory_save` / `memory_search` / `memory_forget` 三个工具,混合 BM25 + TF-IDF 向量检索。
18
+ - 重复失败按签名聚类计数,超过阈值进入提示词。
19
+ - 左侧栏「记忆册」面板:检索、逐条编辑、归档前确认、可恢复。
20
+ - 每条记忆带 `reason`(记录原因)与 `origin`(首次来源项目)。
21
+ - 回合结束时的「设定未记录」提醒(规则判断,零额外 token 开销)。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 巴西鲜橙
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README-zh.md ADDED
@@ -0,0 +1,41 @@
1
+ # 记忆册(dsh-memory-ledger)
2
+
3
+ 给 DeepSeek Harness 用的**长期记忆**插件:记住你的稳定设定,和已经解决过的问题。
4
+
5
+ - **用户设定**:身份、笔名/署名、语言、技术栈、格式偏好——每轮自动注入,新会话不用再问。
6
+ - **问题→解法**:验证过的坑和解法记成经验,下次先查再动手,不重复烧 token。
7
+ - **两级作用域**:用户级设定跟人走;经验默认只留在产生它的工作目录,不污染别的项目。
8
+ - **硬约束段**:置顶的设定(或 `pen_name`/`signature` 这类 key)单独成段、排在记忆段之前,声明优先级高于任何默认署名/风格模板。
9
+ - **主动提醒**:一轮结束发现「像设定但没记」,会提醒 agent 补记(规则判断,零 token)。
10
+ - **重复失败预警**:同一错误按签名聚类计数,超过阈值就进提示词。
11
+ - **左侧栏面板**:浏览、混合检索(BM25 + TF-IDF 向量)、逐条编辑、归档前确认、可恢复;每条都标注**记录原因**与**来源项目**。
12
+
13
+ ## 安装
14
+
15
+ 插件市场「发现」里搜「记忆册」,或命令行:
16
+
17
+ ```sh
18
+ dsh plugin add dsh-memory-ledger
19
+ ```
20
+
21
+ ## 数据放在哪
22
+
23
+ 默认 `$DSH_HOME/memory/`:
24
+
25
+ - `memory.json` —— 用户级设定与经验
26
+ - `projects/<目录名>-<hash8>.json` —— 每个工作目录一份
27
+
28
+ 纯 JSON、原子写入、可手改、可备份。**明文存储,不要放密钥。**
29
+
30
+ ## 已知限制
31
+
32
+ - 检索是 BM25 + TF-IDF 稀疏向量(经典向量空间模型),**不是神经语义嵌入**。
33
+ - 注入段落有字数上限(默认 1600 字符)与条数上限,超出部分需用检索取回。
34
+ - 面板的数据接口只做同源检查,不接入 Host 登录闸口。
35
+ - 项目作用域以会话的工作目录为键。
36
+
37
+ 完整说明见 [docs/DETAIL.md](docs/DETAIL.md)。
38
+
39
+ ## License
40
+
41
+ MIT
package/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # dsh-memory-ledger
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Persistent, user-owned memory for DeepSeek Harness, kept in local JSON files and managed from a left-sidebar panel.
4
+
5
+ - **Standing settings** — identity, pen name, signature, language, stack, formatting rules — are injected into every session, so the agent stops re-asking.
6
+ - **Verified lessons** — problem → solution pairs — are injected too, so a solved failure is not re-investigated on the user's token budget.
7
+ - **Two scopes**: a user-wide ledger plus one ledger per working directory. Facts follow the person; lessons stay with the project that produced them, and the injected section tags project entries as 「(本项目)」.
8
+ - **Hybrid retrieval**: BM25 + TF-IDF sparse-vector cosine over ASCII words and CJK character bigrams, with field weighting and stopwords. `keyword` and `vector` modes are selectable.
9
+ - **Repeated failures** are counted mechanically from failed tool results; at the threshold they surface in the prompt even before a lesson exists.
10
+ - **Changes stay in sync**: the section is recomputed on every prompt assembly, and saving a setting under the same `key` overwrites the old value.
11
+ - **Editable from the panel**: every row has an inline editor (title, kind, key, tags, body, reason, pinned) that writes straight back to the ledger and takes effect on the next turn. Archiving asks for confirmation first.
12
+ - **Provenance on every entry**: `reason` (why it was recorded) plus `origin` (the project, session, and time it was first recorded from), both shown in the panel.
13
+ - **Hard constraints get their own section**: pinned facts and keys listed in `constraintKeys` (default `pen_name,signature`) are injected *before* the memory block as imperative requirements, so a style default cannot quietly override a pen name.
14
+ - **Unrecorded-setting reminder**: when a turn ends with a likely unrecorded user setting, one short reminder is injected for the agent to record it — rule-based, so it costs nothing until it fires.
15
+
16
+ Tools: `memory_save`, `memory_search`, `memory_forget`. Slash command: `/memory`.
17
+ Panel: left sidebar entry 「记忆册」 (`sidebar.panellist` + `main`), served by `/dsh-memory/api/v1` routes (`ledger`, `update`, `forget`) with a same-origin gate.
18
+ Files: `$DSH_HOME/memory/memory.json` and `$DSH_HOME/memory/projects/<name>-<hash>.json` (plain JSON, atomic writes, hand-editable).
19
+
20
+ See `docs/DETAIL.md` for the model, configuration, and limitations — including that the vector mode is a classic TF-IDF vector space, not a neural embedding.
package/client.js ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Browser half of `dsh-memory-ledger`.
3
+ *
4
+ * Registers one left-sidebar entry (`sidebar.panellist`) whose central panel
5
+ * (`main`, same id) lists, searches, and prunes the ledger the Host keeps.
6
+ * Data comes from the Host's own gated routes under `/dsh-memory/api/v1`, so
7
+ * this module needs no generated Remote client and no Harness Client package:
8
+ * only React from the page's module table.
9
+ */
10
+
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-memory-ledger',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const h = React.createElement
16
+
17
+ const PANEL_ID = 'dsh-memory'
18
+ const BASE = '/dsh-memory/api/v1'
19
+ const zh = typeof navigator !== 'undefined' && /^zh/i.test(navigator.language || '')
20
+
21
+ const text = zh
22
+ ? {
23
+ label: '记忆册',
24
+ title: '记忆册',
25
+ subtitle: '跨会话设定与已验证经验',
26
+ search: '搜索记忆(关键词或近义描述)',
27
+ mode: '检索方式',
28
+ modes: { hybrid: '混合', keyword: '关键词', vector: '向量' },
29
+ scope: '范围',
30
+ scopes: { auto: '用户 + 本项目', user: '仅用户', project: '仅本项目', all: '全部项目' },
31
+ refresh: '刷新',
32
+ forget: '归档',
33
+ expand: '展开',
34
+ collapse: '收起',
35
+ empty: '还没有记忆条目。让 agent 用 memory_save 记录一条,或在对话里说「记住我是……」',
36
+ loading: '读取中…',
37
+ error: '读取失败',
38
+ facts: '设定',
39
+ lessons: '经验',
40
+ incidents: '待处理预警',
41
+ userLedger: '用户台账',
42
+ projectLedger: '项目台账',
43
+ projects: '个项目',
44
+ archived: '已归档',
45
+ repeats: '次',
46
+ score: '相关度',
47
+ filtered: '筛选结果',
48
+ all: '全部',
49
+ edit: '编辑',
50
+ save: '保存',
51
+ cancel: '取消',
52
+ titleLabel: '标题',
53
+ bodyLabel: '内容',
54
+ keyLabel: '槽位键(同键覆盖)',
55
+ tagsLabel: '标签(逗号分隔)',
56
+ pinnedLabel: '置顶',
57
+ kindLabel: '类型',
58
+ editHint: '保存后立即生效,并写入该条所属的台账文件。',
59
+ saveFailed: '保存失败',
60
+ reasonLabel: '记录原因',
61
+ sourceLabel: '来源项目',
62
+ reasonUnknown: '未注明',
63
+ confirmArchive: '确认归档这条?',
64
+ archiveConfirm: '确认归档',
65
+ archiveCancel: '取消',
66
+ archiveNote: '归档后不再进入提示词,记录仍留在台账文件里。',
67
+ showArchived: '显示已归档',
68
+ restore: '恢复',
69
+ }
70
+ : {
71
+ label: 'Memory',
72
+ title: 'Memory ledger',
73
+ subtitle: 'Standing settings and verified lessons, across sessions',
74
+ search: 'Search memory (keywords or a paraphrased problem)',
75
+ mode: 'Mode',
76
+ modes: { hybrid: 'Hybrid', keyword: 'Keyword', vector: 'Vector' },
77
+ scope: 'Scope',
78
+ scopes: { auto: 'User + this project', user: 'User only', project: 'This project', all: 'All projects' },
79
+ refresh: 'Refresh',
80
+ forget: 'Archive',
81
+ expand: 'Expand',
82
+ collapse: 'Collapse',
83
+ empty: 'No memory entries yet. Ask the agent to remember something, or have it call memory_save.',
84
+ loading: 'Loading…',
85
+ error: 'Could not load',
86
+ facts: 'facts',
87
+ lessons: 'lessons',
88
+ incidents: 'pending warnings',
89
+ userLedger: 'User ledger',
90
+ projectLedger: 'Project ledger',
91
+ projects: 'projects',
92
+ archived: 'archived',
93
+ repeats: 'x',
94
+ score: 'score',
95
+ filtered: 'filtered',
96
+ all: 'All',
97
+ edit: 'Edit',
98
+ save: 'Save',
99
+ cancel: 'Cancel',
100
+ titleLabel: 'Title',
101
+ bodyLabel: 'Content',
102
+ keyLabel: 'Key (same key overwrites)',
103
+ tagsLabel: 'Tags (comma separated)',
104
+ pinnedLabel: 'Pinned',
105
+ kindLabel: 'Kind',
106
+ editHint: 'Saving takes effect immediately and writes the ledger file it belongs to.',
107
+ saveFailed: 'Save failed',
108
+ reasonLabel: 'Why recorded',
109
+ sourceLabel: 'From project',
110
+ reasonUnknown: 'not stated',
111
+ confirmArchive: 'Archive this entry?',
112
+ archiveConfirm: 'Archive',
113
+ archiveCancel: 'Cancel',
114
+ archiveNote: 'Archived entries stop entering the prompt but stay in the ledger file.',
115
+ showArchived: 'Show archived',
116
+ restore: 'Restore',
117
+ }
118
+
119
+ const styles = `
120
+ .dshmem { display:flex; flex-direction:column; height:100%; min-height:0; background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); font-size:13px; }
121
+ .dshmem-head { display:flex; align-items:baseline; gap:10px; padding:16px 20px 12px; border-bottom:1px solid var(--dsw-alias-border-l1); }
122
+ .dshmem-title { font-size:16px; font-weight:600; }
123
+ .dshmem-sub { color:var(--dsw-alias-label-secondary); font-size:12px; flex:1 1 auto; }
124
+ .dshmem-tools { display:flex; gap:8px; padding:10px 20px; border-bottom:1px solid var(--dsw-alias-border-l1); flex-wrap:wrap; align-items:center; }
125
+ .dshmem-input, .dshmem-select { background:var(--dsw-alias-bg-layer-1); color:inherit; border:1px solid var(--dsw-alias-border-l1); border-radius:8px; padding:6px 10px; font:inherit; }
126
+ .dshmem-input { flex:1 1 260px; min-width:180px; }
127
+ .dshmem-input:focus, .dshmem-select:focus { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:-1px; }
128
+ .dshmem-btn { background:var(--dsw-alias-bg-layer-1); color:inherit; border:1px solid var(--dsw-alias-border-l1); border-radius:8px; padding:6px 12px; font:inherit; cursor:pointer; }
129
+ .dshmem-btn:hover { background:var(--dsw-alias-bg-layer-2); }
130
+ .dshmem-btn:disabled { opacity:0.55; cursor:default; }
131
+ .dshmem-body { flex:1 1 auto; min-height:0; overflow:auto; padding:14px 20px 28px; }
132
+ .dshmem-stats { display:flex; gap:14px; flex-wrap:wrap; color:var(--dsw-alias-label-secondary); font-size:12px; padding:10px 20px 0; }
133
+ .dshmem-sect { margin:14px 0 8px; font-size:12px; font-weight:600; letter-spacing:0.02em; color:var(--dsw-alias-label-secondary); text-transform:none; }
134
+ .dshmem-row { border:1px solid var(--dsw-alias-border-l1); border-radius:10px; padding:10px 12px; margin-bottom:8px; background:var(--dsw-alias-bg-layer-1); }
135
+ .dshmem-row-top { display:flex; gap:8px; align-items:baseline; }
136
+ .dshmem-chip { flex:0 0 auto; font-size:11px; padding:1px 7px; border-radius:999px; border:1px solid var(--dsw-alias-border-l2); color:var(--dsw-alias-label-secondary); white-space:nowrap; }
137
+ .dshmem-chip-fact { color:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); }
138
+ .dshmem-chip-lesson { color:var(--dsw-alias-state-success-primary); border-color:var(--dsw-alias-state-success-primary); }
139
+ .dshmem-row-title { font-weight:600; flex:1 1 auto; line-height:1.45; }
140
+ .dshmem-body-text { margin-top:6px; color:var(--dsw-alias-label-secondary); white-space:pre-wrap; line-height:1.55; }
141
+ .dshmem-meta { margin-top:8px; display:flex; gap:10px; flex-wrap:wrap; color:var(--dsw-alias-state-idle-primary); font-size:11px; align-items:center; }
142
+ .dshmem-link { background:none; border:none; color:var(--dsw-alias-brand-primary); font:inherit; font-size:11px; cursor:pointer; padding:0; }
143
+ .dshmem-link:hover { text-decoration:underline; }
144
+ .dshmem-danger { color:var(--dsw-alias-state-error-primary); }
145
+ .dshmem-warn { color:var(--dsw-alias-state-warn-primary); }
146
+ .dshmem-empty { color:var(--dsw-alias-label-secondary); padding:36px 8px; text-align:center; line-height:1.7; }
147
+ .dshmem-err { color:var(--dsw-alias-state-error-primary); padding:16px 0; }
148
+ .dshmem-code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; }
149
+ .dshmem-form { display:flex; flex-direction:column; gap:8px; margin-top:4px; }
150
+ .dshmem-field { display:flex; flex-direction:column; gap:4px; }
151
+ .dshmem-field-label { font-size:11px; color:var(--dsw-alias-label-secondary); }
152
+ .dshmem-plain { background:var(--dsw-alias-bg-base); color:inherit; border:1px solid var(--dsw-alias-border-l2); border-radius:6px; padding:5px 8px; font:inherit; width:100%; box-sizing:border-box; }
153
+ .dshmem-plain:focus { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:-1px; }
154
+ .dshmem-textarea { min-height:76px; resize:vertical; line-height:1.55; }
155
+ .dshmem-check { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--dsw-alias-label-secondary); }
156
+ .dshmem-form-actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
157
+ .dshmem-hint { font-size:11px; color:var(--dsw-alias-state-idle-primary); }
158
+ .dshmem-prov { margin-top:7px; display:flex; gap:14px; flex-wrap:wrap; font-size:11px; color:var(--dsw-alias-label-secondary); border-left:2px solid var(--dsw-alias-border-l2); padding-left:8px; }
159
+ .dshmem-prov-key { color:var(--dsw-alias-state-idle-primary); }
160
+ .dshmem-confirm { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; padding:8px 10px; border:1px solid var(--dsw-alias-state-warn-primary); border-radius:8px; background:var(--dsw-alias-bg-base); }
161
+ `
162
+
163
+ function initials() {
164
+ return zh ? '记' : 'M'
165
+ }
166
+
167
+ function LedgerIcon(props) {
168
+ const size = typeof props?.size === 'number' ? props.size : 18
169
+ return h('svg', {
170
+ viewBox: '0 0 24 24',
171
+ width: size,
172
+ height: size,
173
+ fill: 'none',
174
+ stroke: 'currentColor',
175
+ strokeWidth: 1.6,
176
+ strokeLinecap: 'round',
177
+ strokeLinejoin: 'round',
178
+ 'aria-hidden': true,
179
+ style: { display: 'block', opacity: props?.active === false ? 0.8 : 1 },
180
+ },
181
+ h('rect', { x: 3.5, y: 2.5, width: 12.5, height: 19, rx: 2.5 }),
182
+ h('path', { d: 'M19.5 5.5v16' }),
183
+ h('path', { d: 'M7 7.5h5.5M7 11.5h5.5M7 15.5h3.5' }),
184
+ )
185
+ }
186
+
187
+ async function readJson(response) {
188
+ const payload = await response.json().catch(() => null)
189
+ if (!response.ok) throw new Error((payload && payload.error) || `HTTP ${response.status}`)
190
+ return payload
191
+ }
192
+
193
+ function MemoryPanel() {
194
+ const [draft, setDraft] = React.useState('')
195
+ const [query, setQuery] = React.useState('')
196
+ const [mode, setMode] = React.useState('hybrid')
197
+ const [scope, setScope] = React.useState('auto')
198
+ const [state, setState] = React.useState({ status: 'loading', data: null, error: '' })
199
+ const [expanded, setExpanded] = React.useState(() => new Set())
200
+ const [busy, setBusy] = React.useState(false)
201
+ const [editing, setEditing] = React.useState(null)
202
+ const [confirming, setConfirming] = React.useState(null)
203
+ const [showArchived, setShowArchived] = React.useState(false)
204
+
205
+ React.useEffect(() => {
206
+ const timer = setTimeout(() => setQuery(draft.trim()), 260)
207
+ return () => clearTimeout(timer)
208
+ }, [draft])
209
+
210
+ const load = React.useCallback(async () => {
211
+ setState(current => ({ status: current.data ? 'refresh' : 'loading', data: current.data, error: '' }))
212
+ try {
213
+ const params = new URLSearchParams()
214
+ if (query !== '') params.set('q', query)
215
+ params.set('mode', mode)
216
+ params.set('scope', scope)
217
+ if (showArchived) params.set('archived', '1')
218
+ const response = await fetch(`${BASE}/ledger?${params.toString()}`, {
219
+ credentials: 'same-origin',
220
+ headers: { accept: 'application/json' },
221
+ })
222
+ setState({ status: 'ready', data: await readJson(response), error: '' })
223
+ } catch (error) {
224
+ setState({ status: 'error', data: null, error: String((error && error.message) || error) })
225
+ }
226
+ }, [query, mode, scope, showArchived])
227
+
228
+ React.useEffect(() => { load() }, [load])
229
+
230
+ const forget = async (id) => {
231
+ setBusy(true)
232
+ try {
233
+ const response = await fetch(`${BASE}/forget`, {
234
+ method: 'POST',
235
+ credentials: 'same-origin',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ id }),
238
+ })
239
+ await readJson(response)
240
+ await load()
241
+ } catch (error) {
242
+ setState(current => ({ ...current, error: String((error && error.message) || error) }))
243
+ } finally {
244
+ setBusy(false)
245
+ }
246
+ }
247
+
248
+ const restore = async (id) => {
249
+ setBusy(true)
250
+ try {
251
+ const response = await fetch(`${BASE}/update`, {
252
+ method: 'POST',
253
+ credentials: 'same-origin',
254
+ headers: { 'content-type': 'application/json' },
255
+ body: JSON.stringify({ id, archived: false }),
256
+ })
257
+ await readJson(response)
258
+ await load()
259
+ } catch (error) {
260
+ setState(current => ({ ...current, error: String((error && error.message) || error) }))
261
+ } finally {
262
+ setBusy(false)
263
+ }
264
+ }
265
+
266
+ const toggle = (id) => {
267
+ setExpanded(current => {
268
+ const next = new Set(current)
269
+ if (next.has(id)) next.delete(id)
270
+ else next.add(id)
271
+ return next
272
+ })
273
+ }
274
+
275
+ const data = state.data
276
+ const entries = (data && Array.isArray(data.entries)) ? data.entries : []
277
+ const incidents = data
278
+ ? [
279
+ ...(data.user && Array.isArray(data.user.incidents) ? data.user.incidents.map(item => ({ ...item, scope: 'user' })) : []),
280
+ ...(data.project && Array.isArray(data.project.incidents) ? data.project.incidents.map(item => ({ ...item, scope: 'project' })) : []),
281
+ ]
282
+ : []
283
+ const facts = entries.filter(entry => entry.kind === 'fact')
284
+ const lessons = entries.filter(entry => entry.kind === 'lesson')
285
+ const userStats = (data && data.user && data.user.stats) || { facts: 0, lessons: 0, pending: 0 }
286
+ const projectCount = (data && Array.isArray(data.projects)) ? data.projects.length : 0
287
+
288
+ const beginEdit = (entry) => setEditing({
289
+ id: entry.id,
290
+ kind: entry.kind,
291
+ title: entry.title,
292
+ body: entry.body || '',
293
+ key: entry.key || '',
294
+ tags: (entry.tags || []).join(','),
295
+ pinned: entry.pinned === true,
296
+ reason: entry.reason || '',
297
+ })
298
+
299
+ const saveEdit = async () => {
300
+ if (editing === null) return
301
+ setBusy(true)
302
+ try {
303
+ const response = await fetch(`${BASE}/update`, {
304
+ method: 'POST',
305
+ credentials: 'same-origin',
306
+ headers: { 'content-type': 'application/json' },
307
+ body: JSON.stringify({
308
+ id: editing.id,
309
+ kind: editing.kind,
310
+ title: editing.title,
311
+ body: editing.body,
312
+ key: editing.key,
313
+ tags: editing.tags,
314
+ pinned: editing.pinned,
315
+ reason: editing.reason,
316
+ }),
317
+ })
318
+ await readJson(response)
319
+ setEditing(null)
320
+ await load()
321
+ } catch (error) {
322
+ setState(current => ({ ...current, error: `${text.saveFailed}: ${String((error && error.message) || error)}` }))
323
+ } finally {
324
+ setBusy(false)
325
+ }
326
+ }
327
+
328
+ const row = (entry) => {
329
+ if (editing !== null && editing.id === entry.id) {
330
+ const change = (patch) => setEditing({ ...editing, ...patch })
331
+ const field = (label, control) => h('label', { className: 'dshmem-field' },
332
+ h('span', { className: 'dshmem-field-label' }, label),
333
+ control,
334
+ )
335
+ return h('div', { className: 'dshmem-row', key: `edit:${entry.id}` },
336
+ h('div', { className: 'dshmem-form' },
337
+ field(text.titleLabel, h('input', {
338
+ className: 'dshmem-plain',
339
+ value: editing.title,
340
+ 'aria-label': text.titleLabel,
341
+ onChange: event => change({ title: event.target.value }),
342
+ })),
343
+ field(text.kindLabel, h('select', {
344
+ className: 'dshmem-plain',
345
+ value: editing.kind,
346
+ 'aria-label': text.kindLabel,
347
+ onChange: event => change({ kind: event.target.value }),
348
+ },
349
+ h('option', { value: 'fact' }, text.facts),
350
+ h('option', { value: 'lesson' }, text.lessons),
351
+ )),
352
+ field(text.keyLabel, h('input', {
353
+ className: 'dshmem-plain',
354
+ value: editing.key,
355
+ 'aria-label': text.keyLabel,
356
+ onChange: event => change({ key: event.target.value }),
357
+ })),
358
+ field(text.tagsLabel, h('input', {
359
+ className: 'dshmem-plain',
360
+ value: editing.tags,
361
+ 'aria-label': text.tagsLabel,
362
+ onChange: event => change({ tags: event.target.value }),
363
+ })),
364
+ field(text.bodyLabel, h('textarea', {
365
+ className: 'dshmem-plain dshmem-textarea',
366
+ value: editing.body,
367
+ 'aria-label': text.bodyLabel,
368
+ onChange: event => change({ body: event.target.value }),
369
+ onKeyDown: event => {
370
+ if (event.key === 'Escape') setEditing(null)
371
+ },
372
+ })),
373
+ field(text.reasonLabel, h('input', {
374
+ className: 'dshmem-plain',
375
+ value: editing.reason,
376
+ 'aria-label': text.reasonLabel,
377
+ placeholder: text.reasonUnknown,
378
+ onChange: event => change({ reason: event.target.value }),
379
+ })),
380
+ h('label', { className: 'dshmem-check' },
381
+ h('input', {
382
+ type: 'checkbox',
383
+ checked: editing.pinned,
384
+ onChange: event => change({ pinned: event.target.checked }),
385
+ }),
386
+ text.pinnedLabel,
387
+ ), h('div', { className: 'dshmem-form-actions' },
388
+ h('button', { className: 'dshmem-btn', type: 'button', onClick: saveEdit, disabled: busy }, text.save),
389
+ h('button', { className: 'dshmem-btn', type: 'button', onClick: () => setEditing(null), disabled: busy }, text.cancel),
390
+ h('span', { className: 'dshmem-hint' }, text.editHint),
391
+ ),
392
+ ),
393
+ )
394
+ }
395
+ const isOpen = expanded.has(entry.id)
396
+ const body = entry.body || ''
397
+ const shown = isOpen || body.length <= 280 ? body : `${body.slice(0, 280)}…`
398
+ return h('div', { className: 'dshmem-row', key: `${entry.scope}:${entry.id}` },
399
+ h('div', { className: 'dshmem-row-top' },
400
+ h('span', {
401
+ className: `dshmem-chip ${entry.kind === 'fact' ? 'dshmem-chip-fact' : 'dshmem-chip-lesson'}`,
402
+ }, entry.kind === 'fact' ? text.facts : text.lessons),
403
+ h('span', { className: 'dshmem-chip' }, entry.scope === 'project' ? text.projectLedger : text.userLedger),
404
+ h('span', { className: 'dshmem-row-title' }, entry.title),
405
+ ),
406
+ shown !== '' ? h('div', { className: 'dshmem-body-text' }, shown) : null,
407
+ body.length > 280
408
+ ? h('button', { className: 'dshmem-link', type: 'button', onClick: () => toggle(entry.id) }, isOpen ? text.collapse : text.expand)
409
+ : null,
410
+ h('div', { className: 'dshmem-prov' },
411
+ h('span', null,
412
+ h('span', { className: 'dshmem-prov-key' }, `${text.reasonLabel}:`),
413
+ entry.reason ? entry.reason : text.reasonUnknown,
414
+ ),
415
+ h('span', null,
416
+ h('span', { className: 'dshmem-prov-key' }, `${text.sourceLabel}:`),
417
+ entry.origin && entry.origin.label ? entry.origin.label : '—',
418
+ ),
419
+ ),
420
+ confirming === entry.id
421
+ ? h('div', { className: 'dshmem-confirm' },
422
+ h('span', { className: 'dshmem-warn' }, text.confirmArchive),
423
+ h('span', { className: 'dshmem-hint' }, text.archiveNote),
424
+ h('button', {
425
+ className: 'dshmem-btn',
426
+ type: 'button',
427
+ disabled: busy,
428
+ onClick: () => {
429
+ setConfirming(null)
430
+ forget(entry.id)
431
+ },
432
+ }, text.archiveConfirm),
433
+ h('button', {
434
+ className: 'dshmem-btn',
435
+ type: 'button',
436
+ disabled: busy,
437
+ onClick: () => setConfirming(null),
438
+ }, text.archiveCancel),
439
+ )
440
+ : null,
441
+ h('div', { className: 'dshmem-meta' },
442
+ h('span', { className: 'dshmem-code' }, entry.id),
443
+ entry.key ? h('span', null, `[${entry.key}]`) : null,
444
+ entry.scope === 'project' && entry.project ? h('span', null, entry.project) : null,
445
+ entry.tags && entry.tags.length ? h('span', null, entry.tags.join(' · ')) : null,
446
+ entry.occurrences > 1 ? h('span', null, `${entry.occurrences} ${text.repeats}`) : null,
447
+ entry.archived ? h('span', { className: 'dshmem-warn' }, text.archived) : null,
448
+ data && data.filtered && entry._score ? h('span', null, `${text.score} ${entry._score}`) : null,
449
+ h('button', {
450
+ className: 'dshmem-link',
451
+ type: 'button',
452
+ disabled: busy,
453
+ onClick: () => beginEdit(entry),
454
+ }, text.edit),
455
+ entry.archived
456
+ ? h('button', {
457
+ className: 'dshmem-link',
458
+ type: 'button',
459
+ disabled: busy,
460
+ onClick: () => restore(entry.id),
461
+ }, text.restore)
462
+ : h('button', {
463
+ className: 'dshmem-link dshmem-danger',
464
+ type: 'button',
465
+ disabled: busy,
466
+ onClick: () => setConfirming(entry.id),
467
+ }, text.forget),
468
+ ),
469
+ )
470
+ }
471
+
472
+ return h('div', { className: 'dshmem' },
473
+ h('style', null, styles),
474
+ h('div', { className: 'dshmem-head' },
475
+ h('span', { className: 'dshmem-title' }, text.title),
476
+ h('span', { className: 'dshmem-sub' }, text.subtitle),
477
+ h('button', { className: 'dshmem-btn', type: 'button', onClick: () => load(), disabled: state.status === 'loading' }, text.refresh),
478
+ ),
479
+ h('div', { className: 'dshmem-tools' },
480
+ h('input', {
481
+ className: 'dshmem-input',
482
+ type: 'search',
483
+ value: draft,
484
+ placeholder: text.search,
485
+ 'aria-label': text.search,
486
+ onChange: event => setDraft(event.target.value),
487
+ }),
488
+ h('select', {
489
+ className: 'dshmem-select',
490
+ value: mode,
491
+ 'aria-label': text.mode,
492
+ onChange: event => setMode(event.target.value),
493
+ }, Object.keys(text.modes).map(key => h('option', { key, value: key }, text.modes[key]))),
494
+ h('select', {
495
+ className: 'dshmem-select',
496
+ value: scope,
497
+ 'aria-label': text.scope,
498
+ onChange: event => setScope(event.target.value),
499
+ }, Object.keys(text.scopes).map(key => h('option', { key, value: key }, text.scopes[key]))),
500
+ h('label', { className: 'dshmem-check' },
501
+ h('input', {
502
+ type: 'checkbox',
503
+ checked: showArchived,
504
+ onChange: event => setShowArchived(event.target.checked),
505
+ }),
506
+ text.showArchived,
507
+ ),
508
+ ),
509
+ h('div', { className: 'dshmem-stats' },
510
+ h('span', null, `${text.userLedger}: ${userStats.facts} ${text.facts} · ${userStats.lessons} ${text.lessons}`),
511
+ h('span', null, `${projectCount} ${text.projects}`),
512
+ userStats.pending > 0 ? h('span', { className: 'dshmem-warn' }, `${userStats.pending} ${text.incidents}`) : null,
513
+ data && data.filtered ? h('span', { className: 'dshmem-warn' }, text.filtered) : null,
514
+ ),
515
+ h('div', { className: 'dshmem-body' },
516
+ state.status === 'loading' ? h('div', { className: 'dshmem-empty' }, text.loading) : null,
517
+ state.status === 'error' ? h('div', { className: 'dshmem-err' }, `${text.error}: ${state.error}`) : null,
518
+ incidents.length > 0
519
+ ? h('div', null,
520
+ h('div', { className: 'dshmem-sect' }, `${text.incidents} (${incidents.length})`),
521
+ incidents.map(incident => h('div', { className: 'dshmem-row', key: incident.signature },
522
+ h('div', { className: 'dshmem-row-top' },
523
+ h('span', { className: 'dshmem-chip dshmem-warn' }, incident.tool),
524
+ h('span', { className: 'dshmem-row-title' }, incident.message || incident.normalized),
525
+ ),
526
+ h('div', { className: 'dshmem-meta' }, h('span', null, `${incident.count} ${text.repeats}`)),
527
+ )),
528
+ )
529
+ : null,
530
+ facts.length > 0 ? h('div', null, h('div', { className: 'dshmem-sect' }, `${text.facts} (${facts.length})`), facts.map(row)) : null,
531
+ lessons.length > 0 ? h('div', null, h('div', { className: 'dshmem-sect' }, `${text.lessons} (${lessons.length})`), lessons.map(row)) : null,
532
+ state.status === 'ready' && entries.length === 0 && incidents.length === 0
533
+ ? h('div', { className: 'dshmem-empty' }, text.empty)
534
+ : null,
535
+ ),
536
+ )
537
+ }
538
+
539
+ return {
540
+ // `slots` is the only service this half needs; the panel reads data from
541
+ // the Host's own HTTP routes, so no Remote namespace is required.
542
+ inject: ['slots'],
543
+ apply(ctx) {
544
+ ctx.slots.inject('main', () => ctx.slots.register(
545
+ { name: 'main', key: PANEL_ID },
546
+ MemoryPanel,
547
+ ))
548
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
549
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 30, label: () => text.label },
550
+ LedgerIcon,
551
+ ))
552
+ },
553
+ // Kept for tooling that reads the registered icon text.
554
+ __initials: initials,
555
+ }
556
+ },
557
+ })