@co-engram/viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +38 -0
  2. package/dist/brand-logos.d.ts +9 -0
  3. package/dist/brand-logos.d.ts.map +1 -0
  4. package/dist/brand-logos.js +10 -0
  5. package/dist/brand-logos.js.map +1 -0
  6. package/dist/html.d.ts +21 -0
  7. package/dist/html.d.ts.map +1 -0
  8. package/dist/html.js +299 -0
  9. package/dist/html.js.map +1 -0
  10. package/dist/index.d.ts +8 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +8 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/runtime/app.d.ts +11 -0
  15. package/dist/runtime/app.d.ts.map +1 -0
  16. package/dist/runtime/app.js +437 -0
  17. package/dist/runtime/app.js.map +1 -0
  18. package/dist/runtime/decay.d.ts +16 -0
  19. package/dist/runtime/decay.d.ts.map +1 -0
  20. package/dist/runtime/decay.js +108 -0
  21. package/dist/runtime/decay.js.map +1 -0
  22. package/dist/runtime/graph.d.ts +13 -0
  23. package/dist/runtime/graph.d.ts.map +1 -0
  24. package/dist/runtime/graph.js +313 -0
  25. package/dist/runtime/graph.js.map +1 -0
  26. package/dist/runtime/i18n.d.ts +16 -0
  27. package/dist/runtime/i18n.d.ts.map +1 -0
  28. package/dist/runtime/i18n.js +76 -0
  29. package/dist/runtime/i18n.js.map +1 -0
  30. package/dist/runtime/tabs.d.ts +8 -0
  31. package/dist/runtime/tabs.d.ts.map +1 -0
  32. package/dist/runtime/tabs.js +1783 -0
  33. package/dist/runtime/tabs.js.map +1 -0
  34. package/dist/server.d.ts +73 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js +985 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/styles.d.ts +13 -0
  39. package/dist/styles.d.ts.map +1 -0
  40. package/dist/styles.js +1632 -0
  41. package/dist/styles.js.map +1 -0
  42. package/dist/vendor/dompurify-source.d.ts +11 -0
  43. package/dist/vendor/dompurify-source.d.ts.map +1 -0
  44. package/dist/vendor/dompurify-source.js +15 -0
  45. package/dist/vendor/dompurify-source.js.map +1 -0
  46. package/dist/vendor/marked-source.d.ts +11 -0
  47. package/dist/vendor/marked-source.d.ts.map +1 -0
  48. package/dist/vendor/marked-source.js +18 -0
  49. package/dist/vendor/marked-source.js.map +1 -0
  50. package/dist/vendor/vis-network-source.d.ts +11 -0
  51. package/dist/vendor/vis-network-source.d.ts.map +1 -0
  52. package/dist/vendor/vis-network-source.js +46 -0
  53. package/dist/vendor/vis-network-source.js.map +1 -0
  54. package/package.json +61 -0
@@ -0,0 +1,1783 @@
1
+ /**
2
+ * Viewer v2 runtime — stats / engrams / proposals / audit / trash / config 六个 tab。
3
+ * Graph tab 单独在 graph.ts(需要 vis-network)。
4
+ *
5
+ * @module @co-engram/claude-code/viewer/runtime/tabs
6
+ */
7
+ export const TABS_RUNTIME = `
8
+ // ============================================================
9
+ // 全局映射表(中文标签)
10
+ // ============================================================
11
+ window.CO_ENGRAM_LABELS = {
12
+ kind: { fact: '事实', observation: '观察', pattern: '模式', procedure: '流程', hypothesis: '假设' },
13
+ status: { active: '活跃', dormant: '休眠', forgotten: '已遗忘', archived: '已归档' },
14
+ freshness: { stable: '稳定', recent: '近期', fading: '衰减', stale: '过时' },
15
+ visibility: { public: '公开', team: '团队', private: '私有', restricted: '受限' },
16
+ emotionalValence: { positive: '积极', negative: '消极', neutral: '中性' },
17
+ sourceType: { firsthand: '一手', secondhand: '二手', inferred: '推断' },
18
+ verification: { unverified: '未验证', plausible: '貌似成立', probable: '较可能', verified: '已验证', refuted: '已反驳' },
19
+ synapse: {
20
+ extends: '扩展', part_of: '部分', similar_to: '相似',
21
+ depends_on: '依赖', causes: '导致', follows: '跟随',
22
+ derives_from: '派生', contradicts: '矛盾', exemplifies: '例证',
23
+ supersedes: '取代', consolidates: '整合',
24
+ contextualizes: '上下文'
25
+ },
26
+ synapseDirection: { directional: '单向', bidirectional: '双向' },
27
+ resolution: { pending: '待处理', auto_resolved: '已自动裁决', escalated: '已升级', contested: '有争议', resolved: '已解决' }
28
+ };
29
+
30
+ // ============================================================
31
+ // Stats
32
+ // ============================================================
33
+ CO_ENGRAM.on('stats', async function() {
34
+ const el = document.getElementById('stats-content');
35
+ if (!el) return;
36
+ if (CO_ENGRAM._statsLoaded) return;
37
+ el.innerHTML = '<div class="loading">加载统计中</div>';
38
+ let data;
39
+ try { data = await CO_ENGRAM.apiGet('/api/stats'); }
40
+ catch (e) { el.innerHTML = '<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'; return; }
41
+
42
+ const L = CO_ENGRAM_LABELS;
43
+ const SYNAPSE_LABEL = {
44
+ extends: '扩展', part_of: '部分', similar_to: '相似',
45
+ depends_on: '依赖', causes: '导致', follows: '跟随',
46
+ derives_from: '派生', contradicts: '矛盾', exemplifies: '例证',
47
+ supersedes: '取代', consolidates: '整合',
48
+ contextualizes: '上下文'
49
+ };
50
+
51
+ const kpiClickable = (label, value, sub, tab) => '<div class="kpi"' + (tab ? ' onclick="CO_ENGRAM.showTab(\\'' + tab + '\\')"' : '') + '>'
52
+ + '<div class="kpi-label">' + CO_ENGRAM.escapeHtml(label) + '</div>'
53
+ + '<div class="kpi-value">' + CO_ENGRAM.escapeHtml(value) + '</div>'
54
+ + (sub ? '<div class="kpi-sub">' + CO_ENGRAM.escapeHtml(sub) + '</div>' : '') + '</div>';
55
+
56
+ const barRow = (label, count, max, color, onclick, tipAttr) => '<div class="bar-row">'
57
+ + '<div class="bar-label"' + (tipAttr || '') + (onclick ? ' onclick="' + onclick + '"' : '') + '>' + CO_ENGRAM.escapeHtml(label) + '</div>'
58
+ + '<div class="bar-track"><div class="bar-fill" style="width:' + (max ? (count / max * 100) : 0).toFixed(1) + '%;background:' + (color || '#5eead4') + '"></div></div>'
59
+ + '<div class="bar-value">' + count + '</div></div>';
60
+
61
+ const kindMap = data.byKind || {};
62
+ const kindKeys = Object.keys(kindMap);
63
+ const kindMax = Math.max(1, ...kindKeys.map(k => kindMap[k] || 0));
64
+ const statusMap = data.byStatus || {};
65
+ const statusKeys = Object.keys(statusMap);
66
+ const statusMax = Math.max(1, ...statusKeys.map(k => statusMap[k] || 0));
67
+ const synKindMap = data.bySynapseKind || {};
68
+ const synKindKeys = Object.keys(synKindMap);
69
+ const synKindMax = Math.max(1, ...synKindKeys.map(k => synKindMap[k] || 0));
70
+ const tagArr = data.topTags || [];
71
+ const tagMax = tagArr.length ? Math.max(1, ...tagArr.map(t => t.count || 0)) : 1;
72
+ const contribArr = data.topContributors || [];
73
+ const contribMax = contribArr.length ? Math.max(1, ...contribArr.map(c => c.total || 0)) : 1;
74
+
75
+ let html = '<div class="kpi-grid">'
76
+ + kpiClickable('记忆印迹总数', data.totalEngrams || 0, '点击查看全部', 'engrams')
77
+ + kpiClickable('记忆突触总数', data.totalSynapses || 0, '点击查看图谱', 'graph')
78
+ + kpiClickable('待审提案', data.pendingProposals || 0, '点击处理', 'proposals')
79
+ + '</div>';
80
+
81
+ // 记忆印迹区(独立一块)
82
+ html += '<div class="card" style="margin-top:1.25rem"><h3 class="section-title"' + CO_ENGRAM.tip('kind.fact') + '>记忆印迹 · 按类型分布</h3>';
83
+ if (!kindKeys.length) html += '<div class="empty">暂无数据</div>';
84
+ else kindKeys.forEach(k => html += barRow(L.kind[k] || k, kindMap[k], kindMax, CO_ENGRAM.kindColor(k), 'CO_ENGRAM.showTab(\\'engrams\\')', CO_ENGRAM.tip('kind.' + k)));
85
+ html += '</div>';
86
+
87
+ html += '<div class="card" style="margin-top:1rem"><h3 class="section-title"' + CO_ENGRAM.tip('status.active') + '>记忆印迹 · 按状态分布</h3>';
88
+ if (!statusKeys.length) html += '<div class="empty">暂无数据</div>';
89
+ else statusKeys.forEach(k => html += barRow(L.status[k] || k, statusMap[k], statusMax, '#94a3b8', '', CO_ENGRAM.tip('status.' + k)));
90
+ html += '</div>';
91
+
92
+ // 记忆突触区(独立一块,与印迹分开)
93
+ html += '<div class="card" style="margin-top:1rem"><h3 class="section-title"' + CO_ENGRAM.tip('family.structural') + '>记忆突触 · 按类型分布</h3>';
94
+ if (!synKindKeys.length) html += '<div class="empty">暂无突触</div>';
95
+ else synKindKeys.forEach(k => html += barRow(SYNAPSE_LABEL[k] || k, synKindMap[k], synKindMax, CO_ENGRAM.edgeColor(k), 'CO_ENGRAM.showTab(\\'graph\\')', CO_ENGRAM.tip('synapse.' + k)));
96
+ html += '</div>';
97
+
98
+ // 贡献者排名
99
+ if (contribArr.length) {
100
+ html += '<div class="card" style="margin-top:1rem"><h3 class="section-title">贡献者排名 · 印迹 + 突触合计</h3>';
101
+ html += '<table class="data-table"><thead><tr><th>#</th><th>贡献者</th><th>印迹</th><th>突触</th><th style="width:35%">合计</th></tr></thead><tbody>';
102
+ contribArr.forEach((c, i) => {
103
+ const pct = (c.total / contribMax * 100).toFixed(1);
104
+ html += '<tr>'
105
+ + '<td>' + (i + 1) + '</td>'
106
+ + '<td><code>' + CO_ENGRAM.escapeHtml(c.actor) + '</code></td>'
107
+ + '<td>' + c.engramCount + '</td>'
108
+ + '<td>' + c.synapseCount + '</td>'
109
+ + '<td><div class="bar-track" style="min-width:120px"><div class="bar-fill" style="width:' + pct + '%;background:#5eead4"></div></div> <span style="margin-left:.4rem">' + c.total + '</span></td>'
110
+ + '</tr>';
111
+ });
112
+ html += '</tbody></table>';
113
+ html += '</div>';
114
+ }
115
+
116
+ if (tagArr.length) {
117
+ html += '<div class="card" style="margin-top:1rem"><h3 class="section-title">高频领域标签</h3>';
118
+ tagArr.slice(0, 10).forEach(t => { html += barRow(t.tag, t.count, tagMax, '#c084fc'); });
119
+ html += '</div>';
120
+ }
121
+
122
+ el.innerHTML = html;
123
+ CO_ENGRAM._statsLoaded = true;
124
+ });
125
+
126
+ // ============================================================
127
+ // Engrams
128
+ // ============================================================
129
+ CO_ENGRAM.on('engrams', async function() {
130
+ const root = document.getElementById('engrams-content');
131
+ if (!root) return;
132
+ if (CO_ENGRAM._engramsLoaded) return;
133
+ CO_ENGRAM._engramsLoaded = true;
134
+ await CO_ENGRAM_ENGRAMS.render(root);
135
+ });
136
+
137
+ window.CO_ENGRAM_ENGRAMS = {
138
+ async render(root) {
139
+ root.innerHTML = '<div class="loading">加载记忆印迹中</div>';
140
+ let data;
141
+ try { data = await CO_ENGRAM.apiGet('/api/engrams'); }
142
+ catch (e) { root.innerHTML = '<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'; return; }
143
+
144
+ const all = data.results || [];
145
+ CO_ENGRAM._engramsCache = all;
146
+ CO_ENGRAM._engramsViewMode = CO_ENGRAM._engramsViewMode || 'card';
147
+
148
+ const T = CO_ENGRAM_T;
149
+ const kindKeys = ['observation', 'fact', 'pattern', 'procedure', 'hypothesis'];
150
+ const kindOptions = kindKeys.map(k => '<option value="' + k + '"' + CO_ENGRAM.tip('kind.' + k) + '>' + CO_ENGRAM.escapeHtml(T.enumLabel('kind', k)) + '</option>').join('');
151
+
152
+ const filterBar = '<div class="filter-bar">'
153
+ + '<input type="search" placeholder="' + CO_ENGRAM.escapeHtml(T.t('engrams.searchPlaceholder')) + '" id="engrams-q" oninput="CO_ENGRAM_ENGRAMS.applyFilter()">'
154
+ + '<label>' + CO_ENGRAM.escapeHtml(T.t('engrams.filter.kind')) + ' <select id="engrams-kind" onchange="CO_ENGRAM_ENGRAMS.applyFilter()">'
155
+ + '<option value="">' + CO_ENGRAM.escapeHtml(T.t('engrams.filter.kindAll')) + '</option>' + kindOptions + '</select></label>'
156
+ + '<label>' + CO_ENGRAM.escapeHtml(T.t('engrams.filter.sort')) + ' <select id="engrams-sort" onchange="CO_ENGRAM_ENGRAMS.applyFilter()">'
157
+ + '<option value="createdAt-desc">' + CO_ENGRAM.escapeHtml(T.t('engrams.filter.sortNewest')) + '</option>'
158
+ + '<option value="createdAt-asc">' + CO_ENGRAM.escapeHtml(T.t('engrams.filter.sortOldest')) + '</option>'
159
+ + '<option value="importance-desc">' + CO_ENGRAM.escapeHtml(T.t('engrams.filter.sortImportance')) + '</option>'
160
+ + '<option value="retrievalCount-desc">' + CO_ENGRAM.escapeHtml(T.t('engrams.filter.sortRetrievals')) + '</option>'
161
+ + '</select></label>'
162
+ + '<span class="spacer"></span>'
163
+ + '<div class="view-toggle" role="group" aria-label="' + CO_ENGRAM.escapeHtml(T.t('engrams.view.card') + ' / ' + T.t('engrams.view.tree')) + '">'
164
+ + '<button class="tab' + (CO_ENGRAM._engramsViewMode === 'card' ? ' active' : '') + '" onclick="CO_ENGRAM_ENGRAMS.setView(\\'card\\')">' + CO_ENGRAM.escapeHtml(T.t('engrams.view.card')) + '</button>'
165
+ + '<button class="tab' + (CO_ENGRAM._engramsViewMode === 'tree' ? ' active' : '') + '" onclick="CO_ENGRAM_ENGRAMS.setView(\\'tree\\')">' + CO_ENGRAM.escapeHtml(T.t('engrams.view.tree')) + '</button>'
166
+ + '</div>'
167
+ + '<span class="chip" id="engrams-count">' + CO_ENGRAM.escapeHtml(T.t('engrams.countTotal', { n: all.length })) + '</span>'
168
+ + '</div>'
169
+ + '<div id="engrams-body"></div>';
170
+
171
+ root.innerHTML = filterBar;
172
+ this.applyFilter();
173
+ },
174
+
175
+ setMode(mode) {
176
+ CO_ENGRAM._engramsViewMode = mode;
177
+ const toggle = document.querySelector('.view-toggle');
178
+ if (toggle) toggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
179
+ this.applyFilter();
180
+ },
181
+
182
+ // 兼容旧调用名
183
+ setView(mode) { this.setMode(mode); },
184
+
185
+ applyFilter() {
186
+ const cache = CO_ENGRAM._engramsCache || [];
187
+ const q = ((document.getElementById('engrams-q') || {}).value || '').toLowerCase();
188
+ const kind = (document.getElementById('engrams-kind') || {}).value || '';
189
+ const sort = ((document.getElementById('engrams-sort') || {}).value || 'createdAt-desc').split('-');
190
+ const [sortKey, sortDir] = sort;
191
+ const T = CO_ENGRAM_T;
192
+ const mode = CO_ENGRAM._engramsViewMode || 'card';
193
+
194
+ let filtered = cache.filter(e => {
195
+ if (kind && e.kind !== kind) return false;
196
+ if (q) {
197
+ const title = (e.title || '').toLowerCase();
198
+ const tags = (e.domainTags || []).join(' ').toLowerCase();
199
+ if (!title.includes(q) && !tags.includes(q)) return false;
200
+ }
201
+ return true;
202
+ });
203
+ filtered.sort((a, b) => {
204
+ const av = a[sortKey] || 0;
205
+ const bv = b[sortKey] || 0;
206
+ if (typeof av === 'string' && typeof bv === 'string') {
207
+ return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
208
+ }
209
+ return sortDir === 'asc' ? av - bv : bv - av;
210
+ });
211
+
212
+ const body = document.getElementById('engrams-body');
213
+ if (!body) return;
214
+ const countEl = document.getElementById('engrams-count');
215
+ if (countEl) countEl.textContent = T.t('engrams.countFiltered', { shown: filtered.length, total: cache.length });
216
+
217
+ if (!filtered.length) {
218
+ body.innerHTML = '<div class="empty"><div class="icon">🕳️</div>' + CO_ENGRAM.escapeHtml(T.t('engrams.empty')) + '</div>';
219
+ return;
220
+ }
221
+
222
+ if (mode === 'tree') {
223
+ // 目录视图:按 domainTags[0] 或 kind 分组
224
+ CO_ENGRAM_ENGRAMS._renderTree(filtered, body);
225
+ return;
226
+ }
227
+
228
+ // 卡片视图
229
+ body.innerHTML = '<div class="grid cols-3">' + filtered.map(e => {
230
+ const tags = (e.domainTags || []).slice(0, 4)
231
+ .map(t => '<span class="chip">' + CO_ENGRAM.escapeHtml(t) + '</span>').join(' ');
232
+ const more = (e.domainTags || []).length > 4 ? '<span class="chip">+' + ((e.domainTags || []).length - 4) + '</span>' : '';
233
+ const kindTip = CO_ENGRAM.tip('kind.' + e.kind);
234
+ const createdCell = e.createdAt
235
+ ? '<span title="' + CO_ENGRAM.escapeHtml(e.createdAt) + '">' + CO_ENGRAM.escapeHtml(CO_ENGRAM.relativeTime(e.createdAt)) + '</span>'
236
+ : '';
237
+ return '<div class="card">'
238
+ + '<div class="card-title" onclick="CO_ENGRAM_ENGRAMS.open(\\'' + CO_ENGRAM.escapeHtml(e.id) + '\\')">' + CO_ENGRAM.escapeHtml(e.title) + '</div>'
239
+ + '<div><span class="chip kind-' + e.kind + '"' + kindTip + '>' + CO_ENGRAM.escapeHtml(T.enumLabel('kind', e.kind)) + '</span> '
240
+ + CO_ENGRAM.importanceBar(e.importance) + '</div>'
241
+ + '<div class="card-meta">'
242
+ + (e.retrievalCount != null ? '<span' + CO_ENGRAM.tip('retrievalCount') + '>' + CO_ENGRAM.escapeHtml(T.t('engrams.retrievalsCount', { n: e.retrievalCount })) + '</span>' : '')
243
+ + createdCell
244
+ + '</div>'
245
+ + (tags ? '<div class="card-meta">' + tags + more + '</div>' : '')
246
+ + '</div>';
247
+ }).join('') + '</div>';
248
+ },
249
+
250
+ // 目录视图:按 domainTags[0](无则归入"未分类")→ kind 两层结构
251
+ _renderTree(items, body) {
252
+ const T = CO_ENGRAM_T;
253
+ const groups = new Map(); // groupKey → { display, items: [] }
254
+ for (const e of items) {
255
+ const topTag = (e.domainTags || [])[0] || '__untagged__';
256
+ const display = topTag === '__untagged__' ? T.t('engrams.untagged') : topTag;
257
+ if (!groups.has(topTag)) groups.set(topTag, { display, items: [] });
258
+ groups.get(topTag).items.push(e);
259
+ }
260
+ // 排序:未分类最后,其他按字母
261
+ const sortedGroups = Array.from(groups.entries()).sort((a, b) => {
262
+ if (a[0] === '__untagged__') return 1;
263
+ if (b[0] === '__untagged__') return -1;
264
+ return a[1].display.localeCompare(b[1].display);
265
+ });
266
+
267
+ let html = '<div class="tree-view">';
268
+ sortedGroups.forEach(([key, group], gi) => {
269
+ const gid = 'tree-group-' + gi;
270
+ const kindSubGroups = new Map();
271
+ for (const e of group.items) {
272
+ if (!kindSubGroups.has(e.kind)) kindSubGroups.set(e.kind, []);
273
+ kindSubGroups.get(e.kind).push(e);
274
+ }
275
+ const kindKeys = Array.from(kindSubGroups.keys()).sort();
276
+ const subRows = kindKeys.map(k => {
277
+ const subItems = kindSubGroups.get(k);
278
+ const subId = gid + '-k-' + k;
279
+ const itemRows = subItems.map(e =>
280
+ '<div class="tree-leaf" onclick="CO_ENGRAM_ENGRAMS.open(\\'' + CO_ENGRAM.escapeHtml(e.id) + '\\')"' + CO_ENGRAM.tip('kind.' + e.kind) + '>'
281
+ + '<span class="chip kind-' + e.kind + '">' + CO_ENGRAM.escapeHtml(T.enumLabel('kind', e.kind)) + '</span> '
282
+ + CO_ENGRAM.escapeHtml(e.title)
283
+ + '</div>'
284
+ ).join('');
285
+ return '<details class="tree-subgroup" open>'
286
+ + '<summary><span class="chip kind-' + k + '"' + CO_ENGRAM.tip('kind.' + k) + '>' + CO_ENGRAM.escapeHtml(T.enumLabel('kind', k)) + '</span> <span class="tree-count">' + subItems.length + '</span></summary>'
287
+ + '<div class="tree-leaf-group">' + itemRows + '</div>'
288
+ + '</details>';
289
+ }).join('');
290
+
291
+ html += '<details class="tree-group" open>'
292
+ + '<summary><span class="tree-folder-icon">📁</span> ' + CO_ENGRAM.escapeHtml(group.display) + ' <span class="tree-count">' + group.items.length + '</span></summary>'
293
+ + '<div class="tree-group-body">' + subRows + '</div>'
294
+ + '</details>';
295
+ });
296
+ html += '</div>';
297
+ body.innerHTML = html;
298
+ },
299
+
300
+ async open(id) {
301
+ let d;
302
+ try { d = await CO_ENGRAM.apiGet('/api/engrams/' + encodeURIComponent(id)); }
303
+ catch (e) { CO_ENGRAM.openDrawer('<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'); return; }
304
+ CO_ENGRAM._currentEngram = d;
305
+ this._renderView(d);
306
+ },
307
+
308
+ _renderView(d) {
309
+ const T = CO_ENGRAM_T;
310
+ const D = CO_ENGRAM_DECAY;
311
+ const id = CO_ENGRAM.escapeHtml(d.id);
312
+ const tags = (d.domainTags || []).map(t => '<span class="chip">' + CO_ENGRAM.escapeHtml(t) + '</span>').join(' ');
313
+ const ctxTags = (d.contextTags || []).map(t => '<span class="chip">' + CO_ENGRAM.escapeHtml(t) + '</span>').join(' ');
314
+
315
+ // 价值评估段(emotionalValence + sourceType + verificationStatus + 衰退进度 + 强化信号)
316
+ const valence = d.emotionalValence;
317
+ const valenceLine = valence
318
+ ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('emotionalValence.' + valence) + '>' + T.fieldLabel('emotionalValence') + '</span>' + CO_ENGRAM.escapeHtml(T.enumLabel('emotionalValence', valence)) + '</div>'
319
+ : '';
320
+ const source = d.sourceType;
321
+ const sourceLine = source
322
+ ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('sourceType.' + source) + '>' + T.fieldLabel('sourceType') + '</span>' + CO_ENGRAM.escapeHtml(T.enumLabel('sourceType', source)) + '</div>'
323
+ : '';
324
+ const verif = d.verificationStatus;
325
+ const verifLine = verif
326
+ ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('verification.' + verif) + '>' + T.fieldLabel('verificationStatus') + '</span>' + CO_ENGRAM.escapeHtml(T.enumLabel('verificationStatus', verif)) + '</div>'
327
+ : '';
328
+
329
+ // 衰退进度段(替代固定半衰期显示)
330
+ const hasHalfLife = d.decayHalfLifeDays !== undefined && d.decayHalfLifeDays !== null;
331
+ const decay = hasHalfLife ? D.computeDecayState(d.lastEffectiveAt, d.decayHalfLifeDays) : null;
332
+ const decayLine = hasHalfLife
333
+ ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('decayHalfLifeDays') + '>' + T.fieldLabel('decayProgress') + '</span><div class="decay-block">' + D.renderDecayBar(decay, d.decayHalfLifeDays) + '</div></div>'
334
+ : '';
335
+
336
+ const evidenceLine = d.evidenceCount !== undefined
337
+ ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('evidenceCount') + '>' + T.fieldLabel('evidenceCount') + '</span>' + (d.evidenceCount || 0) + '</div>'
338
+ : '';
339
+ const lastEffLine = d.lastEffectiveAt
340
+ ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('lastEffectiveAt') + '>' + T.fieldLabel('lastEffective') + '</span><span title="' + CO_ENGRAM.escapeHtml(d.lastEffectiveAt) + '">' + CO_ENGRAM.escapeHtml(CO_ENGRAM.relativeTime(d.lastEffectiveAt)) + '</span></div>'
341
+ : '';
342
+ const scoreLine = (d.reinforcementScore !== undefined && d.reinforcementScore !== 0)
343
+ ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('reinforcementScore') + '>' + T.fieldLabel('reinforcementScore') + '</span>' + (d.reinforcementScore || 0).toFixed(2) + '</div>'
344
+ : '';
345
+ const valueSection = (valenceLine || sourceLine || verifLine || decayLine || evidenceLine || lastEffLine || scoreLine)
346
+ ? '<h3>' + T.sectionLabel('valueAssessment') + '</h3>' + valenceLine + sourceLine + verifLine + decayLine + evidenceLine + lastEffLine + scoreLine
347
+ : '';
348
+
349
+ // 多维重要性段(可选)
350
+ const iv = d.importanceVector;
351
+ const ivSection = iv
352
+ ? '<h3' + CO_ENGRAM.tip('importanceVector') + '>' + T.sectionLabel('multiDimImportance') + '</h3>'
353
+ + '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('importanceDim.personal') + '>个人:</span>' + (iv.personal || 0).toFixed(2)
354
+ + ' <span class="field-label"' + CO_ENGRAM.tip('importanceDim.team') + '>团队:</span>' + (iv.team || 0).toFixed(2)
355
+ + ' <span class="field-label"' + CO_ENGRAM.tip('importanceDim.project') + '>项目:</span>' + (iv.project || 0).toFixed(2)
356
+ + ' <span class="field-label"' + CO_ENGRAM.tip('importanceDim.network') + '>网络:</span>' + (iv.network || 0).toFixed(2)
357
+ + ' <span class="field-label"' + CO_ENGRAM.tip('importanceDim.temporal') + '>时间:</span>' + (iv.temporal || 0).toFixed(2)
358
+ + ' <span class="field-label">复合:</span>' + (iv.composite || 0).toFixed(2) + '</div>'
359
+ : '';
360
+
361
+ // 记忆产生情境段(可选)— section 标题已说明,内嵌 field-label 冗余,直接渲染内容
362
+ const encCtx = d.encodingContext;
363
+ const persp = d.perspective;
364
+ const encSection = (encCtx || persp)
365
+ ? '<h3' + CO_ENGRAM.tip('encodingContext') + '>' + T.sectionLabel('encodingContext') + '</h3>'
366
+ + (persp ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('perspective') + '>' + T.fieldLabel('perspective') + '</span>' + CO_ENGRAM.escapeHtml(persp) + '</div>' : '')
367
+ + (encCtx ? '<div class="field markdown-body"><div>' + CO_ENGRAM.renderMarkdown(encCtx) + '</div></div>' : '')
368
+ : '';
369
+
370
+ // 时间字段补 title 显示完整 ISO
371
+ const createdAtDisplay = d.createdAt
372
+ ? '<span title="' + CO_ENGRAM.escapeHtml(d.createdAt) + '">' + CO_ENGRAM.escapeHtml(CO_ENGRAM.relativeTime(d.createdAt)) + '</span>'
373
+ : CO_ENGRAM.escapeHtml(d.createdAt || '');
374
+
375
+ const body = '<div class="edit-banner" style="display:flex;gap:0.5rem;align-items:center">'
376
+ + '<strong style="margin-right:auto">' + T.actionLabel('detailView') + '</strong>'
377
+ + '<button class="btn" onclick="CO_ENGRAM_ENGRAMS.edit()">' + T.actionLabel('edit') + '</button>'
378
+ + '<button class="btn secondary" onclick="CO_ENGRAM_ENGRAMS.confirmDelete()">' + T.actionLabel('delete') + '</button>'
379
+ + '</div>'
380
+ + '<h2>' + CO_ENGRAM.escapeHtml(d.title) + '</h2>'
381
+ + '<div class="field"><span class="chip kind-' + d.kind + '"' + CO_ENGRAM.tip('kind.' + d.kind) + '>' + CO_ENGRAM.escapeHtml(T.enumLabel('kind', d.kind)) + '</span> '
382
+ + CO_ENGRAM.importanceBar(d.importance) + ' <span class="kpi-sub"' + CO_ENGRAM.tip('importance') + '>' + T.fieldLabel('importance') + ' ' + (d.importance || 0).toFixed(2) + '</span></div>'
383
+ + '<div class="field"><span class="field-label">' + T.fieldLabel('id') + '</span><code>' + id + '</code></div>'
384
+ + (tags ? '<div class="field"><span class="field-label">' + T.fieldLabel('domainTags') + '</span>' + tags + '</div>' : '')
385
+ + (ctxTags ? '<div class="field"><span class="field-label">' + T.fieldLabel('contextTags') + '</span>' + ctxTags + '</div>' : '')
386
+ + '<h3>' + T.sectionLabel('content') + '</h3><div class="markdown-body">' + CO_ENGRAM.renderMarkdown(d.content || '') + '</div>'
387
+ + '<h3>' + T.sectionLabel('stats') + '</h3>'
388
+ + '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('retrievalCount') + '>' + T.fieldLabel('retrievals') + '</span>' + (d.retrievalCount || 0)
389
+ + ' <span class="field-label"' + CO_ENGRAM.tip('effectiveRetrievals') + '>' + T.fieldLabel('effective') + '</span>' + (d.effectiveRetrievals || 0)
390
+ + ' <span class="field-label"' + CO_ENGRAM.tip('failedUses') + '>' + T.fieldLabel('failures') + '</span>' + (d.failedUses || 0) + '</div>'
391
+ + '<div class="field"><span class="field-label">' + T.fieldLabel('creator') + '</span>' + CO_ENGRAM.escapeHtml(d.createdBy || '')
392
+ + ' <span class="field-label">' + T.fieldLabel('time') + '</span>' + createdAtDisplay + '</div>'
393
+ + '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('confidence') + '>' + T.fieldLabel('confidence') + '</span>' + (d.confidence || 0).toFixed(2)
394
+ + ' <span class="field-label"' + CO_ENGRAM.tip('status.' + (d.status || 'active')) + '>' + T.fieldLabel('status') + '</span>' + CO_ENGRAM.escapeHtml(T.enumLabel('status', d.status))
395
+ + ' <span class="field-label"' + CO_ENGRAM.tip('freshness.' + (d.freshness || 'fresh')) + '>' + T.fieldLabel('freshness') + '</span>' + CO_ENGRAM.escapeHtml(T.enumLabel('freshness', d.freshness)) + '</div>'
396
+ + valueSection
397
+ + ivSection
398
+ + encSection;
399
+ CO_ENGRAM.openDrawer(body);
400
+ },
401
+
402
+ edit() {
403
+ const d = CO_ENGRAM._currentEngram;
404
+ if (!d) return;
405
+ const L = CO_ENGRAM_LABELS;
406
+ const id = CO_ENGRAM.escapeHtml(d.id);
407
+ const kindOptions = Object.keys(L.kind).map(k => '<option value="' + k + '"' + (d.kind === k ? ' selected' : '') + CO_ENGRAM.tip('kind.' + k) + '>' + L.kind[k] + '</option>').join('');
408
+ const visOptions = Object.keys(L.visibility).map(v => '<option value="' + v + '"' + (d.visibility === v ? ' selected' : '') + CO_ENGRAM.tip('visibility.' + v) + '>' + L.visibility[v] + '</option>').join('');
409
+
410
+ const body = '<div class="edit-banner"><strong>编辑模式</strong> · 修改后点击"保存"提交</div>'
411
+ + '<h2>编辑记忆印迹</h2>'
412
+ + '<div class="field"><span class="field-label">ID:</span><code>' + id + '</code></div>'
413
+ + '<div class="field"><label class="field-label">标题</label><input id="ef-title" type="text" value="' + CO_ENGRAM.escapeHtml(d.title || '') + '"></div>'
414
+ + '<div class="field"><label class="field-label"' + CO_ENGRAM.tip('kind.fact') + '>类型</label><select id="ef-kind"' + CO_ENGRAM.tip('kind.fact') + '>' + kindOptions + '</select></div>'
415
+ + '<div class="field"><label class="field-label"' + CO_ENGRAM.tip('importance') + '>重要性 (0-1,可拖动滑块)</label><input id="ef-importance-range" type="range" min="0" max="1" step="0.01" value="' + (d.importance || 0) + '" oninput="document.getElementById(\\'ef-importance\\').value=this.value"><input id="ef-importance" type="number" min="0" max="1" step="0.01" value="' + (d.importance || 0) + '" oninput="document.getElementById(\\'ef-importance-range\\').value=this.value" style="width:80px;margin-left:0.5rem"></div>'
416
+ + '<div class="field"><label class="field-label"' + CO_ENGRAM.tip('confidence') + '>置信度 (0-1,可拖动滑块)</label><input id="ef-confidence-range" type="range" min="0" max="1" step="0.01" value="' + (d.confidence || 0) + '" oninput="document.getElementById(\\'ef-confidence\\').value=this.value"><input id="ef-confidence" type="number" min="0" max="1" step="0.01" value="' + (d.confidence || 0) + '" oninput="document.getElementById(\\'ef-confidence-range\\').value=this.value" style="width:80px;margin-left:0.5rem"></div>'
417
+ + '<div class="field"><label class="field-label">领域标签(逗号分隔)</label><input id="ef-tags" type="text" value="' + CO_ENGRAM.escapeHtml((d.domainTags || []).join(', ')) + '"></div>'
418
+ + '<div class="field"><label class="field-label">上下文标签(逗号分隔)</label><input id="ef-ctx-tags" type="text" value="' + CO_ENGRAM.escapeHtml((d.contextTags || []).join(', ')) + '"></div>'
419
+ + '<div class="field"><label class="field-label"' + CO_ENGRAM.tip('visibility.public') + '>可见性</label><select id="ef-visibility"' + CO_ENGRAM.tip('visibility.public') + '>' + visOptions + '</select></div>'
420
+ + '<div class="field"><label class="field-label">内容(Markdown) '
421
+ + '<button type="button" class="btn secondary mini" id="ef-preview-toggle" onclick="CO_ENGRAM_ENGRAMS.togglePreview()">预览</button>'
422
+ + '<span id="ef-content-mode" class="kpi-sub">编辑模式</span></label>'
423
+ + '<textarea id="ef-content" rows="12">' + CO_ENGRAM.escapeHtml(d.content || '') + '</textarea>'
424
+ + '<div id="ef-content-preview" class="markdown-body" style="display:none;margin-top:0.5rem"></div></div>'
425
+ + '<div class="config-save-bar">'
426
+ + '<button class="btn secondary" onclick="CO_ENGRAM_ENGRAMS.cancel()">取消</button>'
427
+ + '<button class="btn" onclick="CO_ENGRAM_ENGRAMS.save()">保存</button>'
428
+ + '</div>';
429
+ CO_ENGRAM.openDrawer(body);
430
+ },
431
+
432
+ togglePreview() {
433
+ const ta = document.getElementById('ef-content');
434
+ const preview = document.getElementById('ef-content-preview');
435
+ const toggleBtn = document.getElementById('ef-preview-toggle');
436
+ const modeLabel = document.getElementById('ef-content-mode');
437
+ if (!ta || !preview || !toggleBtn || !modeLabel) return;
438
+ if (ta.style.display === 'none') {
439
+ // 当前是预览,切回编辑
440
+ ta.style.display = '';
441
+ preview.style.display = 'none';
442
+ toggleBtn.textContent = '预览';
443
+ modeLabel.textContent = '编辑模式';
444
+ } else {
445
+ // 当前是编辑,切到预览
446
+ preview.innerHTML = CO_ENGRAM.renderMarkdown(ta.value);
447
+ ta.style.display = 'none';
448
+ preview.style.display = '';
449
+ toggleBtn.textContent = '编辑';
450
+ modeLabel.textContent = '预览模式';
451
+ }
452
+ },
453
+
454
+ cancel() {
455
+ const d = CO_ENGRAM._currentEngram;
456
+ if (d) this._renderView(d);
457
+ },
458
+
459
+ async save() {
460
+ const d = CO_ENGRAM._currentEngram;
461
+ if (!d) return;
462
+ const patch = {
463
+ title: (document.getElementById('ef-title').value || '').trim(),
464
+ kind: document.getElementById('ef-kind').value,
465
+ importance: Number(document.getElementById('ef-importance').value),
466
+ confidence: Number(document.getElementById('ef-confidence').value),
467
+ domainTags: (document.getElementById('ef-tags').value || '').split(',').map(s => s.trim()).filter(Boolean),
468
+ contextTags: (document.getElementById('ef-ctx-tags').value || '').split(',').map(s => s.trim()).filter(Boolean),
469
+ visibility: document.getElementById('ef-visibility').value,
470
+ content: document.getElementById('ef-content').value
471
+ };
472
+ try {
473
+ const updated = await CO_ENGRAM.apiJson('/api/engrams/' + encodeURIComponent(d.id), 'PATCH', patch);
474
+ CO_ENGRAM._currentEngram = updated;
475
+ this._renderView(updated);
476
+ // 刷新列表缓存
477
+ try {
478
+ const data = await CO_ENGRAM.apiGet('/api/engrams');
479
+ CO_ENGRAM._engramsCache = data.results || [];
480
+ this.applyFilter();
481
+ } catch {}
482
+ } catch (e) { alert('保存失败:' + (e.message || e)); }
483
+ },
484
+
485
+ async confirmDelete() {
486
+ const d = CO_ENGRAM._currentEngram;
487
+ if (!d) return;
488
+ if (!confirm('确定要删除"' + (d.title || d.id) + '"?\\n此操作不可撤销。')) return;
489
+ try {
490
+ await CO_ENGRAM.apiJson('/api/engrams/' + encodeURIComponent(d.id), 'DELETE', null);
491
+ CO_ENGRAM.closeDrawer();
492
+ CO_ENGRAM._currentEngram = null;
493
+ // 重新加载列表
494
+ const root = document.getElementById('engrams-content');
495
+ if (root) {
496
+ CO_ENGRAM._engramsLoaded = false;
497
+ CO_ENGRAM._engramsCache = null;
498
+ await CO_ENGRAM_ENGRAMS.render(root);
499
+ }
500
+ } catch (e) { alert('删除失败:' + (e.message || e)); }
501
+ }
502
+ };
503
+
504
+ // ============================================================
505
+ // Proposals
506
+ // ============================================================
507
+ CO_ENGRAM.on('proposals', async function() {
508
+ const root = document.getElementById('proposals-content');
509
+ if (!root) return;
510
+ if (CO_ENGRAM._proposalsLoaded) return;
511
+ CO_ENGRAM._proposalsLoaded = true;
512
+ await CO_ENGRAM_PROPOSALS.render(root);
513
+ });
514
+
515
+ window.CO_ENGRAM_PROPOSALS = {
516
+ async render(root) {
517
+ root.innerHTML = '<div class="loading">加载提案中</div>';
518
+ let data;
519
+ try { data = await CO_ENGRAM.apiGet('/api/proposals?status=all'); }
520
+ catch (e) { root.innerHTML = '<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'; return; }
521
+
522
+ if (data.enabled === false) {
523
+ root.innerHTML = '<div class="empty"><div class="icon">💤</div>提案引擎未启用。设置环境变量 CO_ENGRAM_PROPOSALS_ENABLED=1 可开启。</div>';
524
+ return;
525
+ }
526
+
527
+ const all = data.results || [];
528
+ CO_ENGRAM._proposalsCache = all;
529
+ this._setStatus('pending');
530
+ },
531
+
532
+ /**
533
+ * 启发式推断 proposal 的标题和类型(后端 Proposal 没有这两个字段)。
534
+ *
535
+ * 标题:取 centroidExcerpt 首句,超过 50 字截断;空时回退 entityId。
536
+ * 类型:基于关键词匹配。中英双语关键词覆盖 5 种 EngramKind。
537
+ * - procedure:步骤/流程/how to/step
538
+ * - fact:应该/必须/always/never/事实
539
+ * - hypothesis:也许/可能/maybe/probably/假设
540
+ * - pattern:规律/总是/usually/pattern
541
+ * - observation:观察到/noticed/看到
542
+ * 默认 observation(中性、不强行猜)。
543
+ */
544
+ _inferMeta(p) {
545
+ const text = (p.centroidExcerpt || (p.sampleQuotes || [])[0] || '').toString();
546
+ const lower = text.toLowerCase();
547
+
548
+ let kind = 'observation';
549
+ if (/(步骤|流程|怎么|如何|how to|step|procedure|process|算法|流程图)/i.test(text)) kind = 'procedure';
550
+ else if (/(应该|必须|总是|事实|always|never|must|fact|规则|定律)/i.test(text)) kind = 'fact';
551
+ else if (/(也许|可能|猜测|假设|maybe|probably|hypoth|hypo|猜测)/i.test(text)) kind = 'hypothesis';
552
+ else if (/(规律|模式|通常|惯|pattern|usually|tend to|often)/i.test(text)) kind = 'pattern';
553
+ else if (/(观察|看到|发现|noticed|observed|saw|found)/i.test(text)) kind = 'observation';
554
+
555
+ let title = text.trim();
556
+ if (title) {
557
+ // 取首句作为标题。正则字面量里的换行转义在模板字符串里必须双重转义,
558
+ // 否则被当成字符串级 escape,变成真实换行符,破坏正则语法。
559
+ const firstClause = title.split(/[。.!?\\n??;;]/)[0].trim();
560
+ title = firstClause || title;
561
+ if (title.length > 50) title = title.slice(0, 50) + '…';
562
+ }
563
+ if (!title) title = p.entityId;
564
+
565
+ return { title, kind };
566
+ },
567
+
568
+ _setStatus(status) {
569
+ const cache = CO_ENGRAM._proposalsCache || [];
570
+ const filtered = status === 'all' ? cache : cache.filter(p => p.status === status);
571
+ const root = document.getElementById('proposals-content');
572
+ if (!root) return;
573
+
574
+ const L = CO_ENGRAM_LABELS;
575
+ const STATUS_LABEL = { pending: '待审', accepted: '已采纳', dismissed: '已驳回', all: '全部' };
576
+
577
+ const buttons = (current) => ['pending', 'accepted', 'dismissed', 'all'].map(s =>
578
+ '<button class="tab ' + (s === current ? 'active' : '') + '" onclick="CO_ENGRAM_PROPOSALS._setStatus(\\'' + s + '\\')">'
579
+ + STATUS_LABEL[s] + ' (' + (s === 'all' ? cache.length : cache.filter(p => p.status === s).length) + ')</button>'
580
+ ).join('');
581
+
582
+ let html = '<div style="margin-bottom:1rem">' + buttons(status) + '</div>';
583
+ if (!filtered.length) {
584
+ html += '<div class="empty"><div class="icon">✓</div>没有 ' + STATUS_LABEL[status] + ' 提案</div>';
585
+ } else {
586
+ html += '<div class="grid cols-3">';
587
+ for (const p of filtered) {
588
+ const meta = this._inferMeta(p);
589
+ const kindLabel = (L.kind && L.kind[meta.kind]) || meta.kind;
590
+ const preview = (p.centroidExcerpt || (p.sampleQuotes || [])[0] || '').toString();
591
+ const previewClip = preview.length > 120 ? preview.slice(0, 120) + '…' : preview;
592
+ const cardClick = p.status === 'pending'
593
+ ? ' style="cursor:pointer" onclick="CO_ENGRAM_PROPOSALS.open(\\'' + CO_ENGRAM.escapeHtml(p.entityId) + '\\')"'
594
+ : ' style="cursor:pointer" onclick="CO_ENGRAM_PROPOSALS.open(\\'' + CO_ENGRAM.escapeHtml(p.entityId) + '\\')"';
595
+
596
+ html += '<div class="card"' + cardClick + '>'
597
+ + '<div class="card-title" title="' + CO_ENGRAM.escapeHtml(p.entityId) + '">' + CO_ENGRAM.escapeHtml(meta.title) + '</div>';
598
+ html += '<div class="card-meta" style="margin-bottom:0.4rem">'
599
+ + '<span class="chip kind-' + meta.kind + '">' + CO_ENGRAM.escapeHtml(kindLabel) + '</span>'
600
+ + '<span>×' + (p.occurrences || 0) + '</span>'
601
+ + (p.createdAt ? '<span>' + CO_ENGRAM.relativeTime(p.createdAt) + '</span>' : '')
602
+ + '<span class="chip">' + (STATUS_LABEL[p.status] || p.status) + '</span>'
603
+ + '</div>';
604
+ if (previewClip) html += '<div style="font-size:0.8rem;color:var(--fg-muted);margin-bottom:0.4rem">' + CO_ENGRAM.escapeHtml(previewClip) + '</div>';
605
+ if (p.status === 'accepted' && p.acceptedEngramId) {
606
+ html += '<div class="card-meta"><span class="chip" style="background:rgba(16,185,129,.12);color:var(--accent)">已转 ▸ ' + CO_ENGRAM.escapeHtml(p.acceptedEngramId.slice(0, 12)) + '</span></div>';
607
+ }
608
+ if (p.status === 'dismissed' && p.dismissReason) {
609
+ html += '<div class="card-meta"><span class="chip" style="background:rgba(239,68,68,.12);color:#ef4444">驳回:' + CO_ENGRAM.escapeHtml((p.dismissReason || '').slice(0, 40)) + '</span></div>';
610
+ }
611
+ html += '</div>';
612
+ }
613
+ html += '</div>';
614
+ }
615
+ root.innerHTML = html;
616
+ },
617
+
618
+ /**
619
+ * 打开 proposal 详情 drawer,提供完整编辑表单。
620
+ * 用户可以在这里调整 title/kind/content/domainTags,然后 Accept 或 Dismiss。
621
+ * 替代原来 prompt() 的简陋交互。
622
+ */
623
+ open(entityId) {
624
+ const cache = CO_ENGRAM._proposalsCache || [];
625
+ const p = cache.find(x => x.entityId === entityId);
626
+ if (!p) { CO_ENGRAM.openDrawer('<div class="empty">提案未找到:' + CO_ENGRAM.escapeHtml(entityId) + '</div>'); return; }
627
+ CO_ENGRAM._currentProposal = p;
628
+
629
+ const L = CO_ENGRAM_LABELS;
630
+ const meta = this._inferMeta(p);
631
+ const samples = (p.sampleQuotes || []).map(s => '<pre class="pre-compact" style="margin:0.3rem 0">' + CO_ENGRAM.escapeHtml(s) + '</pre>').join('');
632
+ const kindOptions = Object.keys(L.kind || {}).map(k =>
633
+ '<option value="' + k + '"' + (meta.kind === k ? ' selected' : '') + '>' + (L.kind[k] || k) + '</option>'
634
+ ).join('');
635
+
636
+ const accepted = p.status === 'accepted';
637
+ const dismissed = p.status === 'dismissed';
638
+ const editable = p.status === 'pending';
639
+
640
+ let actionBtns = '';
641
+ if (editable) {
642
+ actionBtns = '<div class="config-save-bar">'
643
+ + '<button class="btn secondary" onclick="CO_ENGRAM_PROPOSALS.dismissFromForm()">驳回</button>'
644
+ + '<button class="btn" onclick="CO_ENGRAM_PROPOSALS.acceptFromForm()">采纳并保存</button>'
645
+ + '</div>';
646
+ } else {
647
+ actionBtns = '<div class="edit-banner">该提案当前状态:<strong>' + (L.status && L.status[p.status] || p.status) + '</strong>'
648
+ + (accepted && p.acceptedEngramId ? '<br>已创建记忆印迹:<code>' + CO_ENGRAM.escapeHtml(p.acceptedEngramId) + '</code>' : '')
649
+ + (dismissed && p.dismissedUntil ? '<br>驳回至:' + CO_ENGRAM.escapeHtml(p.dismissedUntil) : '')
650
+ + '</div>';
651
+ }
652
+
653
+ const body = '<div class="edit-banner" style="display:flex;gap:0.5rem;align-items:center">'
654
+ + '<strong style="margin-right:auto">候选提案详情</strong>'
655
+ + '<code style="font-size:0.75rem">' + CO_ENGRAM.escapeHtml(p.entityId) + '</code>'
656
+ + '</div>'
657
+ + '<div class="field"' + (editable ? '' : ' style="opacity:0.6"') + '>'
658
+ + '<label class="field-label">标题' + (editable ? '' : ' (只读)') + '</label>'
659
+ + '<input id="pf-title" type="text" value="' + CO_ENGRAM.escapeHtml(meta.title) + '"' + (editable ? '' : ' readonly') + '></div>'
660
+ + '<div class="field"'
661
+ + (editable ? '' : ' style="opacity:0.6"') + '>'
662
+ + '<label class="field-label"' + CO_ENGRAM.tip('kind.fact') + '>类型' + (editable ? '' : ' (只读)') + '</label>'
663
+ + '<select id="pf-kind"' + (editable ? '' : ' disabled') + '>' + kindOptions + '</select></div>'
664
+ + '<div class="field"' + (editable ? '' : ' style="opacity:0.6"') + '>'
665
+ + '<label class="field-label">领域标签(逗号分隔)' + (editable ? '' : ' (只读)') + '</label>'
666
+ + '<input id="pf-tags" type="text" placeholder="如:frontend, dark-mode, css"' + (editable ? '' : ' readonly') + '></div>'
667
+ + '<div class="field"' + (editable ? '' : ' style="opacity:0.6"') + '>'
668
+ + '<label class="field-label">内容(转成记忆印迹的正文)' + (editable ? '' : ' (只读)') + '</label>'
669
+ + '<textarea id="pf-content" rows="6"' + (editable ? '' : ' readonly') + '>' + CO_ENGRAM.escapeHtml(p.centroidExcerpt || '') + '</textarea></div>'
670
+ + '<h3>样本引用(' + (p.occurrences || 0) + ' 次累积)</h3>'
671
+ + (samples || '<div class="empty" style="padding:1rem">(无样本)</div>')
672
+ + '<div class="field"><span class="field-label">首次见到:</span>' + CO_ENGRAM.escapeHtml(p.firstSeenAt || '—')
673
+ + ' <span class="field-label">最后见到:</span>' + CO_ENGRAM.escapeHtml(p.lastSeenAt || '—') + '</div>'
674
+ + actionBtns;
675
+
676
+ CO_ENGRAM.openDrawer(body);
677
+ },
678
+
679
+ async acceptFromForm() {
680
+ const p = CO_ENGRAM._currentProposal;
681
+ if (!p) return;
682
+ const title = (document.getElementById('pf-title').value || '').trim();
683
+ const content = (document.getElementById('pf-content').value || '').trim();
684
+ const kind = document.getElementById('pf-kind').value;
685
+ const tags = (document.getElementById('pf-tags').value || '').split(',').map(s => s.trim()).filter(Boolean);
686
+ if (!title) { alert('请填写标题'); return; }
687
+ if (!content) { alert('请填写内容'); return; }
688
+ try {
689
+ const r = await CO_ENGRAM.apiJson('/api/proposals/' + encodeURIComponent(p.entityId) + '/accept', 'POST', { title, content, kind, domainTags: tags });
690
+ CO_ENGRAM.closeDrawer();
691
+ CO_ENGRAM._proposalsLoaded = false;
692
+ await this.render(document.getElementById('proposals-content'));
693
+ const engramId = r && r.engramId ? r.engramId : '';
694
+ alert('✓ 已采纳' + (engramId ? '\\n创建记忆印迹:' + engramId : ''));
695
+ } catch (e) { alert('采纳失败:' + (e.message || e)); }
696
+ },
697
+
698
+ async dismissFromForm() {
699
+ const p = CO_ENGRAM._currentProposal;
700
+ if (!p) return;
701
+ const reason = prompt('驳回理由(可选):', '') || '';
702
+ const daysStr = prompt('驳回 N 天(默认 30):', '30') || '30';
703
+ const dismissDays = Number(daysStr) || 30;
704
+ try {
705
+ await CO_ENGRAM.apiJson('/api/proposals/' + encodeURIComponent(p.entityId) + '/dismiss', 'POST', { reason, dismissDays });
706
+ CO_ENGRAM.closeDrawer();
707
+ CO_ENGRAM._proposalsLoaded = false;
708
+ await this.render(document.getElementById('proposals-content'));
709
+ } catch (e) { alert('驳回失败:' + (e.message || e)); }
710
+ },
711
+
712
+ /** @deprecated 保留旧 API 兼容(从其他地方调用),内部走 open() */
713
+ async accept(entityId) {
714
+ this.open(entityId);
715
+ },
716
+
717
+ async dismiss(entityId) {
718
+ const p = (CO_ENGRAM._proposalsCache || []).find(x => x.entityId === entityId);
719
+ if (!p) return;
720
+ CO_ENGRAM._currentProposal = p;
721
+ await this.dismissFromForm();
722
+ }
723
+ };
724
+
725
+ // ============================================================
726
+ // Audit
727
+ // ============================================================
728
+ CO_ENGRAM.on('audit', async function() {
729
+ const root = document.getElementById('audit-content');
730
+ if (!root) return;
731
+ if (CO_ENGRAM._auditLoaded) return;
732
+ CO_ENGRAM._auditLoaded = true;
733
+
734
+ const filterBar = '<div class="filter-bar">'
735
+ + '<label>发起者 <select id="audit-actor" onchange="CO_ENGRAM_AUDIT.applyFilter()">'
736
+ + '<option value="">全部</option><option value="user">用户</option><option value="llm">LLM</option><option value="system">系统</option></select></label>'
737
+ + '<label>类别 <select id="audit-cat" onchange="CO_ENGRAM_AUDIT.applyFilter()">'
738
+ + '<option value="">全部</option>'
739
+ + '<option value="state">状态变更</option>'
740
+ + '<option value="effective">有效性</option>'
741
+ + '<option value="contradicted">矛盾</option>'
742
+ + '<option value="proposal">提案</option></select></label>'
743
+ + '<input type="search" id="audit-engram" placeholder="按记忆印迹编号过滤..." oninput="CO_ENGRAM_AUDIT.applyFilter()">'
744
+ + '<span class="chip removable audit-action-chip" id="audit-action-chip" style="display:none" title="点击清除 action 过滤" onclick="CO_ENGRAM_AUDIT.clearActionFilter()"></span>'
745
+ + '<span class="spacer"></span>'
746
+ + '<span class="chip" id="audit-count">—</span>'
747
+ + '</div>'
748
+ + '<div id="audit-stats" class="kpi-grid" style="margin-bottom:1rem"></div>'
749
+ + '<div id="audit-timeline" class="timeline"></div>';
750
+ root.innerHTML = filterBar;
751
+ await CO_ENGRAM_AUDIT.load();
752
+ });
753
+
754
+ window.CO_ENGRAM_AUDIT = {
755
+ async load() {
756
+ const tl = document.getElementById('audit-timeline');
757
+ if (!tl) return;
758
+ tl.innerHTML = '<div class="loading">加载审计日志中</div>';
759
+ let data, engramsData;
760
+ try {
761
+ // 并行拉 audit + engrams(后者用来判断 engramId 是否仍存在,决定显示可点 chip 还是灰色)
762
+ [data, engramsData] = await Promise.all([
763
+ CO_ENGRAM.apiGet('/api/audit?limit=500'),
764
+ CO_ENGRAM.apiGet('/api/engrams?limit=10000').catch(() => ({ results: [] })),
765
+ ]);
766
+ } catch (e) { tl.innerHTML = '<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'; return; }
767
+
768
+ if (data.enabled === false) {
769
+ tl.innerHTML = '<div class="empty"><div class="icon">💤</div>审计日志未启用。</div>';
770
+ return;
771
+ }
772
+ this._existingIds = new Set((engramsData.results || []).map(e => e.id));
773
+ this._cache = (data.results || []).slice().sort((a, b) => (a.ts < b.ts ? 1 : -1));
774
+ this._renderStats();
775
+ this.applyFilter();
776
+ },
777
+
778
+ _renderStats() {
779
+ const el = document.getElementById('audit-stats');
780
+ if (!el) return;
781
+ const cache = this._cache || [];
782
+ const cat = { state: 0, effective: 0, contradicted: 0, proposal: 0 };
783
+ for (const e of cache) {
784
+ const cls = CO_ENGRAM.auditActionClass(e.action);
785
+ if (cls === 'audit-state') cat.state++;
786
+ else if (cls === 'audit-effective') cat.effective++;
787
+ else if (cls === 'audit-contradicted') cat.contradicted++;
788
+ else cat.proposal++;
789
+ }
790
+ const kpi = (label, n, color) => '<div class="kpi"><div class="kpi-label">' + label + '</div>'
791
+ + '<div class="kpi-value" style="color:' + color + '">' + n + '</div></div>';
792
+ el.innerHTML = kpi('总计', cache.length, 'var(--fg)')
793
+ + kpi('状态变更', cat.state, '#3b82f6')
794
+ + kpi('有效性信号', cat.effective, '#10b981')
795
+ + kpi('矛盾', cat.contradicted, '#ef4444')
796
+ + kpi('提案', cat.proposal, '#8b5cf6');
797
+ },
798
+
799
+ applyFilter() {
800
+ const cache = this._cache || [];
801
+ const actor = (document.getElementById('audit-actor') || {}).value || '';
802
+ const cat = (document.getElementById('audit-cat') || {}).value || '';
803
+ const engramQ = ((document.getElementById('audit-engram') || {}).value || '').toLowerCase();
804
+ const actionFilter = this._actionFilter || '';
805
+
806
+ const filtered = cache.filter(e => {
807
+ if (actor && e.actor !== actor) return false;
808
+ if (cat && CO_ENGRAM.auditActionClass(e.action) !== 'audit-' + cat) return false;
809
+ if (engramQ && !(e.engramId || '').toLowerCase().includes(engramQ)) return false;
810
+ if (actionFilter && e.action !== actionFilter) return false;
811
+ return true;
812
+ });
813
+
814
+ const countEl = document.getElementById('audit-count');
815
+ if (countEl) countEl.textContent = filtered.length + ' / ' + cache.length;
816
+
817
+ // 同步 action chip 显示
818
+ const chipEl = document.getElementById('audit-action-chip');
819
+ if (chipEl) {
820
+ if (actionFilter) {
821
+ chipEl.style.display = 'inline-flex';
822
+ chipEl.innerHTML = 'action=<code>' + CO_ENGRAM.escapeHtml(actionFilter) + '</code> ✕';
823
+ } else {
824
+ chipEl.style.display = 'none';
825
+ chipEl.innerHTML = '';
826
+ }
827
+ }
828
+
829
+ const tl = document.getElementById('audit-timeline');
830
+ if (!tl) return;
831
+ if (!filtered.length) {
832
+ tl.innerHTML = '<div class="empty"><div class="icon">—</div>没有匹配的事件</div>';
833
+ return;
834
+ }
835
+
836
+ const ACTOR_LETTER = { user: 'U', llm: 'L', system: 'S' };
837
+ const ACTOR_TIP = { user: '用户 (user):由人工触发的事件', llm: 'LLM (llm):由语言模型 agent 触发的事件', system: '系统 (system):由后台维护/自愈流程触发的事件' };
838
+ const ACTION_TIP = {
839
+ // 状态变更
840
+ create: 'create:创建新记忆印迹',
841
+ update: 'update:修改已有印迹的字段',
842
+ update_lifecycle: 'update_lifecycle:状态迁移(archived/forgotten)',
843
+ reinforce: 'reinforce:强化(LTP)— 检索有效、闭环成功',
844
+ report_failure: 'report_failure:负向反馈(LTD)— 检索不准、闭环失败',
845
+ forget: 'forget:标记为 forgotten',
846
+ restore: 'restore:从 forgotten/archived 恢复为 active',
847
+ sweep_to_trash: 'sweep_to_trash:forgotten 满 30 天,文件移到 .trash/',
848
+ restore_from_trash: 'restore_from_trash:从 .trash/ 物理恢复',
849
+ purge: 'purge:硬删除(内容 + 元 + 关联突触)',
850
+ // 有效性
851
+ retrieve_hit: 'retrieve_hit:搜索命中',
852
+ retrieve_effective: 'retrieve_effective:命中后被实际采用',
853
+ retrieve_inconclusive: 'retrieve_inconclusive:命中但不确定是否有效',
854
+ // 矛盾
855
+ contradicted: 'contradicted:检测到与其他印迹冲突,进入裁决流程',
856
+ // 提案
857
+ propose: 'propose:捕获到候选记忆',
858
+ accept: 'accept:采纳候选,转化为正式印迹',
859
+ dismiss: 'dismiss:驳回候选'
860
+ };
861
+ tl.innerHTML = filtered.slice(0, 300).map(e => {
862
+ return CO_ENGRAM_AUDIT.renderRow(e, ACTOR_LETTER, ACTOR_TIP, ACTION_TIP);
863
+ }).join('');
864
+ },
865
+
866
+ /** 点击 action 标签 → 按该 action 精确过滤;再次点同一个 → 清除 */
867
+ filterByAction(action) {
868
+ if (!action) return;
869
+ this._actionFilter = this._actionFilter === action ? '' : action;
870
+ this.applyFilter();
871
+ },
872
+
873
+ /** 清除 action 过滤(点击 chip 触发) */
874
+ clearActionFilter() {
875
+ this._actionFilter = '';
876
+ this.applyFilter();
877
+ },
878
+
879
+ /** 把任意 metadata value 渲染为可读的短字符串(单值,用于 chip / kv) */
880
+ _formatVal(v) {
881
+ if (v == null) return 'null';
882
+ if (typeof v === 'string') {
883
+ const s = v.length > 60 ? v.slice(0, 57) + '…' : v;
884
+ return '"' + s + '"';
885
+ }
886
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
887
+ if (Array.isArray(v)) {
888
+ if (v.length === 0) return '[]';
889
+ const sample = v.slice(0, 3).map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(', ');
890
+ return '[' + sample + (v.length > 3 ? ', …×' + (v.length - 3) : '') + ']';
891
+ }
892
+ try { return JSON.stringify(v); } catch { return String(v); }
893
+ },
894
+
895
+ /** 结构化渲染 audit metadata */
896
+ renderMeta(e) {
897
+ const m = e.metadata || {};
898
+ const keys = Object.keys(m);
899
+ if (keys.length === 0) return '<span class="audit-meta-empty">—</span>';
900
+
901
+ // 1. update 类:有 changes 字段 → 渲染 changed fields
902
+ if (m.changes && typeof m.changes === 'object' && !Array.isArray(m.changes)) {
903
+ const fields = Object.keys(m.changes);
904
+ if (fields.length === 0) {
905
+ return '<span class="audit-meta-empty">(无字段实际变化)</span>';
906
+ }
907
+ const rows = fields.map(f => {
908
+ const ch = m.changes[f] || {};
909
+ const from = CO_ENGRAM.escapeHtml(CO_ENGRAM_AUDIT._formatVal(ch.from));
910
+ const to = CO_ENGRAM.escapeHtml(CO_ENGRAM_AUDIT._formatVal(ch.to));
911
+ return '<div class="audit-change-row">'
912
+ + '<span class="audit-field">' + CO_ENGRAM.escapeHtml(f) + '</span>'
913
+ + '<span class="audit-from">' + from + '</span>'
914
+ + '<span class="audit-arrow">→</span>'
915
+ + '<span class="audit-to">' + to + '</span>'
916
+ + '</div>';
917
+ }).join('');
918
+ // 如果还有其他 metadata(updatedBy 等),追加显示
919
+ const extra = keys.filter(k => k !== 'changes').map(k =>
920
+ '<span class="audit-kv"><b>' + CO_ENGRAM.escapeHtml(k) + '</b>='
921
+ + CO_ENGRAM.escapeHtml(CO_ENGRAM_AUDIT._formatVal(m[k])) + '</span>'
922
+ ).join(' ');
923
+ return '<div class="audit-changes">' + rows + '</div>' + (extra ? '<div class="audit-meta-extra">' + extra + '</div>' : '');
924
+ }
925
+
926
+ // 2. synapse 类(target=synapse):渲染 synapse 关键字段
927
+ if (m.target === 'synapse') {
928
+ const chips = ['<span class="chip synapse-chip">突触</span>'];
929
+ if (m.kind) chips.push('<span class="chip kind-' + CO_ENGRAM.escapeHtml(m.kind) + '">' + CO_ENGRAM.escapeHtml(m.kind) + '</span>');
930
+ if (m.direction) chips.push('<span class="chip">' + CO_ENGRAM.escapeHtml(m.direction) + '</span>');
931
+ if (typeof m.weight === 'number') chips.push('<span class="chip">w=' + m.weight.toFixed(2) + '</span>');
932
+ if (m.to) chips.push('<span class="chip">→ ' + CO_ENGRAM.escapeHtml(m.to.length > 18 ? m.to.slice(0, 16) + '…' : m.to) + '</span>');
933
+ // 其它字段
934
+ const extras = keys.filter(k => !['target', 'kind', 'direction', 'weight', 'to', 'from', 'synapseId', 'createdBy'].includes(k))
935
+ .map(k => '<span class="audit-kv"><b>' + CO_ENGRAM.escapeHtml(k) + '</b>=' + CO_ENGRAM.escapeHtml(CO_ENGRAM_AUDIT._formatVal(m[k])) + '</span>').join(' ');
936
+ return '<div class="audit-chips">' + chips.join(' ') + (extras ? ' <span class="audit-meta-extra">' + extras + '</span>' : '') + '</div>';
937
+ }
938
+
939
+ // 3. 通用 fallback:键值对
940
+ const kvs = keys.map(k =>
941
+ '<span class="audit-kv"><b>' + CO_ENGRAM.escapeHtml(k) + '</b>='
942
+ + CO_ENGRAM.escapeHtml(CO_ENGRAM_AUDIT._formatVal(m[k])) + '</span>'
943
+ ).join(' ');
944
+ return '<div class="audit-chips">' + kvs + '</div>';
945
+ },
946
+
947
+ /** 渲染单条 audit row,判断 engram/synapse 是否仍存在,生成可点按钮或灰色文本 */
948
+ renderRow(e, ACTOR_LETTER, ACTOR_TIP, ACTION_TIP) {
949
+ const ts = CO_ENGRAM.relativeTime(e.ts);
950
+ const tsFull = CO_ENGRAM.escapeHtml(e.ts);
951
+ const cls = CO_ENGRAM.auditActionClass(e.action);
952
+ const actorLetter = ACTOR_LETTER[e.actor] || '?';
953
+ const actorTip = ACTOR_TIP[e.actor] || e.actor || '';
954
+ const actionTip = ACTION_TIP[e.action] || e.action || '';
955
+
956
+ const existing = this._existingIds || new Set();
957
+ const m = e.metadata || {};
958
+ const isSynapse = m.target === 'synapse' || !!m.synapseId;
959
+ // synapse 类:目标 from engram(用 metadata.from 优先,fallback 到 engramId)
960
+ // engram 类:目标 = engramId
961
+ const targetId = isSynapse ? (m.from || e.engramId) : e.engramId;
962
+ const targetExists = !!targetId && existing.has(targetId);
963
+
964
+ let targetCell;
965
+ if (!targetId) {
966
+ targetCell = '<span class="audit-target-none">—</span>';
967
+ } else if (targetExists) {
968
+ const short = targetId.length > 22 ? targetId.slice(0, 20) + '…' : targetId;
969
+ const label = isSynapse ? '🌐 打开源印迹' : '📄 打开印迹';
970
+ targetCell = '<button class="btn-link audit-target-exists" title="' + CO_ENGRAM.escapeHtml(targetId) + '" '
971
+ + 'onclick="CO_ENGRAM.showTab(\\'engrams\\');setTimeout(()=>CO_ENGRAM_ENGRAMS.open(\\'' + CO_ENGRAM.escapeHtml(targetId) + '\\'),50)">'
972
+ + label + ' <code>' + CO_ENGRAM.escapeHtml(short) + '</code></button>';
973
+ } else {
974
+ // 目标已不存在(被 purge / delete)→ 灰色删除线
975
+ const short = targetId.length > 22 ? targetId.slice(0, 20) + '…' : targetId;
976
+ targetCell = '<span class="audit-target-gone" title="目标已不存在:' + CO_ENGRAM.escapeHtml(targetId) + '">'
977
+ + (isSynapse ? '🌐 ' : '📄 ') + '<code><s>' + CO_ENGRAM.escapeHtml(short) + '</s></code> <em>(已删除)</em></span>';
978
+ }
979
+
980
+ const metaHtml = this.renderMeta(e);
981
+
982
+ const isActive = this._actionFilter === e.action;
983
+ const actionBtnClass = 'action ' + cls + ' action-button' + (isActive ? ' active' : '');
984
+
985
+ return '<div class="timeline-row audit-row">'
986
+ + '<span class="ts" title="' + tsFull + '">' + ts + '</span>'
987
+ + '<span class="actor-icon ' + e.actor + '" title="' + CO_ENGRAM.escapeHtml(actorTip) + '">' + actorLetter + '</span>'
988
+ + '<button type="button" class="' + actionBtnClass + '" title="' + CO_ENGRAM.escapeHtml(actionTip) + ' — 点击仅显示此类事件" onclick="CO_ENGRAM_AUDIT.filterByAction(\\'' + CO_ENGRAM.escapeHtml(e.action) + '\\')">' + CO_ENGRAM.escapeHtml(e.action) + '</button>'
989
+ + targetCell
990
+ + '<div class="metadata audit-meta-cell">' + metaHtml + '</div>'
991
+ + '</div>';
992
+ }
993
+ };
994
+
995
+ // ============================================================
996
+ // Merges (P4.3) — git merge driver health dashboard
997
+ // ============================================================
998
+ CO_ENGRAM.on('merges', async function() {
999
+ const root = document.getElementById('merges-content');
1000
+ if (!root) return;
1001
+ if (CO_ENGRAM._mergesLoaded) return;
1002
+ CO_ENGRAM._mergesLoaded = true;
1003
+ await CO_ENGRAM_MERGES.render(root);
1004
+ });
1005
+
1006
+ window.CO_ENGRAM_MERGES = {
1007
+ async render(root) {
1008
+ root.innerHTML = '<div class="loading">加载合并统计中</div>';
1009
+ let payload;
1010
+ try {
1011
+ payload = await CO_ENGRAM.apiGet('/api/merge-stats?windowDays=7');
1012
+ } catch (e) {
1013
+ root.innerHTML = '<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>';
1014
+ return;
1015
+ }
1016
+ if (!payload.enabled || !payload.stats) {
1017
+ root.innerHTML = '<div class="empty">audit log 未启用,无合并数据。</div>';
1018
+ return;
1019
+ }
1020
+
1021
+ // 异常告警横幅(spec §13.2)— 失败不阻塞主统计渲染
1022
+ let banner = '';
1023
+ try {
1024
+ const anom = await CO_ENGRAM.apiGet('/api/merge-anomalies?windowDays=7');
1025
+ if (anom.enabled && anom.anomalies && anom.anomalies.length > 0) {
1026
+ const items = anom.anomalies.map(function(a) {
1027
+ const cls = a.severity === 'critical' ? 'anom-critical' : (a.severity === 'warning' ? 'anom-warning' : 'anom-info');
1028
+ const icon = a.severity === 'critical' ? '✗' : (a.severity === 'warning' ? '⚠' : 'ℹ');
1029
+ return '<li class="' + cls + '"><span class="anom-icon">' + icon + '</span><strong>' + CO_ENGRAM.escapeHtml(a.kind) + '</strong>: ' + CO_ENGRAM.escapeHtml(a.message) + '</li>';
1030
+ }).join('');
1031
+ banner = '<div class="anomaly-banner"><h3>异常告警 · ' + anom.anomalies.length + ' 条</h3><ul>' + items + '</ul></div>';
1032
+ }
1033
+ } catch (_) { /* anomaly API 可选,失败静默 */ }
1034
+
1035
+ root.innerHTML = banner + CO_ENGRAM_MERGES.renderHtml(payload.stats, payload.windowDays);
1036
+ },
1037
+
1038
+ renderHtml(s, windowDays) {
1039
+ const pct = (r) => (r * 100).toFixed(1) + '%';
1040
+
1041
+ const kpi = (label, value, sub) =>
1042
+ '<div class="kpi">' +
1043
+ '<div class="kpi-label">' + CO_ENGRAM.escapeHtml(label) + '</div>' +
1044
+ '<div class="kpi-value">' + CO_ENGRAM.escapeHtml(String(value)) + '</div>' +
1045
+ (sub ? '<div class="kpi-sub">' + CO_ENGRAM.escapeHtml(sub) + '</div>' : '') +
1046
+ '</div>';
1047
+
1048
+ const bar = (label, count, max, color) =>
1049
+ '<div class="bar-row">' +
1050
+ '<div class="bar-label">' + CO_ENGRAM.escapeHtml(label) + '</div>' +
1051
+ '<div class="bar-track"><div class="bar-fill" style="width:' + (max ? (count / max * 100).toFixed(1) : 0) + '%;background:' + (color || '#5eead4') + '"></div></div>' +
1052
+ '<div class="bar-value">' + count + '</div>' +
1053
+ '</div>';
1054
+
1055
+ let html = '<div class="panel">';
1056
+ html += '<div class="panel-header"><h2>合并统计 · 最近 ' + windowDays + ' 天</h2></div>';
1057
+ html += '<div class="kpi-grid" style="grid-template-columns:repeat(auto-fit,minmax(140px,1fr));margin-bottom:1.5rem">';
1058
+ html += kpi('总合并', s.totalMerges);
1059
+ html += kpi('自动解决', s.autoResolved, pct(s.autoResolveRate));
1060
+ html += kpi('升级到冲突标记', s.escalatedToMarkers);
1061
+ html += kpi('Backup 失败', s.backupFailures);
1062
+ html += '</div>';
1063
+
1064
+ // LLM 段
1065
+ html += '<h3 style="margin-top:1.5rem">LLM 仲裁</h3>';
1066
+ html += '<div class="kpi-grid" style="grid-template-columns:repeat(auto-fit,minmax(140px,1fr));margin-bottom:1rem">';
1067
+ html += kpi('总调用', s.llm.totalInvocations);
1068
+ html += kpi('成功', s.llm.arbitrated);
1069
+ html += kpi('升级', s.llm.escalated);
1070
+ html += kpi('失败', s.llm.failed);
1071
+ html += kpi('成功率', pct(s.llm.successRate));
1072
+ html += '</div>';
1073
+
1074
+ // 按策略
1075
+ const strategies = Object.entries(s.byStrategy || {}).sort((a, b) => b[1] - a[1]);
1076
+ if (strategies.length > 0) {
1077
+ const max = strategies[0][1];
1078
+ html += '<h3 style="margin-top:1.5rem">解决策略分布(Top 8)</h3>';
1079
+ html += '<div style="margin-bottom:1rem">';
1080
+ for (const [name, count] of strategies.slice(0, 8)) {
1081
+ html += bar(name, count, max, '#5eead4');
1082
+ }
1083
+ html += '</div>';
1084
+ }
1085
+
1086
+ // Hot paths
1087
+ const paths = Object.entries(s.byPath || {}).sort((a, b) => b[1] - a[1]);
1088
+ if (paths.length > 0) {
1089
+ const max = paths[0][1];
1090
+ html += '<h3 style="margin-top:1.5rem">冲突热点路径(Top 8)</h3>';
1091
+ html += '<div style="margin-bottom:1rem">';
1092
+ for (const [p, count] of paths.slice(0, 8)) {
1093
+ html += bar(p, count, max, '#fbbf24');
1094
+ }
1095
+ html += '</div>';
1096
+ }
1097
+
1098
+ // 按天趋势
1099
+ const days = Object.entries(s.byDay || {}).sort((a, b) => a[0].localeCompare(b[0]));
1100
+ if (days.length > 0) {
1101
+ const max = Math.max(...days.map((d) => d[1]));
1102
+ html += '<h3 style="margin-top:1.5rem">每日合并量(趋势)</h3>';
1103
+ html += '<div style="margin-bottom:1rem">';
1104
+ for (const [day, count] of days) {
1105
+ html += bar(day, count, max, '#60a5fa');
1106
+ }
1107
+ html += '</div>';
1108
+ }
1109
+
1110
+ html += '</div>';
1111
+ return html;
1112
+ }
1113
+ };
1114
+
1115
+ // ============================================================
1116
+ // Trash
1117
+ // ============================================================
1118
+ CO_ENGRAM.on('trash', async function() {
1119
+ const root = document.getElementById('trash-content');
1120
+ if (!root) return;
1121
+ if (CO_ENGRAM._trashLoaded) return;
1122
+ CO_ENGRAM._trashLoaded = true;
1123
+ await CO_ENGRAM_TRASH.render(root);
1124
+ });
1125
+
1126
+ window.CO_ENGRAM_TRASH = {
1127
+ async render(root) {
1128
+ root.innerHTML = '<div class="loading">加载回收站中</div>';
1129
+ let data;
1130
+ try { data = await CO_ENGRAM.apiGet('/api/trash'); }
1131
+ catch (e) { root.innerHTML = '<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'; return; }
1132
+
1133
+ const items = data.results || [];
1134
+ if (!items.length) {
1135
+ root.innerHTML = '<div class="empty"><div class="icon">🗑️</div>回收站为空</div>';
1136
+ return;
1137
+ }
1138
+ // 顶部工具栏:统计 + 分区筛选 + 一键清空
1139
+ const partitions = [...new Set(items.map((t) => t.partition).filter(Boolean))].sort();
1140
+ const total = items.length;
1141
+ let html = '<div class="card" style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem">'
1142
+ + '<strong style="margin-right:auto">回收站 · 共 ' + total + ' 条</strong>';
1143
+ if (partitions.length > 1) {
1144
+ html += '<label style="font-weight:normal;font-size:0.9rem">分区:'
1145
+ + '<select id="trash-partition-filter" onchange="CO_ENGRAM_TRASH.applyFilter()" style="margin-left:0.4rem">'
1146
+ + '<option value="">全部</option>'
1147
+ + partitions.map((p) => '<option value="' + CO_ENGRAM.escapeHtml(p) + '">' + CO_ENGRAM.escapeHtml(p) + '</option>').join('')
1148
+ + '</select></label>';
1149
+ }
1150
+ html += '<button class="btn secondary" onclick="CO_ENGRAM_TRASH.purgeAll(false)">永久清空全部</button>'
1151
+ + '</div>';
1152
+
1153
+ html += '<table class="data-table" id="trash-table"><thead><tr>'
1154
+ + '<th>ID</th><th>分区</th><th>回收时间</th><th></th>'
1155
+ + '</tr></thead><tbody>';
1156
+ for (const t of items) {
1157
+ const part = t.partition || '';
1158
+ html += '<tr data-partition="' + CO_ENGRAM.escapeHtml(part) + '">'
1159
+ + '<td><code>' + CO_ENGRAM.escapeHtml(t.id) + '</code></td>'
1160
+ + '<td>' + CO_ENGRAM.escapeHtml(t.partition || '—') + '</td>'
1161
+ + '<td>' + CO_ENGRAM.escapeHtml(t.trashedAt || '—') + '</td>'
1162
+ + '<td>'
1163
+ + '<button class="btn-link" onclick="CO_ENGRAM_TRASH.preview(\\'' + CO_ENGRAM.escapeHtml(t.id) + '\\')">查看</button> '
1164
+ + '<button class="btn secondary" onclick="CO_ENGRAM_TRASH.restore(\\'' + CO_ENGRAM.escapeHtml(t.id) + '\\')">恢复</button>'
1165
+ + '</td>'
1166
+ + '</tr>';
1167
+ }
1168
+ html += '</tbody></table>';
1169
+ root.innerHTML = html;
1170
+ },
1171
+
1172
+ // 分区筛选:隐藏不匹配的行
1173
+ applyFilter() {
1174
+ const sel = document.getElementById('trash-partition-filter');
1175
+ const v = sel ? sel.value : '';
1176
+ document.querySelectorAll('#trash-table tbody tr').forEach((tr) => {
1177
+ const p = tr.getAttribute('data-partition') || '';
1178
+ tr.style.display = (!v || p === v) ? '' : 'none';
1179
+ });
1180
+ },
1181
+
1182
+ // 预览单条回收站内容(只读 drawer)
1183
+ async preview(id) {
1184
+ let d;
1185
+ try { d = await CO_ENGRAM.apiGet('/api/trash/' + encodeURIComponent(id)); }
1186
+ catch (e) { alert('加载失败:' + (e.message || e)); return; }
1187
+
1188
+ const fm = d.frontmatter || {};
1189
+ const L = CO_ENGRAM_LABELS;
1190
+ const kind = fm.kind ? (L.kind[fm.kind] || fm.kind) : '';
1191
+ const status = fm.status ? (L.status[fm.status] || fm.status) : '';
1192
+ const valence = fm.emotionalValence ? (L.emotionalValence[fm.emotionalValence] || fm.emotionalValence) : '';
1193
+ const source = fm.sourceType ? (L.sourceType[fm.sourceType] || fm.sourceType) : '';
1194
+
1195
+ const body = '<div class="edit-banner" style="background:rgba(239,68,68,.08);border-left:3px solid #ef4444;padding:0.6rem 0.8rem;margin-bottom:0.8rem">'
1196
+ + '<strong>回收站预览</strong> · 此记忆已被移出主索引,需先"恢复"才能再次编辑或召回。'
1197
+ + '</div>'
1198
+ + '<h2>' + CO_ENGRAM.escapeHtml(fm.title || id) + '</h2>'
1199
+ + '<div class="field"><span class="field-label">ID:</span><code>' + CO_ENGRAM.escapeHtml(id) + '</code></div>'
1200
+ + '<div class="field">'
1201
+ + (kind ? '<span class="chip kind-' + fm.kind + '"' + CO_ENGRAM.tip('kind.' + fm.kind) + '>' + kind + '</span> ' : '')
1202
+ + (status ? '<span class="field-label"' + CO_ENGRAM.tip('status.' + fm.status) + '>状态:</span>' + CO_ENGRAM.escapeHtml(status) : '')
1203
+ + '</div>'
1204
+ + (fm.domainTags && fm.domainTags.length
1205
+ ? '<div class="field"><span class="field-label">领域标签:</span>' + fm.domainTags.map((t) => '<span class="chip">' + CO_ENGRAM.escapeHtml(t) + '</span>').join(' ') + '</div>'
1206
+ : '')
1207
+ + '<div class="field"><span class="field-label">分区:</span>' + CO_ENGRAM.escapeHtml(d.partition || '—')
1208
+ + ' <span class="field-label"' + CO_ENGRAM.tip('lastEffectiveAt') + '>回收时间:</span>' + CO_ENGRAM.escapeHtml(d.trashedAt || '—') + '</div>'
1209
+ + (valence ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('emotionalValence.' + fm.emotionalValence) + '>情感极性:</span>' + CO_ENGRAM.escapeHtml(valence) + '</div>' : '')
1210
+ + (source ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('sourceType.' + fm.sourceType) + '>来源:</span>' + CO_ENGRAM.escapeHtml(source) + '</div>' : '')
1211
+ + (fm.createdBy ? '<div class="field"><span class="field-label">创建者:</span>' + CO_ENGRAM.escapeHtml(fm.createdBy) + '</div>' : '')
1212
+ + '<h3>内容</h3><div class="markdown-body">' + CO_ENGRAM.renderMarkdown(d.content || '') + '</div>'
1213
+ + '<div style="margin-top:1rem;display:flex;gap:0.5rem">'
1214
+ + '<button class="btn" onclick="CO_ENGRAM.closeDrawer();CO_ENGRAM_TRASH.restore(\\'' + CO_ENGRAM.escapeHtml(id) + '\\')">恢复到主索引</button>'
1215
+ + '<button class="btn secondary" onclick="CO_ENGRAM.closeDrawer()">关闭</button>'
1216
+ + '</div>';
1217
+ CO_ENGRAM.openDrawer(body);
1218
+ },
1219
+
1220
+ async restore(id) {
1221
+ if (!confirm('恢复 ' + id + ' 到主索引?')) return;
1222
+ try {
1223
+ await CO_ENGRAM.apiJson('/api/trash/' + encodeURIComponent(id) + '/restore', 'POST', {});
1224
+ CO_ENGRAM._trashLoaded = false;
1225
+ await this.render(document.getElementById('trash-content'));
1226
+ } catch (e) { alert('恢复失败:' + (e.message || e)); }
1227
+ },
1228
+
1229
+ // 永久清空:byPartition=true 只清当前筛选分区,false=清全部
1230
+ async purgeAll(byPartition) {
1231
+ const filterSel = document.getElementById('trash-partition-filter');
1232
+ const part = byPartition && filterSel ? filterSel.value : '';
1233
+ const scope = part ? '分区 ' + part + ' 内' : '全部(跨所有分区)';
1234
+ // 先 dryRun 看看会删多少条
1235
+ let preview;
1236
+ try {
1237
+ const url = '/api/trash?dryRun=1' + (part ? '&partition=' + encodeURIComponent(part) : '');
1238
+ preview = await CO_ENGRAM.apiGet(url);
1239
+ } catch (e) { alert('预扫描失败:' + (e.message || e)); return; }
1240
+
1241
+ const n = preview.count || 0;
1242
+ if (n === 0) { alert('当前范围无内容可清空'); return; }
1243
+ if (!confirm('即将永久删除 ' + scope + ' 的 ' + n + ' 条记忆。\\n此操作不可撤销(物理 unlink),即使有 git 仓库也只能从历史 commit 恢复。\\n\\n确认继续?')) return;
1244
+ if (!confirm('二次确认:真的清空 ' + scope + ' 的全部 ' + n + ' 条?')) return;
1245
+
1246
+ try {
1247
+ const url = '/api/trash' + (part ? '?partition=' + encodeURIComponent(part) : '');
1248
+ const r = await CO_ENGRAM.apiJson(url, 'DELETE', {});
1249
+ alert('已永久删除 ' + (r.count || 0) + ' 条记忆。');
1250
+ CO_ENGRAM._trashLoaded = false;
1251
+ await this.render(document.getElementById('trash-content'));
1252
+ } catch (e) { alert('清空失败:' + (e.message || e)); }
1253
+ }
1254
+ };
1255
+
1256
+ // ============================================================
1257
+ // Config
1258
+ // ============================================================
1259
+ CO_ENGRAM.on('config', async function() {
1260
+ const root = document.getElementById('config-content');
1261
+ if (!root) return;
1262
+ if (CO_ENGRAM._configLoaded) return;
1263
+ CO_ENGRAM._configLoaded = true;
1264
+ await CO_ENGRAM_CONFIG.render(root);
1265
+ });
1266
+
1267
+ window.CO_ENGRAM_CONFIG = {
1268
+ async render(root) {
1269
+ root.innerHTML = '<div class="loading">加载配置中</div>';
1270
+ let data;
1271
+ try { data = await CO_ENGRAM.apiGet('/api/config'); }
1272
+ catch (e) { root.innerHTML = '<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'; return; }
1273
+
1274
+ CO_ENGRAM._configData = data;
1275
+ const persisted = data.persisted || {};
1276
+ const runtime = data.runtime || {};
1277
+ // hostType 决定重启相关的提示文字与按钮可见性
1278
+ // 'mcp-server' → 由 Claude Code 父进程管理,支持优雅重启
1279
+ // 'openclaw-plugin' → 是 openclaw gateway 的一部分,不支持自动重启
1280
+ const hostType = data.hostType || 'mcp-server';
1281
+ const HOST_LABEL = hostType === 'openclaw-plugin' ? 'openclaw gateway' : 'MCP server';
1282
+ const HOST_PARENT = hostType === 'openclaw-plugin' ? 'openclaw' : 'Claude Code';
1283
+ const HOST_SUPPORTS_RESTART = hostType !== 'openclaw-plugin';
1284
+
1285
+ const LANG_LABEL = { zh: '中文', en: 'English' };
1286
+ const langOptions = Object.keys(LANG_LABEL).map(k => '<option value="' + k + '"' + (persisted.language === k ? ' selected' : '') + '>' + LANG_LABEL[k] + '</option>').join('');
1287
+ const profileOptions = ['minimal', 'standard', 'full'].map(p => '<option value="' + p + '"' + (persisted.toolsProfile === p ? ' selected' : '') + '>' + p + '</option>').join('');
1288
+
1289
+ let html = '';
1290
+
1291
+ // 持久化(可编辑)
1292
+ html += '<div class="config-section">';
1293
+ html += '<h3>持久化配置(可编辑,保存后重启生效)</h3>';
1294
+ html += '<div class="config-row"><div class="config-label">语言<span class="desc">UI / 工具描述 / 提示词所用语言</span></div>'
1295
+ + '<div class="config-control"><select id="cf-language">' + langOptions + '</select></div></div>';
1296
+ html += '<div class="config-row"><div class="config-label">默认创建者<span class="desc">新记忆印迹的默认 createdBy 字段;留空回退到 git 身份</span></div>'
1297
+ + '<div class="config-control"><input id="cf-default-created-by" type="text" value="' + CO_ENGRAM.escapeHtml(persisted.defaultCreatedBy || '') + '" placeholder="(留空使用 git 作者)"></div></div>';
1298
+ html += '<div class="config-row"><div class="config-label">工具 Profile<span class="desc">LLM 可见工具数量:minimal=最小 / standard=标准 / full=全部</span></div>'
1299
+ + '<div class="config-control"><select id="cf-tools-profile">' + profileOptions + '</select></div></div>';
1300
+ html += '</div>';
1301
+
1302
+ // 运行时状态:可编辑(下次启动生效),viewer 自身只读避免 UI 自杀
1303
+ const toggle = (id, label, desc, currentOn, desiredOn, editable) => {
1304
+ const effective = (desiredOn !== undefined ? desiredOn : currentOn);
1305
+ const onClass = effective ? 'on' : 'off';
1306
+ const control = editable
1307
+ ? '<label class="toggle-switch"><input type="checkbox" id="' + id + '"' + (effective ? ' checked' : '') + '><span class="toggle-slider"></span></label>'
1308
+ + '<span class="toggle-state ' + onClass + '">' + (effective ? '启用' : '禁用') + '</span>'
1309
+ : '<input type="text" value="' + (currentOn ? '已启用' : '未启用') + '" readonly>';
1310
+ const badge = (desiredOn !== undefined && desiredOn !== currentOn)
1311
+ ? '<span class="chip" style="background:rgba(251,191,36,.12);color:var(--accent-warm);margin-left:.5rem">重启后生效</span>'
1312
+ : '';
1313
+ return '<div class="config-row"><div class="config-label">' + label + (desc ? '<span class="desc">' + desc + '</span>' : '') + badge + '</div>'
1314
+ + '<div class="config-control" style="display:flex;align-items:center;gap:.6rem">' + control + '</div></div>';
1315
+ };
1316
+
1317
+ html += '<div class="config-section">';
1318
+ html += '<h3>运行时状态(编辑后需重启 ' + HOST_LABEL + ' 生效)</h3>';
1319
+ html += '<div class="edit-banner" style="margin-bottom:.8rem">说明:这些开关把"下次启动时期望的状态"持久化到 config.json。当前正在运行的实例不会受影响——重启 ' + HOST_LABEL + ' 后,新值才会生效。' + (HOST_SUPPORTS_RESTART ? '' : ' openclaw 模式下请使用 <code>openclaw gateway restart</code> 命令。') + '</div>';
1320
+ html += toggle('cf-audit', '审计日志', '记录所有 API / 工具调用事件', runtime.auditEnabled, persisted.audit?.enabled, true);
1321
+ html += toggle('cf-proposals', '提案引擎', '隐式捕获候选记忆待审批', runtime.proposalEnabled, persisted.proposals?.enabled, true);
1322
+ html += toggle('cf-maintenance', '维护服务', '后台 light/deep/rem 三阶段维护', runtime.maintenanceEnabled, persisted.maintenance?.enabled, true);
1323
+ html += toggle(null, '搜索器', '语义 + 关键词检索', runtime.searchEnabled, undefined, false);
1324
+ html += toggle(null, 'Web 查看器', '本页面所在 HTTP 服务(不可关闭,否则 UI 失联)', runtime.viewerEnabled, undefined, false);
1325
+ html += '</div>';
1326
+
1327
+ // 运行时元数据(只读)
1328
+ html += '<div class="config-section">';
1329
+ html += '<h3>运行时元数据(只读)</h3>';
1330
+ html += '<div class="config-row readonly"><div class="config-label">运行 Profile</div>'
1331
+ + '<div class="config-control"><input type="text" value="' + CO_ENGRAM.escapeHtml(runtime.profile || 'standard') + '" readonly></div></div>';
1332
+ html += '<div class="config-row readonly"><div class="config-label">当前语言(运行时)</div>'
1333
+ + '<div class="config-control"><input type="text" value="' + CO_ENGRAM.escapeHtml(LANG_LABEL[runtime.language] || runtime.language || '') + '" readonly></div></div>';
1334
+ html += '<div class="config-row readonly"><div class="config-label">运行时 createdBy</div>'
1335
+ + '<div class="config-control"><input type="text" value="' + CO_ENGRAM.escapeHtml(runtime.defaultCreatedBy || '(未设置)') + '" readonly></div></div>';
1336
+ html += '</div>';
1337
+
1338
+ // 元数据
1339
+ html += '<div class="config-section">';
1340
+ html += '<h3>仓库元数据</h3>';
1341
+ // 数据根目录:当前值只读展示 + "切换"按钮打开 drawer 编辑下次启动的期望值
1342
+ const currentDataRoot = data.dataRoot || '(未知)';
1343
+ const desiredDataRoot = persisted.desiredDataRoot || '';
1344
+ const dataRootBadge = desiredDataRoot
1345
+ ? '<span class="chip" style="background:rgba(251,191,36,.12);color:var(--accent-warm);margin-left:.5rem">重启后切换到 ' + CO_ENGRAM.escapeHtml(desiredDataRoot) + '</span>'
1346
+ : '';
1347
+ html += '<div class="config-row"><div class="config-label">数据根目录<span class="desc">记忆印迹/突触/审计的实际落盘位置。env CO_ENGRAM_DATA_ROOT 优先于此处持久化值。</span>' + dataRootBadge + '</div>'
1348
+ + '<div class="config-control" style="display:flex;gap:.4rem;align-items:center">'
1349
+ + '<input type="text" value="' + CO_ENGRAM.escapeHtml(currentDataRoot) + '" readonly style="flex:1">'
1350
+ + '<button class="btn secondary" onclick="CO_ENGRAM_CONFIG.editDataRoot()">切换…</button>'
1351
+ + '</div></div>';
1352
+ html += '<div class="config-row readonly"><div class="config-label">配置版本</div>'
1353
+ + '<div class="config-control"><input type="text" value="' + CO_ENGRAM.escapeHtml(String(persisted.version || 1)) + '" readonly></div></div>';
1354
+ html += '<div class="config-row readonly"><div class="config-label">创建时间</div>'
1355
+ + '<div class="config-control"><input type="text" value="' + CO_ENGRAM.escapeHtml(persisted.createdAt || '—') + '" readonly></div></div>';
1356
+ html += '<div class="config-row readonly"><div class="config-label">最后更新</div>'
1357
+ + '<div class="config-control"><input type="text" value="' + CO_ENGRAM.escapeHtml(persisted.updatedAt || persisted.createdAt || '—') + '" readonly></div></div>';
1358
+ html += '</div>';
1359
+
1360
+ html += '<div class="config-save-bar">'
1361
+ + '<button class="btn secondary" onclick="CO_ENGRAM_CONFIG.reload()">重置</button>'
1362
+ + '<button class="btn" onclick="CO_ENGRAM_CONFIG.save()">保存配置</button>'
1363
+ + '</div>';
1364
+
1365
+ root.innerHTML = html;
1366
+ },
1367
+
1368
+ async reload() {
1369
+ CO_ENGRAM._configLoaded = false;
1370
+ const root = document.getElementById('config-content');
1371
+ if (root) await this.render(root);
1372
+ },
1373
+
1374
+ async save() {
1375
+ const auditEl = document.getElementById('cf-audit');
1376
+ const proposalsEl = document.getElementById('cf-proposals');
1377
+ const maintenanceEl = document.getElementById('cf-maintenance');
1378
+ const auditOn = auditEl ? !!auditEl.checked : undefined;
1379
+ const proposalsOn = proposalsEl ? !!proposalsEl.checked : undefined;
1380
+ const maintenanceOn = maintenanceEl ? !!maintenanceEl.checked : undefined;
1381
+ const body = {
1382
+ language: document.getElementById('cf-language').value,
1383
+ defaultCreatedBy: document.getElementById('cf-default-created-by').value,
1384
+ toolsProfile: document.getElementById('cf-tools-profile').value,
1385
+ // 嵌套结构:与 config.json schema 一致
1386
+ ...(auditOn !== undefined ? { audit: { enabled: auditOn } } : {}),
1387
+ ...(proposalsOn !== undefined ? { proposals: { enabled: proposalsOn } } : {}),
1388
+ ...(maintenanceOn !== undefined ? { maintenance: { enabled: maintenanceOn } } : {}),
1389
+ };
1390
+ // 检测哪些字段需要重启生效,用于在 banner 里给用户更准确的提示。
1391
+ const before = CO_ENGRAM._configData?.persisted || {};
1392
+ const restartFields = [
1393
+ { section: 'audit', label: '审计日志' },
1394
+ { section: 'proposals', label: '提案引擎' },
1395
+ { section: 'maintenance', label: '维护服务' }
1396
+ ];
1397
+ const changed = restartFields.filter(f => {
1398
+ const oldVal = before[f.section]?.enabled;
1399
+ const newVal = body[f.section]?.enabled;
1400
+ return oldVal !== newVal && !(oldVal == null && newVal == null);
1401
+ });
1402
+ const dataRootChanged = !!document.querySelector('.edit-banner[data-dataroot-changed="1"]');
1403
+ try {
1404
+ await CO_ENGRAM.apiJson('/api/config', 'PUT', body);
1405
+ const needsRestart = changed.length > 0 || dataRootChanged;
1406
+ let banner;
1407
+ if (needsRestart) {
1408
+ const changedLabels = changed.map(c => c.label).concat(dataRootChanged ? ['数据根目录'] : []);
1409
+ const hostType = (CO_ENGRAM._configData && CO_ENGRAM._configData.hostType) || 'mcp-server';
1410
+ const HOST_LABEL = hostType === 'openclaw-plugin' ? 'openclaw gateway' : 'MCP server';
1411
+ const HOST_PARENT = hostType === 'openclaw-plugin' ? 'openclaw' : 'Claude Code';
1412
+ const restartSupported = hostType !== 'openclaw-plugin';
1413
+ const restartBtn = restartSupported
1414
+ ? '<button class="btn" style="margin-left:auto;padding:.3rem .8rem;font-size:.8rem" '
1415
+ + 'title="点击后 ' + HOST_LABEL + ' 会优雅退出(退出码 0),由父进程(通常 ' + HOST_PARENT + ')自动重启。\\n\\n'
1416
+ + '影响范围:\\n'
1417
+ + ' • 工具会短暂断开(几秒内自动重连,不影响正在进行的对话)\\n'
1418
+ + ' • 浏览器会失联,本页面会在服务恢复后自动刷新\\n'
1419
+ + ' • 维护线程、proposal 引擎等后台任务会以新配置重新启动\\n\\n'
1420
+ + '不会丢失:\\n'
1421
+ + ' • 已保存的配置(刚刚写入 config.json)\\n'
1422
+ + ' • 已存在的 engram / synapse 数据(落盘持久化)\\n'
1423
+ + ' • 当前对话历史(由 ' + HOST_PARENT + ' 持有,与服务重启无关)" '
1424
+ + 'onclick="CO_ENGRAM_CONFIG.restartNow()">立即重启生效</button>'
1425
+ : '<span style="margin-left:auto;font-size:.8rem;color:var(--fg-muted)">openclaw 模式不支持自动重启,请手动执行 <code>openclaw gateway restart</code></span>';
1426
+ banner = '<div class="edit-banner" style="background:rgba(251,191,36,0.08);border-color:rgba(251,191,36,0.35);color:var(--accent-warm);display:flex;flex-wrap:wrap;gap:.6rem;align-items:center">'
1427
+ + '<span>✓ 配置已保存。以下改动需重启 ' + HOST_LABEL + ' 才能生效:<strong>' + changedLabels.join('、') + '</strong></span>'
1428
+ + restartBtn
1429
+ + '</div>';
1430
+ } else {
1431
+ banner = '<div class="edit-banner" style="background:rgba(94,234,212,0.08);border-color:rgba(94,234,212,0.25);color:var(--accent)">✓ 配置已保存。</div>';
1432
+ }
1433
+ const root = document.getElementById('config-content');
1434
+ if (root) root.insertAdjacentHTML('afterbegin', banner);
1435
+ // 重新加载持久化部分
1436
+ setTimeout(() => { CO_ENGRAM._configLoaded = false; this.render(document.getElementById('config-content')); }, 2000);
1437
+ } catch (e) { alert('保存失败:' + (e.message || e)); }
1438
+ },
1439
+
1440
+ /**
1441
+ * Trigger graceful exit; parent process will auto-restart.
1442
+ *
1443
+ * Only available in mcp-server mode. In openclaw-plugin mode, /api/restart
1444
+ * returns 409 and we prompt the user to manually run 'openclaw gateway restart'.
1445
+ *
1446
+ * Flow:
1447
+ * 1. POST /api/restart - server process.exit(0) after 300ms
1448
+ * 2. Show "restarting" overlay
1449
+ * 3. Poll /api/stats every 500ms; 2 consecutive successes = recovered
1450
+ * 4. Reload page to re-render UI
1451
+ *
1452
+ * Fallback: if not recovered within 30s, prompt user to refresh manually.
1453
+ */
1454
+ async restartNow() {
1455
+ const hostType = (CO_ENGRAM._configData && CO_ENGRAM._configData.hostType) || 'mcp-server';
1456
+ const HOST_LABEL = hostType === 'openclaw-plugin' ? 'openclaw gateway' : 'MCP server';
1457
+ const HOST_PARENT = hostType === 'openclaw-plugin' ? 'openclaw' : 'Claude Code';
1458
+ if (!confirm('确认重启 ' + HOST_LABEL + '?\\n\\n • 工具会短暂断开(几秒内自动重连)\\n • 浏览器会失联,本页面会在服务恢复后自动刷新\\n • 已保存的配置和 engram 数据不会丢失')) return;
1459
+ // openclaw-plugin 模式:服务端会拒绝,直接提示用户手动重启 gateway
1460
+ if (hostType === 'openclaw-plugin') {
1461
+ alert('openclaw plugin 模式不支持从 viewer 自动重启——这会杀掉整个 gateway 进程,影响其他 plugin/会话。\\n\\n请手动执行:\\n openclaw gateway restart');
1462
+ return;
1463
+ }
1464
+ // 显示重启遮罩
1465
+ let mask = document.getElementById('restart-mask');
1466
+ if (!mask) {
1467
+ mask = document.createElement('div');
1468
+ mask.id = 'restart-mask';
1469
+ mask.style.cssText = 'position:fixed;inset:0;background:rgba(10,14,31,0.85);backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;color:var(--fg);font-size:1rem;flex-direction:column;gap:1rem';
1470
+ mask.innerHTML = '<div style="font-size:1.5rem">⟳ 正在重启 ' + HOST_LABEL + '…</div>'
1471
+ + '<div style="color:var(--fg-muted);font-size:.85rem;max-width:480px;text-align:center">'
1472
+ + '服务正在退出并由父进程(' + HOST_PARENT + ')重新拉起。页面会在恢复后自动刷新。</div>'
1473
+ + '<div class="spinner" style="width:24px;height:24px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite"></div>';
1474
+ document.body.appendChild(mask);
1475
+ }
1476
+ try {
1477
+ await CO_ENGRAM.apiJson('/api/restart', 'POST');
1478
+ } catch {
1479
+ // 预期内:服务退出会导致 fetch 失败,继续 poll
1480
+ }
1481
+ // Poll until service comes back
1482
+ const start = Date.now();
1483
+ const deadline = start + 30000;
1484
+ let successCount = 0;
1485
+ const poll = async () => {
1486
+ if (Date.now() > deadline) {
1487
+ mask.innerHTML = '<div style="font-size:1.2rem;color:var(--accent-warm)">重启超时(30s)</div>'
1488
+ + '<div style="color:var(--fg-muted);font-size:.85rem">请手动刷新页面;若 ' + HOST_LABEL + ' 仍未恢复,请检查 ' + HOST_PARENT + ' 状态。</div>'
1489
+ + '<button class="btn" onclick="location.reload()" style="margin-top:.5rem">手动刷新</button>';
1490
+ return;
1491
+ }
1492
+ try {
1493
+ await CO_ENGRAM.apiGet('/api/stats');
1494
+ successCount++;
1495
+ if (successCount >= 2) {
1496
+ location.reload();
1497
+ return;
1498
+ }
1499
+ } catch {
1500
+ successCount = 0;
1501
+ }
1502
+ setTimeout(poll, 500);
1503
+ };
1504
+ setTimeout(poll, 800);
1505
+ },
1506
+
1507
+ /**
1508
+ * 打开 drawer 编辑"下次启动的数据根目录"。
1509
+ * 当前运行实例不会受影响——已加载的 repository/maintainer/viewer 仍指向旧路径。
1510
+ */
1511
+ editDataRoot() {
1512
+ const data = CO_ENGRAM._configData || {};
1513
+ const persisted = data.persisted || {};
1514
+ const current = data.dataRoot || '(未知)';
1515
+ const desired = persisted.desiredDataRoot || '';
1516
+ const envSet = data.envSet || false;
1517
+ const envPath = data.envDataRoot || '';
1518
+ const envMatchedRuntime = !!data.envDataRootOverride;
1519
+
1520
+ const body = '<div class="edit-banner"><strong>切换数据根目录</strong> · 仅影响下次服务启动</div>'
1521
+ + '<div class="field"><span class="field-label">当前运行时:</span><code>' + CO_ENGRAM.escapeHtml(current) + '</code></div>'
1522
+ + '<div class="field"><label class="field-label">下次启动使用:</label>'
1523
+ + '<input id="cf-dataroot" type="text" style="width:100%" value="' + CO_ENGRAM.escapeHtml(desired) + '" placeholder="(留空回退到 env / 默认路径)">'
1524
+ + '<div style="font-size:0.75rem;color:var(--fg-muted);margin-top:0.3rem">'
1525
+ + '绝对路径(如 <code>/home/USER/team-memory</code> 或 <code>/var/lib/co-engram</code>)。'
1526
+ + '留空则清除持久化值,回退到 env / 默认路径。'
1527
+ + '<br><strong style="color:var(--accent)">优先级:此处配置 &gt; env CO_ENGRAM_DATA_ROOT &gt; 默认路径。</strong>'
1528
+ + (envSet
1529
+ ? ' <span style="color:var(--fg-muted)">检测到 env 已设置 <code>' + CO_ENGRAM.escapeHtml(envPath || '(空)') + '</code>' + (envMatchedRuntime ? '(当前正是 env 路径)' : '') + ';此处填值将<strong>覆盖</strong> env。</span>'
1530
+ : '')
1531
+ + '</div></div>'
1532
+ + '<div class="edit-banner" style="background:rgba(251,191,36,0.06);border-color:rgba(251,191,36,0.25);color:var(--accent-warm)">'
1533
+ + '<strong>注意:</strong>切换目录后,新目录若为空将自动初始化;原目录的数据不会迁移。'
1534
+ + '保存后需要重启服务才会切换。</div>'
1535
+ + '<div class="config-save-bar">'
1536
+ + '<button class="btn secondary" onclick="CO_ENGRAM_CONFIG.reload(); CO_ENGRAM.closeDrawer();">取消</button>'
1537
+ + '<button class="btn" onclick="CO_ENGRAM_CONFIG.saveDataRoot()">保存期望值</button>'
1538
+ + '</div>';
1539
+ CO_ENGRAM.openDrawer(body);
1540
+ },
1541
+
1542
+ async saveDataRoot() {
1543
+ const input = document.getElementById('cf-dataroot');
1544
+ if (!input) return;
1545
+ const value = (input.value || '').trim();
1546
+ try {
1547
+ await CO_ENGRAM.apiJson('/api/config', 'PUT', { desiredDataRoot: value });
1548
+ CO_ENGRAM.closeDrawer();
1549
+ const hostType = (CO_ENGRAM._configData && CO_ENGRAM._configData.hostType) || 'mcp-server';
1550
+ const HOST_LABEL = hostType === 'openclaw-plugin' ? 'openclaw gateway' : 'MCP server';
1551
+ const banner = '<div class="edit-banner" style="background:rgba(251,191,36,0.08);border-color:rgba(251,191,36,0.25);color:var(--accent-warm)">✓ 数据根目录期望值已保存:' + (value ? CO_ENGRAM.escapeHtml(value) : '(已清除,回退默认)') + '。<strong>重启 ' + HOST_LABEL + ' 生效</strong>。</div>';
1552
+ const root = document.getElementById('config-content');
1553
+ if (root) root.insertAdjacentHTML('afterbegin', banner);
1554
+ CO_ENGRAM._configLoaded = false;
1555
+ await this.render(document.getElementById('config-content'));
1556
+ } catch (e) { alert('保存失败:' + (e.message || e)); }
1557
+ }
1558
+ };
1559
+
1560
+ // ============================================================
1561
+ // Synapses(被 graph tab 调用,也可独立打开)
1562
+ // ============================================================
1563
+ window.CO_ENGRAM_SYNAPSES = {
1564
+ async open(id) {
1565
+ let d;
1566
+ try { d = await CO_ENGRAM.apiGet('/api/synapses/' + encodeURIComponent(id)); }
1567
+ catch (e) { CO_ENGRAM.openDrawer('<div class="empty">加载失败:' + CO_ENGRAM.escapeHtml(e.message) + '</div>'); return; }
1568
+ CO_ENGRAM._currentSynapse = d;
1569
+ this._renderView(d);
1570
+ },
1571
+
1572
+ _renderView(d) {
1573
+ const L = CO_ENGRAM_LABELS;
1574
+ const id = CO_ENGRAM.escapeHtml(d.id);
1575
+ const kindLabel = L.synapse[d.kind] || d.kind;
1576
+ const family = CO_ENGRAM.synapseFamily(d.kind);
1577
+ const familyLabel = { structural: '结构', causal: '因果', evidential: '证据', temporal: '时间', modulatory: '调节' }[family] || family;
1578
+ const dirLabel = L.synapseDirection[d.direction] || d.direction || '单向';
1579
+ const evidence = (d.evidence || []);
1580
+ const evidenceHtml = evidence.length
1581
+ ? '<h3>证据 (' + evidence.length + ')</h3>' + evidence.map(ev => '<div class="field markdown-body" style="padding-left:.5rem;border-left:2px solid var(--border);margin-bottom:.4rem">'
1582
+ + CO_ENGRAM.renderMarkdown(ev.description || '')
1583
+ + (ev.source ? ' <span class="chip">' + CO_ENGRAM.escapeHtml(ev.source) + '</span>' : '')
1584
+ + (ev.confidence != null ? ' <span class="kpi-sub">置信度 ' + Number(ev.confidence).toFixed(2) + '</span>' : '')
1585
+ + (ev.addedBy ? ' <span class="kpi-sub">· ' + CO_ENGRAM.escapeHtml(ev.addedBy) + '</span>' : '')
1586
+ + '</div>').join('')
1587
+ : '<div class="empty">无证据</div>';
1588
+
1589
+ const body = '<div class="edit-banner" style="display:flex;gap:.5rem;align-items:center">'
1590
+ + '<strong style="margin-right:auto">突触详情</strong>'
1591
+ + '<button class="btn" onclick="CO_ENGRAM_SYNAPSES.edit()">编辑</button>'
1592
+ + '<button class="btn secondary" onclick="CO_ENGRAM_SYNAPSES.confirmDelete()">删除</button>'
1593
+ + '</div>'
1594
+ + '<h2>' + CO_ENGRAM.escapeHtml(kindLabel) + '</h2>'
1595
+ + '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('synapse.' + d.kind) + '>类型:</span>' + CO_ENGRAM.escapeHtml(kindLabel) + ' (' + CO_ENGRAM.escapeHtml(d.kind) + ')</div>'
1596
+ + '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('family.' + family) + '>所属族:</span><span class="chip dot" style="color:' + CO_ENGRAM.familyColor(family) + '">' + CO_ENGRAM.escapeHtml(familyLabel) + '</span></div>'
1597
+ + '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('synapseDirection.' + (d.direction || 'directional')) + '>方向:</span>' + CO_ENGRAM.escapeHtml(dirLabel) + '</div>'
1598
+ + '<div class="field"><span class="field-label">权重:</span>' + (d.weight != null ? Number(d.weight).toFixed(2) : '—') + '</div>'
1599
+ + (d.resolutionStatus ? '<div class="field"><span class="field-label"' + CO_ENGRAM.tip('resolution.' + d.resolutionStatus) + '>裁决状态:</span><span class="chip" style="background:rgba(239,68,68,.12);color:#ef4444">' + CO_ENGRAM.escapeHtml(L.resolution[d.resolutionStatus] || d.resolutionStatus) + '</span></div>' : '')
1600
+ + '<div class="field"><span class="field-label">ID:</span><code>' + id + '</code></div>'
1601
+ + '<div class="field"><span class="field-label">源 → 目标:</span><span class="engram-link" data-engram-id="' + CO_ENGRAM.escapeHtml(d.from) + '">' + CO_ENGRAM.escapeHtml(d.from) + '</span> → <span class="engram-link" data-engram-id="' + CO_ENGRAM.escapeHtml(d.to) + '">' + CO_ENGRAM.escapeHtml(d.to) + '</span></div>'
1602
+ + '<div class="field"><span class="field-label">创建者:</span>' + CO_ENGRAM.escapeHtml(d.createdBy || '')
1603
+ + ' <span class="field-label">时间:</span>' + CO_ENGRAM.escapeHtml(d.createdAt || '') + '</div>'
1604
+ + evidenceHtml;
1605
+ CO_ENGRAM.openDrawer(body);
1606
+ },
1607
+
1608
+ edit() {
1609
+ const d = CO_ENGRAM._currentSynapse;
1610
+ if (!d) return;
1611
+ const L = CO_ENGRAM_LABELS;
1612
+ // 12 种 kind,按族分组,与 stats/graph tab 一致
1613
+ const KINDS_BY_FAMILY = [
1614
+ { family: 'structural', label: '结构族', kinds: ['extends', 'part_of', 'similar_to'] },
1615
+ { family: 'causal', label: '因果族', kinds: ['depends_on', 'causes', 'follows'] },
1616
+ { family: 'evidential', label: '证据族', kinds: ['derives_from', 'contradicts', 'exemplifies'] },
1617
+ { family: 'temporal', label: '时间族', kinds: ['supersedes', 'consolidates'] },
1618
+ { family: 'modulatory', label: '调节族', kinds: ['contextualizes'] }
1619
+ ];
1620
+ const kindOptions = KINDS_BY_FAMILY.map(group =>
1621
+ '<optgroup label="' + group.label + '">' + group.kinds.map(k =>
1622
+ '<option value="' + k + '"' + (d.kind === k ? ' selected' : '') + CO_ENGRAM.tip('synapse.' + k) + '>' + (L.synapse[k] || k) + ' · ' + k + '</option>'
1623
+ ).join('') + '</optgroup>'
1624
+ ).join('');
1625
+ const dirOptions = Object.keys(L.synapseDirection).map(k => '<option value="' + k + '"' + (d.direction === k ? ' selected' : '') + CO_ENGRAM.tip('synapseDirection.' + k) + '>' + L.synapseDirection[k] + '</option>').join('');
1626
+
1627
+ const body = '<div class="edit-banner"><strong>编辑模式</strong> · 修改后点击"保存"提交</div>'
1628
+ + '<h2>编辑记忆突触</h2>'
1629
+ + '<div class="field"><span class="field-label">ID:</span><code>' + CO_ENGRAM.escapeHtml(d.id) + '</code></div>'
1630
+ + '<div class="edit-banner" style="background:rgba(251,191,36,.06);border-color:rgba(251,191,36,.25);color:var(--accent-warm)">提示:修改"类型"或"方向"会让突触 ID 重新计算(因 ID 派生自 from+to+kind+direction),旧 ID 将失效,但所有元数据(权重/证据/创建者)会迁移到新 ID。</div>'
1631
+ + '<div class="field"><label class="field-label"' + CO_ENGRAM.tip('synapse.' + d.kind) + '>类型</label><select id="sf-kind"' + CO_ENGRAM.tip('synapse.' + d.kind) + '>' + kindOptions + '</select></div>'
1632
+ + '<div class="field"><label class="field-label"' + CO_ENGRAM.tip('synapseDirection.' + (d.direction || 'directional')) + '>方向</label><select id="sf-direction"' + CO_ENGRAM.tip('synapseDirection.' + (d.direction || 'directional')) + '>' + dirOptions + '</select></div>'
1633
+ + '<div class="field"><label class="field-label">权重 (0-1,可拖动滑块)</label><input id="sf-weight-range" type="range" min="0" max="1" step="0.01" value="' + (d.weight || 0) + '" oninput="document.getElementById(\\'sf-weight\\').value=this.value"><input id="sf-weight" type="number" min="0" max="1" step="0.01" value="' + (d.weight || 0) + '" oninput="document.getElementById(\\'sf-weight-range\\').value=this.value" style="width:80px;margin-left:.5rem"></div>'
1634
+ + '<div class="field"><label class="field-label">新增证据描述(可选,留空则不追加)</label><input id="sf-evidence-desc" type="text" placeholder="如:通过 codegraph 验证..."></div>'
1635
+ + '<div class="field"><label class="field-label">证据来源(可选)</label><input id="sf-evidence-source" type="text" placeholder="如:manual / ci / docs"></div>'
1636
+ + '<div class="config-save-bar">'
1637
+ + '<button class="btn secondary" onclick="CO_ENGRAM_SYNAPSES.cancel()">取消</button>'
1638
+ + '<button class="btn" onclick="CO_ENGRAM_SYNAPSES.save()">保存</button>'
1639
+ + '</div>';
1640
+ CO_ENGRAM.openDrawer(body);
1641
+ },
1642
+
1643
+ cancel() {
1644
+ const d = CO_ENGRAM._currentSynapse;
1645
+ if (d) this._renderView(d);
1646
+ },
1647
+
1648
+ async save() {
1649
+ const d = CO_ENGRAM._currentSynapse;
1650
+ if (!d) return;
1651
+ const nextKind = document.getElementById('sf-kind').value;
1652
+ const nextDirection = document.getElementById('sf-direction').value;
1653
+ const patch = {
1654
+ weight: Number(document.getElementById('sf-weight').value),
1655
+ direction: nextDirection
1656
+ };
1657
+ // 仅当 kind/direction 变化时传 kind(避免无谓的删除+重建)
1658
+ if (nextKind !== d.kind) patch.kind = nextKind;
1659
+ const desc = (document.getElementById('sf-evidence-desc').value || '').trim();
1660
+ if (desc) {
1661
+ const source = (document.getElementById('sf-evidence-source').value || '').trim();
1662
+ patch.evidence = [{ description: desc, ...(source ? { source } : {}), addedBy: 'viewer' }];
1663
+ }
1664
+ try {
1665
+ const updated = await CO_ENGRAM.apiJson('/api/synapses/' + encodeURIComponent(d.id), 'PATCH', patch);
1666
+ CO_ENGRAM._currentSynapse = updated;
1667
+ this._renderView(updated);
1668
+ // kind 变化 → 图谱中的边 id 已变,需要重载
1669
+ if (patch.kind && CO_ENGRAM._graphState) {
1670
+ CO_ENGRAM._graphState.initialized = false;
1671
+ CO_ENGRAM._graphState = null;
1672
+ }
1673
+ } catch (e) { alert('保存失败:' + (e.message || e)); }
1674
+ },
1675
+
1676
+ async confirmDelete() {
1677
+ const d = CO_ENGRAM._currentSynapse;
1678
+ if (!d) return;
1679
+ if (!confirm('确定删除此记忆突触?\\n此操作不可撤销。')) return;
1680
+ try {
1681
+ await CO_ENGRAM.apiJson('/api/synapses/' + encodeURIComponent(d.id), 'DELETE', null);
1682
+ CO_ENGRAM.closeDrawer();
1683
+ CO_ENGRAM._currentSynapse = null;
1684
+ // 重新加载图谱(如果当前在图谱 tab)
1685
+ if (CO_ENGRAM._graphState) {
1686
+ CO_ENGRAM._graphState.initialized = false;
1687
+ CO_ENGRAM._graphState = null;
1688
+ const gc = document.getElementById('graph-canvas');
1689
+ if (gc) gc.innerHTML = '<div class="loading">重新加载图谱中</div>';
1690
+ CO_ENGRAM.onTabEnter('graph');
1691
+ }
1692
+ } catch (e) { alert('删除失败:' + (e.message || e)); }
1693
+ }
1694
+ };
1695
+
1696
+ // ============================================================
1697
+ // Help — 帮助文档(概念入门 + 各 tab 速查)
1698
+ // ============================================================
1699
+ CO_ENGRAM.on('help', async function() {
1700
+ const el = document.getElementById('help-content');
1701
+ if (!el) return;
1702
+ if (CO_ENGRAM._helpLoaded) return;
1703
+ CO_ENGRAM._helpLoaded = true;
1704
+ el.innerHTML = CO_ENGRAM_HELP.render();
1705
+ });
1706
+
1707
+ window.CO_ENGRAM_HELP = {
1708
+ render() {
1709
+ return ''
1710
+ + '<div class="panel" style="max-width:900px;margin:0 auto;padding:1.5rem;line-height:1.7">'
1711
+ + '<h2 style="margin-top:0">Co-Engram · 自进化的团队记忆</h2>'
1712
+ + '<p>Co-Engram 把团队工作中的对话、决策、踩过的坑沉淀为<em>记忆印迹(engram)</em>,'
1713
+ + '用<em>记忆突触(synapse)</em>把它们连成可演化的知识网络。模型在后续任务里通过 '
1714
+ + '<code>memory_search</code> 召回相关记忆,引用有效时调 <code>engram_reinforce</code> '
1715
+ + '强化,出错时调 <code>engram_report_failure</code> 弱化——这套闭环让高价值记忆自动浮现、过时记忆自动衰减。</p>'
1716
+
1717
+ + '<h3>核心概念</h3>'
1718
+ + '<dl style="padding-left:0.5rem;border-left:3px solid var(--border)">'
1719
+ + '<dt><strong>记忆印迹(engram)</strong></dt>'
1720
+ + '<dd style="margin-bottom:0.6rem">一条结构化的记忆条目,含标题/内容/类型/标签/重要性/置信度等字段。'
1721
+ + '类型分 5 种:<code>fact(事实)</code> <code>observation(观察)</code> <code>pattern(模式)</code> '
1722
+ + '<code>procedure(流程)</code> <code>hypothesis(假设)</code>。鼠标悬停字段可以看到该字段的解释。</dd>'
1723
+ + '<dt><strong>记忆突触(synapse)</strong></dt>'
1724
+ + '<dd style="margin-bottom:0.6rem">连接两个 engram 的有向边,分 5 个族:'
1725
+ + '<code>结构族</code>(extends/part_of/similar_to)、'
1726
+ + '<code>因果族</code>(depends_on/causes/follows)、'
1727
+ + '<code>证据族</code>(derives_from/contradicts/exemplifies)、'
1728
+ + '<code>时间族</code>(supersedes/consolidates)、'
1729
+ + '<code>调节族</code>(contextualizes)。<code>contradicts</code> 会进入裁决流程。</dd>'
1730
+ + '<dt><strong>重要性(importance)与置信度(confidence)</strong></dt>'
1731
+ + '<dd style="margin-bottom:0.6rem">两个独立的 0-1 数值。重要性由强化信号 + 时间衰减派生,影响召回权重;'
1732
+ + '置信度反映该记忆成立的可信程度(元认知评分),与重要性解耦。</dd>'
1733
+ + '<dt><strong>多维重要性向量(importanceVector)</strong></dt>'
1734
+ + '<dd style="margin-bottom:0.6rem">把重要性拆解为 personal/team/project/network/temporal 5 个维度,便于精细化调控。'
1735
+ + '查看 engram 详情时如果存在,会显示在专门的段落里。</dd>'
1736
+ + '<dt><strong>生命周期</strong></dt>'
1737
+ + '<dd style="margin-bottom:0.6rem"><code>draft → active → archived → forgotten</code>。'
1738
+ + '遗忘的文件仍在仓库,但默认不召回。维护周期会自动评估并迁移状态。</dd>'
1739
+ + '</dl>'
1740
+
1741
+ + '<h3>各 tab 用途</h3>'
1742
+ + '<ul style="padding-left:1.2rem">'
1743
+ + '<li><strong>统计</strong>—总览仪表盘:按类型/状态/族分布,显示团队贡献者和 top 标签。'
1744
+ + '顶部搜索框做全文检索。</li>'
1745
+ + '<li><strong>记忆印迹</strong>—全部 engram 的卡片/目录视图,支持按 tag/kind/status 过滤,'
1746
+ + '点击进入详情(可编辑/删除/查看突触)。</li>'
1747
+ + '<li><strong>记忆突触</strong>—知识图谱可视化。'
1748
+ + '可按族/类型过滤边,按 engram 类型过滤节点。打开 engram 详情时图谱会高亮其邻居。</li>'
1749
+ + '<li><strong>记忆提案</strong>—候选记忆审批队列。系统从对话中提取候选,'
1750
+ + '由人工/LLM 采纳(engram_accept_proposal)或忽略(engram_dismiss_proposal)。</li>'
1751
+ + '<li><strong>审计</strong>—操作时间线,记录 create/update/reinforce/report_failure 等所有状态变更,'
1752
+ + '便于追溯"谁在何时改了什么"。</li>'
1753
+ + '<li><strong>记忆回收站</strong>—被删除的 engram 暂存处。可恢复单个,或一键清空(支持按分区筛选,'
1754
+ + '永久删除前会 dryRun 预扫描条数 + 二次确认)。</li>'
1755
+ + '<li><strong>配置</strong>—数据根目录、维护周期、自进化参数等。改持久化配置后需重启宿主生效。</li>'
1756
+ + '</ul>'
1757
+
1758
+ + '<h3>记忆怎么自动进化</h3>'
1759
+ + '<ol style="padding-left:1.2rem">'
1760
+ + '<li><strong>检索</strong>:agent 调 <code>memory_search</code>,FTS + 三因子打分召回 top-N。</li>'
1761
+ + '<li><strong>引用</strong>:agent 把相关记忆内容写进答案,用户据此决策。</li>'
1762
+ + '<li><strong>强化</strong>:agent 自主判断引用是否有效——有效调 <code>engram_reinforce</code>,'
1763
+ + '出错调 <code>engram_report_failure</code>。</li>'
1764
+ + '<li><strong>扩散</strong>:强化通过突触按 Hebbian 比例扩散到邻居(contradicts 除外)。</li>'
1765
+ + '<li><strong>衰减</strong>:每个 engram 有 <code>decayHalfLifeDays</code>,'
1766
+ + 'importance 按 lastEffectiveAt + 半衰期指数衰减。</li>'
1767
+ + '<li><strong>维护</strong>:后台周期跑 light/deep/rem 三阶段,'
1768
+ + '完成"巩固强化 → 衰减遗忘 → REM 抽象模式 → 触发元认知评分"。</li>'
1769
+ + '</ol>'
1770
+
1771
+ + '<h3>提示</h3>'
1772
+ + '<ul style="padding-left:1.2rem">'
1773
+ + '<li>字段名旁的 <code>?</code> 图标(鼠标悬停)有该字段的简短解释。</li>'
1774
+ + '<li>详情视图的"价值评估/多维重要性/记忆产生情境"段落仅在 engram 携带相应字段时显示。</li>'
1775
+ + '<li>配置 tab 的修改默认写入持久化文件,重启宿主(如 <code>openclaw gateway restart</code>)后生效。</li>'
1776
+ + '<li>遇到仓库不一致,可在 agent 中调 <code>engram_doctor</code> 自愈扫描。</li>'
1777
+ + '</ul>'
1778
+
1779
+ + '</div>';
1780
+ }
1781
+ };
1782
+ `;
1783
+ //# sourceMappingURL=tabs.js.map