dsh-loci 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -1,1369 +1,1369 @@
1
- /**
2
- * dsh-loci — Client half.
3
- *
4
- * Two surfaces over the Host's owner-only API:
5
- *
6
- * - a **sidebar mirror** (`sidebar.footer.action`): a flat, dense card that
7
- * shows what memory changed today and last week, with the last few changes
8
- * as one-liners. It is the "副屏" — glanceable, never modal;
9
- * - a **panel** (`sidebar.panellist` + `main`): the full view, with a feed,
10
- * the exact injected index, search, the review queue and statistics.
11
- *
12
- * Styling follows the shell: `--dsw-alias-*` tokens only, 1px borders, no
13
- * shadows or gradients, 12–13px type, square-ish 6–8px radii. Version text is
14
- * small and quiet, like the other sidebar cards in this ecosystem.
15
- *
16
- * Hand-authored in the module-loader format (`window.__ModuleLoader__.load`),
17
- * which is what the Host serves for `dsh.client` packages.
18
- */
19
- window.__ModuleLoader__.load({
20
- id: 'dsh-loci',
21
- factory(require) {
22
- const React = require('react');
23
- const h = React.createElement;
24
- const { useCallback, useEffect, useMemo, useState } = React;
25
-
26
- const NS = 'loci';
27
- const API = '/api/loci';
28
- const ENTRY_ID = 'dsh-loci';
29
- const PANEL_ID = 'loci';
30
-
31
- const EN = {
32
- 'meta.title': 'Loci memory palace',
33
- 'meta.description':
34
- 'Layered long-term memory: the prompt holds a stable index, full text is one lookup away, and only verified facts are injected.',
35
-
36
- 'card.title': 'Memory',
37
- 'card.empty': 'Nothing understood yet',
38
- 'card.emptyHint': 'Confirm a fact and it appears here',
39
- 'card.updated': 'updated',
40
- 'card.pending': '{n} still need your review',
41
- 'card.pinned': 'pinned',
42
- 'card.open': 'Open memory',
43
- 'card.check': 'Check for updates',
44
- 'card.upToDate': 'Up to date',
45
- 'card.updateTo': 'Update available',
46
- 'card.checking': 'Checking…',
47
- 'card.unknown': 'Version check unavailable',
48
- 'card.unpublished': 'Not on npm yet',
49
-
50
- 'prose.agreed': 'we agreed ',
51
- 'prose.lesson': 'lesson — ',
52
-
53
- 'kind.rule': 'agreed',
54
- 'kind.procedural': 'lesson',
55
- 'kind.semantic': 'fact',
56
- 'kind.episodic': 'event',
57
- 'kind.working': 'draft',
58
-
59
- 'since.now': 'just now',
60
- 'since.minutes': '{n}m ago',
61
- 'since.hours': '{n}h ago',
62
- 'since.yesterday': 'yesterday',
63
- 'since.days': '{n}d ago',
64
-
65
- 'action.added': 'added',
66
- 'action.revised': 'revised',
67
- 'action.verified': 'verified',
68
- 'action.disputed': 'disputed',
69
- 'action.pinned': 'pinned',
70
- 'action.unpinned': 'unpinned',
71
- 'action.deleted': 'deleted',
72
- 'action.purged': 'purged',
73
- 'action.restored': 'restored',
74
- 'action.imported': 'imported',
75
- 'action.maintained': 'maintenance',
76
-
77
- 'settings.title': 'Loci',
78
- 'settings.intro': 'Long-term memory for this agent. Fields marked live apply immediately.',
79
- 'settings.unavailable': 'Live editing needs the settings service; these are the effective values.',
80
- 'settings.saved': 'saved',
81
- 'settings.rejected': 'rejected by the host',
82
- 'settings.failed': 'write failed',
83
- 'settings.budget': 'Index budget',
84
- 'settings.budgetHint': 'bytes for the whole injected section',
85
- 'settings.scope': 'Default scope for new facts',
86
- 'settings.capture': 'Session capture',
87
- 'settings.assist': 'First-turn recall assist',
88
- 'settings.maintenance': 'Background maintenance',
89
- 'settings.secret': 'Secrets in memory',
90
- 'settings.vector': 'Embedding channel',
91
- 'settings.diagnostics': 'Diagnostics',
92
- 'settings.danger': 'Delete everything',
93
- 'settings.dangerHint': 'Type the phrase to confirm. Entries are soft-deleted: history stays readable and exportable.',
94
- 'settings.dangerPhrase': 'forget everything',
95
- 'settings.dangerRun': 'Delete all memory',
96
- 'settings.dangerBusy': 'Deleting…',
97
- 'settings.dangerDone': 'Deleted {n} entries.',
98
- 'off': 'off',
99
- 'captureRules': 'rules only',
100
- 'secretRedact': 'redact',
101
- 'secretReject': 'refuse the write',
102
- 'vectorOff': 'off (lexical only)',
103
- 'vectorHttp': 'HTTP endpoint',
104
-
105
- 'panel.title': 'Memory',
106
- 'panel.feed': 'Feed',
107
- 'panel.index': 'Index',
108
- 'panel.search': 'Search',
109
- 'panel.review': 'Review',
110
- 'panel.stats': 'Stats',
111
- 'panel.refresh': 'Refresh',
112
- 'panel.loading': 'Loading…',
113
- 'panel.empty': 'Nothing here yet.',
114
- 'panel.feedHint': 'What was remembered, revised, verified or removed. Newest first.',
115
- 'panel.indexHint': 'Exactly what the model sees in its system prompt, with its byte cost.',
116
- 'panel.bytes': 'bytes',
117
- 'panel.listed': 'listed',
118
- 'panel.hidden': 'hidden',
119
- 'panel.searchPlaceholder': 'Search titles and bodies…',
120
- 'panel.searchEmpty': 'No match.',
121
- 'panel.more': 'Load more',
122
- 'panel.total': 'total',
123
- 'panel.verify': 'Verify',
124
- 'panel.dispute': 'Dispute',
125
- 'panel.delete': 'Delete',
126
- 'panel.restore': 'Restore',
127
- 'panel.pin': 'Pin',
128
- 'panel.unpin': 'Unpin',
129
- 'panel.back': 'Back',
130
- 'panel.history': 'History',
1
+ /**
2
+ * dsh-loci — Client half.
3
+ *
4
+ * Two surfaces over the Host's owner-only API:
5
+ *
6
+ * - a **sidebar mirror** (`sidebar.footer.action`): a flat, dense card that
7
+ * shows what memory changed today and last week, with the last few changes
8
+ * as one-liners. It is the "副屏" — glanceable, never modal;
9
+ * - a **panel** (`sidebar.panellist` + `main`): the full view, with a feed,
10
+ * the exact injected index, search, the review queue and statistics.
11
+ *
12
+ * Styling follows the shell: `--dsw-alias-*` tokens only, 1px borders, no
13
+ * shadows or gradients, 12–13px type, square-ish 6–8px radii. Version text is
14
+ * small and quiet, like the other sidebar cards in this ecosystem.
15
+ *
16
+ * Hand-authored in the module-loader format (`window.__ModuleLoader__.load`),
17
+ * which is what the Host serves for `dsh.client` packages.
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-loci',
21
+ factory(require) {
22
+ const React = require('react');
23
+ const h = React.createElement;
24
+ const { useCallback, useEffect, useMemo, useState } = React;
25
+
26
+ const NS = 'loci';
27
+ const API = '/api/loci';
28
+ const ENTRY_ID = 'dsh-loci';
29
+ const PANEL_ID = 'loci';
30
+
31
+ const EN = {
32
+ 'meta.title': 'Loci memory palace',
33
+ 'meta.description':
34
+ 'Layered long-term memory: the prompt holds a stable index, full text is one lookup away, and only verified facts are injected.',
35
+
36
+ 'card.title': 'Memory',
37
+ 'card.empty': 'Nothing understood yet',
38
+ 'card.emptyHint': 'Confirm a fact and it appears here',
39
+ 'card.updated': 'updated',
40
+ 'card.pending': '{n} still need your review',
41
+ 'card.pinned': 'pinned',
42
+ 'card.open': 'Open memory',
43
+ 'card.check': 'Check for updates',
44
+ 'card.upToDate': 'Up to date',
45
+ 'card.updateTo': 'Update available',
46
+ 'card.checking': 'Checking…',
47
+ 'card.unknown': 'Version check unavailable',
48
+ 'card.unpublished': 'Not on npm yet',
49
+
50
+ 'prose.agreed': 'we agreed ',
51
+ 'prose.lesson': 'lesson — ',
52
+
53
+ 'kind.rule': 'agreed',
54
+ 'kind.procedural': 'lesson',
55
+ 'kind.semantic': 'fact',
56
+ 'kind.episodic': 'event',
57
+ 'kind.working': 'draft',
58
+
59
+ 'since.now': 'just now',
60
+ 'since.minutes': '{n}m ago',
61
+ 'since.hours': '{n}h ago',
62
+ 'since.yesterday': 'yesterday',
63
+ 'since.days': '{n}d ago',
64
+
65
+ 'action.added': 'added',
66
+ 'action.revised': 'revised',
67
+ 'action.verified': 'verified',
68
+ 'action.disputed': 'disputed',
69
+ 'action.pinned': 'pinned',
70
+ 'action.unpinned': 'unpinned',
71
+ 'action.deleted': 'deleted',
72
+ 'action.purged': 'purged',
73
+ 'action.restored': 'restored',
74
+ 'action.imported': 'imported',
75
+ 'action.maintained': 'maintenance',
76
+
77
+ 'settings.title': 'Loci',
78
+ 'settings.intro': 'Long-term memory for this agent. Fields marked live apply immediately.',
79
+ 'settings.unavailable': 'Live editing needs the settings service; these are the effective values.',
80
+ 'settings.saved': 'saved',
81
+ 'settings.rejected': 'rejected by the host',
82
+ 'settings.failed': 'write failed',
83
+ 'settings.budget': 'Index budget',
84
+ 'settings.budgetHint': 'bytes for the whole injected section',
85
+ 'settings.scope': 'Default scope for new facts',
86
+ 'settings.capture': 'Session capture',
87
+ 'settings.assist': 'First-turn recall assist',
88
+ 'settings.maintenance': 'Background maintenance',
89
+ 'settings.secret': 'Secrets in memory',
90
+ 'settings.vector': 'Embedding channel',
91
+ 'settings.diagnostics': 'Diagnostics',
92
+ 'settings.danger': 'Delete everything',
93
+ 'settings.dangerHint': 'Type the phrase to confirm. Entries are soft-deleted: history stays readable and exportable.',
94
+ 'settings.dangerPhrase': 'forget everything',
95
+ 'settings.dangerRun': 'Delete all memory',
96
+ 'settings.dangerBusy': 'Deleting…',
97
+ 'settings.dangerDone': 'Deleted {n} entries.',
98
+ 'off': 'off',
99
+ 'captureRules': 'rules only',
100
+ 'secretRedact': 'redact',
101
+ 'secretReject': 'refuse the write',
102
+ 'vectorOff': 'off (lexical only)',
103
+ 'vectorHttp': 'HTTP endpoint',
104
+
105
+ 'panel.title': 'Memory',
106
+ 'panel.feed': 'Feed',
107
+ 'panel.index': 'Index',
108
+ 'panel.search': 'Search',
109
+ 'panel.review': 'Review',
110
+ 'panel.stats': 'Stats',
111
+ 'panel.refresh': 'Refresh',
112
+ 'panel.loading': 'Loading…',
113
+ 'panel.empty': 'Nothing here yet.',
114
+ 'panel.feedHint': 'What was remembered, revised, verified or removed. Newest first.',
115
+ 'panel.indexHint': 'Exactly what the model sees in its system prompt, with its byte cost.',
116
+ 'panel.bytes': 'bytes',
117
+ 'panel.listed': 'listed',
118
+ 'panel.hidden': 'hidden',
119
+ 'panel.searchPlaceholder': 'Search titles and bodies…',
120
+ 'panel.searchEmpty': 'No match.',
121
+ 'panel.more': 'Load more',
122
+ 'panel.total': 'total',
123
+ 'panel.verify': 'Verify',
124
+ 'panel.dispute': 'Dispute',
125
+ 'panel.delete': 'Delete',
126
+ 'panel.restore': 'Restore',
127
+ 'panel.pin': 'Pin',
128
+ 'panel.unpin': 'Unpin',
129
+ 'panel.back': 'Back',
130
+ 'panel.history': 'History',
131
131
  'panel.evidence': 'Evidence',
132
- 'panel.source': 'Source',
133
- 'panel.candidates': 'Candidates',
134
- 'panel.disputed': 'Disputed',
135
- 'panel.noCandidates': 'Nothing waiting for review.',
136
- 'panel.reviewHint': 'Only entries that could be injected are listed. A summary of a past session is history, not a claim.',
137
- 'panel.reject': 'Reject',
138
- 'panel.verifyAll': 'Verify all {n}',
139
- 'panel.actionFailed': 'Action failed',
140
- 'panel.injections': 'Recent injections',
141
- 'panel.store': 'Store',
142
- 'panel.confirmDelete': 'Soft-delete this entry?',
143
- 'panel.error': 'Request failed',
144
- 'panel.updated': 'Updated',
145
- 'panel.version': 'version',
146
- };
147
-
148
- const ZH = {
149
- 'meta.title': 'Loci 记忆宫殿',
150
- 'meta.description':
151
- '分层长期记忆:提示词里只放稳定索引,全文按需一次取回;只有已核实的记忆才会被注入。',
152
-
153
- 'card.title': '记忆',
154
- 'card.empty': '还没有真正记下的东西',
155
- 'card.emptyHint': '你确认过的事实会出现在这里',
156
- 'card.updated': '更新于',
157
- 'card.pending': '另有 {n} 条我拿不准,等你看一眼',
158
- 'card.pinned': '置顶',
159
- 'card.open': '打开记忆',
160
- 'card.check': '检查更新',
161
- 'card.upToDate': '已是最新',
162
- 'card.updateTo': '有新版本',
163
- 'card.checking': '检查中…',
164
- 'card.unknown': '无法检查版本',
165
- 'card.unpublished': '尚未发布',
166
-
167
- 'prose.agreed': '已定:',
168
- 'prose.lesson': '经验:',
169
-
170
- 'kind.rule': '共识',
171
- 'kind.procedural': '经验',
172
- 'kind.semantic': '事实',
173
- 'kind.episodic': '事件',
174
- 'kind.working': '草稿',
175
-
176
- 'since.now': '刚才',
177
- 'since.minutes': '{n} 分钟前',
178
- 'since.hours': '{n} 小时前',
179
- 'since.yesterday': '昨天',
180
- 'since.days': '{n} 天前',
181
-
182
- 'action.added': '新增',
183
- 'action.revised': '更正',
184
- 'action.verified': '核实',
185
- 'action.disputed': '存疑',
186
- 'action.pinned': '置顶',
187
- 'action.unpinned': '取消置顶',
188
- 'action.deleted': '删除',
189
- 'action.purged': '彻底删除',
190
- 'action.restored': '恢复',
191
- 'action.imported': '导入',
192
- 'action.maintained': '维护',
193
-
194
- 'settings.title': 'Loci',
195
- 'settings.intro': '给 agent 的长期记忆。标注「即时」的改动立即生效。',
196
- 'settings.unavailable': '当前缺少设置服务,以下显示实际生效的配置。',
197
- 'settings.saved': '已保存',
198
- 'settings.rejected': '宿主拒绝写入',
199
- 'settings.failed': '写入失败',
200
- 'settings.budget': '索引预算',
201
- 'settings.budgetHint': '整段注入的字节上限',
202
- 'settings.scope': '新事实的默认作用域',
203
- 'settings.capture': '会话捕获',
204
- 'settings.assist': '首轮辅助召回',
205
- 'settings.maintenance': '后台维护',
206
- 'settings.secret': '记忆中的密钥',
207
- 'settings.vector': '向量通道',
208
- 'settings.diagnostics': '诊断',
209
- 'settings.danger': '清空记忆',
210
- 'settings.dangerHint': '输入确认短语才会执行。条目为软删除:历史仍可读、可导出。',
211
- 'settings.dangerPhrase': 'forget everything',
212
- 'settings.dangerRun': '删除全部记忆',
213
- 'settings.dangerBusy': '删除中…',
214
- 'settings.dangerDone': '已删除 {n} 条。',
215
- 'off': '关闭',
216
- 'captureRules': '仅规则',
217
- 'secretRedact': '脱敏',
218
- 'secretReject': '拒绝写入',
219
- 'vectorOff': '关闭(仅词法)',
220
- 'vectorHttp': 'HTTP 端点',
221
-
222
- 'panel.title': '记忆',
223
- 'panel.feed': '动态',
224
- 'panel.index': '索引',
225
- 'panel.search': '检索',
226
- 'panel.review': '待审',
227
- 'panel.stats': '统计',
228
- 'panel.refresh': '刷新',
229
- 'panel.loading': '加载中…',
230
- 'panel.empty': '这里还没有内容。',
231
- 'panel.feedHint': '记住了什么、更正了什么、核实或删除了什么。最新的在前。',
232
- 'panel.indexHint': '与模型系统提示里完全一致的那一段,以及它的字节开销。',
233
- 'panel.bytes': '字节',
234
- 'panel.listed': '已列出',
235
- 'panel.hidden': '未列出',
236
- 'panel.searchPlaceholder': '检索标题与正文…',
237
- 'panel.searchEmpty': '没有匹配。',
238
- 'panel.more': '加载更多',
239
- 'panel.total': '总数',
240
- 'panel.verify': '核实',
241
- 'panel.dispute': '标记争议',
242
- 'panel.delete': '删除',
243
- 'panel.restore': '恢复',
244
- 'panel.pin': '置顶',
245
- 'panel.unpin': '取消置顶',
246
- 'panel.back': '返回',
247
- 'panel.history': '历史',
132
+ 'panel.source': 'Source',
133
+ 'panel.candidates': 'Candidates',
134
+ 'panel.disputed': 'Disputed',
135
+ 'panel.noCandidates': 'Nothing waiting for review.',
136
+ 'panel.reviewHint': 'Only entries that could be injected are listed. A summary of a past session is history, not a claim.',
137
+ 'panel.reject': 'Reject',
138
+ 'panel.verifyAll': 'Verify all {n}',
139
+ 'panel.actionFailed': 'Action failed',
140
+ 'panel.injections': 'Recent injections',
141
+ 'panel.store': 'Store',
142
+ 'panel.confirmDelete': 'Soft-delete this entry?',
143
+ 'panel.error': 'Request failed',
144
+ 'panel.updated': 'Updated',
145
+ 'panel.version': 'version',
146
+ };
147
+
148
+ const ZH = {
149
+ 'meta.title': 'Loci 记忆宫殿',
150
+ 'meta.description':
151
+ '分层长期记忆:提示词里只放稳定索引,全文按需一次取回;只有已核实的记忆才会被注入。',
152
+
153
+ 'card.title': '记忆',
154
+ 'card.empty': '还没有真正记下的东西',
155
+ 'card.emptyHint': '你确认过的事实会出现在这里',
156
+ 'card.updated': '更新于',
157
+ 'card.pending': '另有 {n} 条我拿不准,等你看一眼',
158
+ 'card.pinned': '置顶',
159
+ 'card.open': '打开记忆',
160
+ 'card.check': '检查更新',
161
+ 'card.upToDate': '已是最新',
162
+ 'card.updateTo': '有新版本',
163
+ 'card.checking': '检查中…',
164
+ 'card.unknown': '无法检查版本',
165
+ 'card.unpublished': '尚未发布',
166
+
167
+ 'prose.agreed': '已定:',
168
+ 'prose.lesson': '经验:',
169
+
170
+ 'kind.rule': '共识',
171
+ 'kind.procedural': '经验',
172
+ 'kind.semantic': '事实',
173
+ 'kind.episodic': '事件',
174
+ 'kind.working': '草稿',
175
+
176
+ 'since.now': '刚才',
177
+ 'since.minutes': '{n} 分钟前',
178
+ 'since.hours': '{n} 小时前',
179
+ 'since.yesterday': '昨天',
180
+ 'since.days': '{n} 天前',
181
+
182
+ 'action.added': '新增',
183
+ 'action.revised': '更正',
184
+ 'action.verified': '核实',
185
+ 'action.disputed': '存疑',
186
+ 'action.pinned': '置顶',
187
+ 'action.unpinned': '取消置顶',
188
+ 'action.deleted': '删除',
189
+ 'action.purged': '彻底删除',
190
+ 'action.restored': '恢复',
191
+ 'action.imported': '导入',
192
+ 'action.maintained': '维护',
193
+
194
+ 'settings.title': 'Loci',
195
+ 'settings.intro': '给 agent 的长期记忆。标注「即时」的改动立即生效。',
196
+ 'settings.unavailable': '当前缺少设置服务,以下显示实际生效的配置。',
197
+ 'settings.saved': '已保存',
198
+ 'settings.rejected': '宿主拒绝写入',
199
+ 'settings.failed': '写入失败',
200
+ 'settings.budget': '索引预算',
201
+ 'settings.budgetHint': '整段注入的字节上限',
202
+ 'settings.scope': '新事实的默认作用域',
203
+ 'settings.capture': '会话捕获',
204
+ 'settings.assist': '首轮辅助召回',
205
+ 'settings.maintenance': '后台维护',
206
+ 'settings.secret': '记忆中的密钥',
207
+ 'settings.vector': '向量通道',
208
+ 'settings.diagnostics': '诊断',
209
+ 'settings.danger': '清空记忆',
210
+ 'settings.dangerHint': '输入确认短语才会执行。条目为软删除:历史仍可读、可导出。',
211
+ 'settings.dangerPhrase': 'forget everything',
212
+ 'settings.dangerRun': '删除全部记忆',
213
+ 'settings.dangerBusy': '删除中…',
214
+ 'settings.dangerDone': '已删除 {n} 条。',
215
+ 'off': '关闭',
216
+ 'captureRules': '仅规则',
217
+ 'secretRedact': '脱敏',
218
+ 'secretReject': '拒绝写入',
219
+ 'vectorOff': '关闭(仅词法)',
220
+ 'vectorHttp': 'HTTP 端点',
221
+
222
+ 'panel.title': '记忆',
223
+ 'panel.feed': '动态',
224
+ 'panel.index': '索引',
225
+ 'panel.search': '检索',
226
+ 'panel.review': '待审',
227
+ 'panel.stats': '统计',
228
+ 'panel.refresh': '刷新',
229
+ 'panel.loading': '加载中…',
230
+ 'panel.empty': '这里还没有内容。',
231
+ 'panel.feedHint': '记住了什么、更正了什么、核实或删除了什么。最新的在前。',
232
+ 'panel.indexHint': '与模型系统提示里完全一致的那一段,以及它的字节开销。',
233
+ 'panel.bytes': '字节',
234
+ 'panel.listed': '已列出',
235
+ 'panel.hidden': '未列出',
236
+ 'panel.searchPlaceholder': '检索标题与正文…',
237
+ 'panel.searchEmpty': '没有匹配。',
238
+ 'panel.more': '加载更多',
239
+ 'panel.total': '总数',
240
+ 'panel.verify': '核实',
241
+ 'panel.dispute': '标记争议',
242
+ 'panel.delete': '删除',
243
+ 'panel.restore': '恢复',
244
+ 'panel.pin': '置顶',
245
+ 'panel.unpin': '取消置顶',
246
+ 'panel.back': '返回',
247
+ 'panel.history': '历史',
248
248
  'panel.evidence': '证据',
249
- 'panel.source': '出处',
250
- 'panel.candidates': '候选',
251
- 'panel.disputed': '争议',
252
- 'panel.noCandidates': '没有待审条目。',
253
- 'panel.reviewHint': '这里只列可能被注入的条目;过去会话的摘要属于历史,不是主张。',
254
- 'panel.reject': '驳回',
255
- 'panel.verifyAll': '全部核实({n})',
256
- 'panel.actionFailed': '操作失败',
257
- 'panel.injections': '最近注入',
258
- 'panel.store': '存储',
259
- 'panel.confirmDelete': '软删除这条记忆?',
260
- 'panel.error': '请求失败',
261
- 'panel.updated': '更新于',
262
- 'panel.version': '版本',
263
- };
264
-
265
- const CSS = `
266
- .loci-root { display: flex; flex-direction: column; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); font-size: 13px; }
267
- .loci-card-root { display: flex; flex-direction: column; gap: 6px; width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 8px; cursor: pointer; text-align: left; font: inherit; color: inherit; }
268
- .loci-card-root:hover { background: var(--dsw-alias-bg-layer-2); }
269
- .loci-card-root.is-rail { padding: 6px; align-items: center; justify-content: center; }
270
- .loci-card-head { display: flex; align-items: center; gap: 6px; width: 100%; }
271
- .loci-card-title { font-size: 12px; font-weight: 600; letter-spacing: .01em; }
272
- .loci-card-grow { flex: 1 1 auto; }
273
- .loci-card-ver { font-size: 10px; color: var(--dsw-alias-label-secondary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
274
- .loci-card-mark { display: flex; align-items: center; color: var(--dsw-alias-brand-primary); flex: 0 0 auto; }
275
- .loci-card-note { display: flex; flex-direction: column; gap: 5px; }
276
- .loci-card-stamp { font-size: 10.5px; color: var(--dsw-alias-label-secondary); letter-spacing: .01em; }
277
- .loci-card-prose { margin: 0; font-size: 12px; line-height: 1.62; color: var(--dsw-alias-label-primary); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
278
- .loci-card-hint { font-size: 10.5px; color: var(--dsw-alias-label-secondary); margin-top: 1px; }
279
- .loci-card-empty { font-size: 11px; color: var(--dsw-alias-label-secondary); }
280
- .loci-card-line { display: flex; gap: 6px; align-items: baseline; min-width: 0; }
281
- .loci-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-brand-primary); flex: 0 0 auto; }
282
-
283
- .loci-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
284
- .loci-title { font-size: 14px; font-weight: 600; }
285
- .loci-tabs { display: flex; gap: 2px; }
286
- .loci-tab { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); padding: 5px 10px; border-radius: 6px; cursor: pointer; font: inherit; }
287
- .loci-tab:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
288
- .loci-tab.is-active { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
289
- .loci-grow { flex: 1 1 auto; }
290
- .loci-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 12px 16px 24px; }
291
- .loci-btn { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 3px 9px; font: inherit; cursor: pointer; }
292
- .loci-btn:hover { border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); }
293
- .loci-btn:disabled { opacity: .5; cursor: default; }
294
- .loci-btn.is-primary { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
295
- .loci-btn.is-danger { color: var(--dsw-alias-state-error-primary); }
296
- .loci-btn.is-quiet { border-color: transparent; background: transparent; color: var(--dsw-alias-label-secondary); padding: 1px 6px; font-size: 11px; }
297
- .loci-btn.is-quiet:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
298
- .loci-input { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 6px 9px; font: inherit; }
299
- .loci-row { display: flex; gap: 10px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
300
- .loci-row:last-child { border-bottom: 0; }
301
- .loci-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-label-secondary); flex: 0 0 auto; }
302
- .loci-main { min-width: 0; flex: 1 1 auto; }
303
- .loci-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
304
- .loci-meta { font-size: 11px; color: var(--dsw-alias-label-secondary); display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
305
- .loci-snippet { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 3px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
306
- .loci-actions { display: flex; gap: 6px; flex: 0 0 auto; align-items: center; }
307
- .loci-pill { font-size: 11px; border-radius: 999px; padding: 1px 7px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); }
308
- .loci-pill.is-verified { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }
309
- .loci-pill.is-candidate { color: var(--dsw-alias-state-warn-primary); border-color: var(--dsw-alias-state-warn-primary); }
310
- .loci-pill.is-disputed { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); }
311
- .loci-pill.is-deleted { color: var(--dsw-alias-state-idle-primary); }
312
- .loci-tag { font-size: 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 5px; color: var(--dsw-alias-label-secondary); }
313
- .loci-tag.is-added { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }
314
- .loci-tag.is-revised { color: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
315
- .loci-tag.is-verified { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }
316
- .loci-tag.is-deleted, .loci-tag.is-purged { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); }
317
- .loci-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
318
- .loci-cell { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 8px; padding: 10px 12px; }
319
- .loci-cell b { display: block; font-size: 18px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
320
- .loci-label { font-size: 11px; color: var(--dsw-alias-label-secondary); }
321
- .loci-pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px 12px; margin: 0; }
322
- .loci-section { margin: 18px 0 8px; font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); text-transform: uppercase; letter-spacing: .06em; }
323
- .loci-field { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
324
- .loci-field:last-child { border-bottom: 0; }
325
- .loci-field select, .loci-field input[type="number"] { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 4px 8px; font: inherit; }
326
- .loci-error { color: var(--dsw-alias-state-error-primary); font-size: 12px; margin: 6px 0; }
327
- .loci-muted { color: var(--dsw-alias-label-secondary); font-size: 12px; }
328
- .loci-kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 12px; }
329
- .loci-kv dt { color: var(--dsw-alias-label-secondary); }
330
- .loci-kv dd { margin: 0; }
331
- .loci-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
332
- .loci-evidence { border-left: 2px solid var(--dsw-alias-brand-primary); padding-left: 10px; color: var(--dsw-alias-label-secondary); font-size: 12px; white-space: pre-wrap; }
333
- `;
334
-
335
- /** Locale binding that never throws on a missing key. */
336
- function tOf(bind) {
337
- return (key) => {
338
- try {
339
- const value = bind(key);
340
- return typeof value === 'string' && value.length > 0 ? value : key;
341
- } catch {
342
- return key;
343
- }
344
- };
345
- }
346
-
347
- /** Fetch JSON with the session cookie; throws a readable error. */
348
- async function apiFetch(path, options = {}) {
349
- const response = await fetch(`${API}${path}`, {
350
- credentials: 'same-origin',
351
- headers: options.body === undefined ? {} : { 'content-type': 'application/json' },
352
- ...options,
353
- });
354
- const text = await response.text();
355
- let payload;
356
- try {
357
- payload = text.length === 0 ? {} : JSON.parse(text);
358
- } catch {
359
- throw new Error(`HTTP ${response.status}: ${text.slice(0, 160)}`);
360
- }
361
- if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`);
362
- return payload;
363
- }
364
-
365
- /** Subscribe to one GET endpoint with an optional poll interval. */
366
- function useJson(path, options = {}) {
367
- const { intervalMs, enabled = true } = options;
368
- const [state, setState] = useState({ loading: true, data: undefined, error: undefined });
369
- const [nonce, setNonce] = useState(0);
370
- const reload = useCallback(() => setNonce((value) => value + 1), []);
371
- useEffect(() => {
372
- if (path === undefined || enabled !== true) return undefined;
373
- const controller = new AbortController();
374
- setState((previous) => ({ ...previous, loading: true }));
375
- apiFetch(path, { signal: controller.signal })
376
- .then((data) => setState({ loading: false, data, error: undefined }))
377
- .catch((error) => {
378
- if (controller.signal.aborted) return;
379
- setState({ loading: false, data: undefined, error: String(error?.message ?? error) });
380
- });
381
- return () => controller.abort();
382
- }, [path, nonce, enabled]);
383
- useEffect(() => {
384
- if (intervalMs === undefined || enabled !== true) return undefined;
385
- const timer = setInterval(reload, intervalMs);
386
- return () => clearInterval(timer);
387
- }, [intervalMs, reload, enabled]);
388
- return { ...state, reload };
389
- }
390
-
391
- const fmtDate = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : '—');
392
- const fmtBytes = (n) => `${Math.round(Number(n) || 0)} B`;
393
-
394
- /**
395
- * Turn the few things currently understood into one paragraph.
396
- *
397
- * The card is a page of notes, so this reads as prose rather than a list: one
398
- * clause per entry, joined with 。or , and cut when it stops fitting. Consensus
399
- * items lead with 已定 and a fact leads with its own subject, because "we
400
- * agreed" and "it happens to be true" are different kinds of knowing.
401
- */
402
- function proseOf(notes, t) {
403
- const clauses = [];
404
- for (const note of notes) {
405
- const title = String(note.title ?? '').trim().replace(/[。.]$/, '');
406
- if (title.length === 0) continue;
407
- const gist = typeof note.gist === 'string' && note.gist !== title ? note.gist : null;
408
- const body = gist === null ? title : `${title},${gist}`;
409
- if (note.kind === 'rule') clauses.push(`${t('prose.agreed')}${body}`);
410
- else if (note.kind === 'procedural') clauses.push(`${t('prose.lesson')}${body}`);
411
- else clauses.push(body);
412
- // The card is narrow; three clauses already fill it. A fourth only shows
413
- // up when the first ones are unusually short.
414
- if (clauses.join(';').length > 90) break;
415
- }
416
- return `${clauses.join(';')}。`;
417
- }
418
-
419
- /** `2026-11-13 21:11`, matching the note style of the other sidebars. */
420
- function stampOf(ms) {
421
- if (!Number.isFinite(ms)) return '';
422
- const date = new Date(ms);
423
- const pad = (value) => String(value).padStart(2, '0');
424
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
425
- }
426
-
427
-
428
- /**
429
- * How long ago something was understood, in words rather than numbers.
430
- *
431
- * "昨天" and "刚才" are the whole point of the card: they are what makes a
432
- * reader believe the memory is alive. A bare timestamp makes them do the
433
- * arithmetic instead.
434
- */
435
- function fmtSince(ms, t, now = Date.now()) {
436
- if (!Number.isFinite(ms)) return '';
437
- const delta = Math.max(0, now - ms);
438
- const minute = 60_000;
439
- if (delta < minute) return t('since.now');
440
- if (delta < 60 * minute) return t('since.minutes').replace('{n}', String(Math.floor(delta / minute)));
441
- if (delta < 24 * 60 * minute) return t('since.hours').replace('{n}', String(Math.floor(delta / (60 * minute))));
442
- if (delta < 48 * 60 * minute) return t('since.yesterday');
443
- if (delta < 30 * 24 * 60 * minute) {
444
- return t('since.days').replace('{n}', String(Math.floor(delta / (24 * 60 * minute))));
445
- }
446
- return new Date(ms).toISOString().slice(0, 10);
447
- }
448
-
449
- const ACTION_KEY = (action) => `action.${action}`;
450
-
451
- function StatusPill({ status, t }) {
452
- return h('span', { className: `loci-pill is-${status}` }, status);
453
- }
454
-
455
- function ErrorLine({ error, t }) {
456
- if (error === undefined) return null;
457
- return h('div', { className: 'loci-error' }, `${t('panel.error')}: ${error}`);
458
- }
459
-
460
- function Tabs({ value, onChange, tabs }) {
461
- return h(
462
- 'div',
463
- { className: 'loci-tabs', role: 'tablist' },
464
- tabs.map((tab) =>
465
- h(
466
- 'button',
467
- {
468
- key: tab.id,
469
- type: 'button',
470
- role: 'tab',
471
- 'aria-selected': value === tab.id,
472
- className: `loci-tab${value === tab.id ? ' is-active' : ''}`,
473
- onClick: () => onChange(tab.id),
474
- },
475
- tab.label,
476
- ),
477
- ),
478
- );
479
- }
480
-
481
- /** The small version line shared by the card and the panel footer. */
482
- function VersionLine({ t, update, onCheck, checking }) {
483
- const version = typeof update?.version === 'string' ? update.version.replace(/^v/i, '') : null;
484
- const hasUpdate = update?.updateAvailable === true;
485
- const unknown = update !== undefined && update.status === 'unavailable';
486
- const text = hasUpdate
487
- ? `${t('card.updateTo')}${update.latestVersion ? ` · v${update.latestVersion}` : ''}`
488
- : unknown
489
- ? t('card.unknown')
490
- : update?.status === 'ok'
491
- ? t('card.upToDate')
492
- : t('card.check');
493
- return h(
494
- 'span',
495
- { className: 'loci-card-line', style: { gap: '5px' } },
496
- version ? h('span', { className: 'loci-card-ver' }, `v${version}`) : null,
497
- h(
498
- 'button',
499
- {
500
- type: 'button',
501
- className: 'loci-btn is-quiet',
502
- title: text,
503
- disabled: checking === true,
504
- onClick: (event) => {
505
- event.stopPropagation();
506
- onCheck?.();
507
- },
508
- },
509
- checking === true ? t('card.checking') : text,
510
- ),
511
- hasUpdate ? h('span', { className: 'loci-dot' }) : null,
512
- );
513
- }
514
-
515
- // ------------------------------------------------------------ sidebar card
516
-
517
- function MemoryCard(props) {
518
- const t = props?.t ?? ((key) => key);
519
- const wide = props?.wide !== false;
520
- const open = props?.openPanel;
521
- // The card asks "what does it know", not "what happened": a page of notes,
522
- // not a dashboard. No counters — a number tells you something changed, a
523
- // sentence tells you what was understood.
524
- const knowledge = useJson('/knowledge?limit=4', { intervalMs: 30_000 });
525
- const update = useJson('/update');
526
- const [checking, setChecking] = useState(false);
527
- const [fresh, setFresh] = useState(undefined);
528
-
529
- const check = useCallback(async () => {
530
- setChecking(true);
531
- try {
532
- const answer = await apiFetch('/update?refresh=1');
533
- setFresh(answer);
534
- } catch {
535
- setFresh({ version: undefined, status: 'unavailable', updateAvailable: false });
536
- } finally {
537
- setChecking(false);
538
- }
539
- }, []);
540
-
541
- const data = knowledge.data;
542
- const notes = data?.items ?? [];
543
- const pending = Number(data?.pending ?? 0);
544
-
545
- if (!wide) {
546
- return h(
547
- 'button',
548
- {
549
- type: 'button',
550
- className: 'loci-card-root is-rail',
551
- title: t('card.open'),
552
- 'aria-label': t('card.open'),
553
- onClick: () => open?.(),
554
- },
555
- h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
556
- h('path', { d: 'M4 6.5h16M4 12h16M4 17.5h10', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }),
557
- h('circle', { cx: 18.5, cy: 17.5, r: 2.2, stroke: 'currentColor', strokeWidth: 1.5 }),
558
- ),
559
- );
560
- }
561
-
562
- return h(
563
- 'div',
564
- {
565
- className: 'loci-card-root',
566
- role: 'button',
567
- tabIndex: 0,
568
- onClick: () => open?.(),
569
- onKeyDown: (event) => {
570
- if (event.key === 'Enter' || event.key === ' ') open?.();
571
- },
572
- },
573
- h(
574
- 'div',
575
- { className: 'loci-card-head' },
576
- h(
577
- 'span',
578
- { className: 'loci-card-mark', 'aria-hidden': true },
579
- h('svg', { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none' },
580
- h('path', {
581
- d: 'M20 4c0 9-6.5 15-16 16 0 0 .5-7 4.5-11S20 4 20 4Z',
582
- stroke: 'currentColor',
583
- strokeWidth: 1.6,
584
- strokeLinejoin: 'round',
585
- }),
586
- h('path', { d: 'M5 20 14 10', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }),
587
- ),
588
- ),
589
- h('span', { className: 'loci-card-title' }, t('card.title')),
590
- h('span', { className: 'loci-card-grow' }),
591
- h(VersionLine, { t, update: fresh ?? update.data, onCheck: check, checking }),
592
- ),
593
- data === undefined
594
- ? h('div', { className: 'loci-card-empty' }, knowledge.error ?? t('panel.loading'))
595
- : notes.length === 0
596
- ? h(
597
- 'div',
598
- null,
599
- h('div', { className: 'loci-card-empty' }, t('card.empty')),
600
- h('div', { className: 'loci-card-hint' }, t('card.emptyHint')),
601
- )
602
- : h(
603
- 'div',
604
- { className: 'loci-card-note' },
605
- h(
606
- 'div',
607
- { className: 'loci-card-stamp' },
608
- `${t('card.updated')} ${stampOf(notes[0]?.at ?? data.generatedAt)}`,
609
- ),
610
- h('p', { className: 'loci-card-prose' }, proseOf(notes, t)),
611
- pending > 0
612
- ? h('div', { className: 'loci-card-hint' }, t('card.pending').replace('{n}', String(pending)))
613
- : null,
614
- ),
615
- );
616
- }
617
-
618
- // ---------------------------------------------------------------- panel
619
-
620
- function FeedTab({ t, onOpen }) {
621
- const { data, error, loading, reload } = useJson('/activity?limit=60', { intervalMs: 20_000 });
622
- // One row per entry, not per event: "added earlier, verified just now" is
623
- // one story. Events for the same id collapse into a chain of tags and the
624
- // row keeps the position of its newest event.
625
- const rows = [];
626
- const byId = new Map();
627
- for (const item of data?.items ?? []) {
628
- const seen = item.id === null ? undefined : byId.get(item.id);
629
- if (seen !== undefined) {
630
- if (!seen.chain.includes(item.action)) seen.chain.push(item.action);
631
- continue;
632
- }
633
- const row = { ...item, chain: [item.action] };
634
- rows.push(row);
635
- if (item.id !== null) byId.set(item.id, row);
636
- }
637
- return h(
638
- 'div',
639
- null,
640
- h(
641
- 'div',
642
- { className: 'loci-meta', style: { marginBottom: '10px', alignItems: 'center' } },
643
- h('span', { className: 'loci-muted' }, t('panel.feedHint')),
644
- h('span', { className: 'loci-grow' }),
645
- h('button', { type: 'button', className: 'loci-btn', onClick: reload }, t('panel.refresh')),
646
- ),
647
- h(ErrorLine, { error, t }),
648
- loading && data === undefined ? h('div', { className: 'loci-muted' }, t('panel.loading')) : null,
649
- data !== undefined && rows.length === 0 ? h('div', { className: 'loci-muted' }, t('panel.empty')) : null,
650
- rows.map((item, index) =>
651
- h(
652
- 'div',
653
- { key: `${item.ts}-${item.id ?? 'x'}-${index}`, className: 'loci-row' },
654
- h(
655
- 'span',
656
- { className: 'loci-actions', style: { gap: '3px' } },
657
- item.chain.map((action, step) =>
658
- h(
659
- 'span',
660
- { key: `${action}-${step}`, className: 'loci-tag is-' + action, style: { display: 'flex', gap: '3px' } },
661
- step > 0 ? h('span', { className: 'loci-muted' }, '→') : null,
662
- t(ACTION_KEY(action)),
663
- ),
664
- ),
665
- ),
666
- h(
667
- 'div',
668
- { className: 'loci-main' },
669
- h('div', { className: 'loci-name' }, item.title ?? (item.action === 'maintained' ? t('action.maintained') : '—')),
670
- h(
671
- 'div',
672
- { className: 'loci-meta' },
673
- h('span', null, fmtSince(item.ts, t)),
674
- item.kind ? h('span', null, t(`kind.${item.kind}`)) : null,
675
- item.scope ? h('span', null, item.scope) : null,
676
- item.detail
677
- ? h(
678
- 'span',
679
- null,
680
- `${item.detail.closedCandidates} closed${item.detail.reindexed ? ' · reindexed' : ''}`,
681
- )
682
- : null,
683
- ),
684
- ),
685
- h(
686
- 'div',
687
- { className: 'loci-actions' },
688
- item.id
689
- ? h('button', { type: 'button', className: 'loci-btn is-quiet', onClick: () => onOpen(item.id) }, '→')
690
- : null,
691
- ),
692
- ),
693
- ),
694
- );
695
- }
696
-
697
- function Row({ item, t, onOpen }) {
698
- return h(
699
- 'div',
700
- { className: 'loci-row' },
701
- h('span', { className: 'loci-id' }, item.id.slice(0, 10)),
702
- h(
703
- 'div',
704
- { className: 'loci-main' },
705
- h('div', { className: 'loci-name' }, item.title),
706
- h(
707
- 'div',
708
- { className: 'loci-meta' },
709
- h('span', null, t(`kind.${item.kind}`)),
710
- h('span', null, item.scope),
711
- h('span', null, fmtDate(item.validFrom)),
712
- item.pinned ? h('span', null, '★') : null,
713
- item.total !== undefined ? h('span', null, `${t('panel.total')}: ${item.total}`) : null,
714
- ),
715
- item.snippet ? h('div', { className: 'loci-snippet' }, item.snippet) : null,
716
- ),
717
- h(
718
- 'div',
719
- { className: 'loci-actions' },
720
- h(StatusPill, { status: item.status, t }),
721
- h('button', { type: 'button', className: 'loci-btn', onClick: () => onOpen(item.id) }, '→'),
722
- ),
723
- );
724
- }
725
-
726
- function Detail({ id, t, onBack, onChange }) {
727
- const { data, error, loading, reload } = useJson(`/item?id=${encodeURIComponent(id)}&history=1`);
728
- const entry = data?.entry;
729
- const act = async (path, body) => {
730
- try {
731
- await apiFetch(path, { method: 'POST', body: JSON.stringify(body) });
732
- reload();
733
- onChange?.();
734
- } catch (failure) {
735
- window.console?.warn?.('loci: action failed', failure);
736
- }
737
- };
738
- return h(
739
- 'div',
740
- null,
741
- h(
742
- 'div',
743
- { className: 'loci-detail-head' },
744
- h('button', { type: 'button', className: 'loci-btn', onClick: onBack }, `← ${t('panel.back')}`),
745
- h('span', { className: 'loci-grow' }),
746
- entry
747
- ? h(
748
- 'div',
749
- { className: 'loci-actions' },
750
- entry.status !== 'verified'
751
- ? h('button', { type: 'button', className: 'loci-btn is-primary', onClick: () => act('/verify', { id, status: 'verified' }) }, t('panel.verify'))
752
- : null,
753
- entry.status !== 'disputed'
754
- ? h('button', { type: 'button', className: 'loci-btn', onClick: () => act('/verify', { id, status: 'disputed' }) }, t('panel.dispute'))
755
- : null,
756
- h(
757
- 'button',
758
- { type: 'button', className: 'loci-btn', onClick: () => act('/verify', { id, status: entry.pinned ? 'unpin' : 'pin' }) },
759
- entry.pinned ? t('panel.unpin') : t('panel.pin'),
760
- ),
761
- entry.status === 'deleted'
762
- ? h('button', { type: 'button', className: 'loci-btn', onClick: () => act('/delete', { id, mode: 'restore' }) }, t('panel.restore'))
763
- : h(
764
- 'button',
765
- {
766
- type: 'button',
767
- className: 'loci-btn is-danger',
768
- onClick: () => {
769
- if (window.confirm?.(t('panel.confirmDelete')) === false) return;
770
- act('/delete', { id, mode: 'soft' });
771
- },
772
- },
773
- t('panel.delete'),
774
- ),
775
- )
776
- : null,
777
- ),
778
- h(ErrorLine, { error, t }),
779
- loading && entry === undefined ? h('div', { className: 'loci-muted' }, t('panel.loading')) : null,
780
- entry
781
- ? h(
782
- 'div',
783
- null,
784
- h('div', { className: 'loci-name', style: { fontSize: '15px', fontWeight: 600 } }, entry.title),
785
- h(
786
- 'div',
787
- { className: 'loci-meta' },
788
- h('span', { className: 'loci-id' }, `${entry.id}@${entry.revision}`),
789
- h(StatusPill, { status: entry.status, t }),
790
- h('span', null, entry.kind),
791
- h('span', null, `${entry.scope}:${entry.scopeLabel}`),
792
- h('span', null, `${fmtDate(entry.validFrom)} → ${entry.validTo === null ? 'now' : fmtDate(entry.validTo)}`),
793
- ),
794
- h('div', { className: 'loci-section' }, 'Body'),
795
- h('pre', { className: 'loci-pre' }, entry.body),
796
- entry.sourceRef
797
- ? h(
798
- 'div',
799
- null,
800
- h('div', { className: 'loci-section' }, t('panel.source')),
801
- h(
802
- 'div',
803
- { className: 'loci-meta' },
804
- h('code', { className: 'loci-id' }, entry.sourceRef),
805
- ),
806
- )
807
- : null,
808
- entry.evidence?.quote
809
- ? h(
810
- 'div',
811
- null,
812
- h('div', { className: 'loci-section' }, t('panel.evidence')),
813
- h('div', { className: 'loci-evidence' }, entry.evidence.quote),
814
- )
815
- : null,
816
- h('div', { className: 'loci-section' }, t('panel.history')),
817
- h(
818
- 'div',
819
- { className: 'loci-kv' },
820
- (data.history ?? []).map((revision) =>
821
- h(
822
- 'div',
823
- { key: revision.revision, style: { display: 'contents' } },
824
- h('dt', null, `r${revision.revision}`),
825
- h(
826
- 'dd',
827
- null,
828
- `${revision.status} · ${fmtDate(revision.validFrom)} → ${revision.validTo === null ? 'now' : fmtDate(revision.validTo)}${revision.supersededBy ? ` → ${revision.supersededBy}` : ''}`,
829
- ),
830
- ),
831
- ),
832
- ),
833
- )
834
- : null,
835
- );
836
- }
837
-
838
- function IndexTab({ t }) {
839
- const { data, error, loading, reload } = useJson('/preview');
840
- return h(
841
- 'div',
842
- null,
843
- h('div', { className: 'loci-muted', style: { marginBottom: '8px' } }, t('panel.indexHint')),
844
- h(
845
- 'div',
846
- { className: 'loci-actions', style: { marginBottom: '10px' } },
847
- h('button', { type: 'button', className: 'loci-btn', onClick: reload }, t('panel.refresh')),
848
- data
849
- ? h(
850
- 'span',
851
- { className: 'loci-meta' },
852
- h('span', null, `${fmtBytes(data.bytes)} ${t('panel.bytes')}`),
853
- h('span', null, `${t('panel.listed')}: ${data.listed}`),
854
- h('span', null, `${t('panel.hidden')}: ${data.hidden}`),
855
- )
856
- : null,
857
- ),
858
- h(ErrorLine, { error, t }),
859
- loading && data === undefined ? h('div', { className: 'loci-muted' }, t('panel.loading')) : null,
860
- data ? h('pre', { className: 'loci-pre' }, data.text.length === 0 ? t('panel.empty') : data.text) : null,
861
- );
862
- }
863
-
864
- function SearchTab({ t, onOpen }) {
865
- const [draft, setDraft] = useState('');
866
- const [query, setQuery] = useState('');
867
- const [pages, setPages] = useState([]);
868
- const [error, setError] = useState(undefined);
869
- const [loading, setLoading] = useState(false);
870
- const request = async (cursor) => {
871
- setLoading(true);
872
- setError(undefined);
873
- try {
874
- const params = new URLSearchParams({ q: query, limit: '20', status: 'any' });
875
- if (cursor) params.set('cursor', cursor);
876
- const data = await apiFetch(`/list?${params.toString()}`);
877
- setPages((previous) => (cursor ? [...previous, data] : [data]));
878
- } catch (failure) {
879
- setError(String(failure?.message ?? failure));
880
- } finally {
881
- setLoading(false);
882
- }
883
- };
884
- useEffect(() => {
885
- if (query.length === 0) return;
886
- request(undefined);
887
- // eslint-disable-next-line react-hooks/exhaustive-deps
888
- }, [query]);
889
- const hits = pages.flatMap((page) => page.hits ?? []);
890
- const last = pages.at(-1);
891
- return h(
892
- 'div',
893
- null,
894
- h(
895
- 'form',
896
- {
897
- onSubmit: (event) => {
898
- event.preventDefault();
899
- setQuery(draft.trim());
900
- },
901
- style: { marginBottom: '10px' },
902
- },
903
- h('input', {
904
- className: 'loci-input',
905
- value: draft,
906
- placeholder: t('panel.searchPlaceholder'),
907
- onChange: (event) => setDraft(event.target.value),
908
- }),
909
- ),
910
- h(ErrorLine, { error, t }),
911
- last ? h('div', { className: 'loci-meta' }, `${t('panel.total')}: ${last.total}`) : null,
912
- hits.length === 0 && last !== undefined && !loading ? h('div', { className: 'loci-muted' }, t('panel.searchEmpty')) : null,
913
- hits.map((hit) => h(Row, { key: hit.id, t, item: hit, onOpen })),
914
- last?.hasMore
915
- ? h('button', { type: 'button', className: 'loci-btn', disabled: loading, onClick: () => request(last.cursor) }, t('panel.more'))
916
- : null,
917
- );
918
- }
919
-
920
- function ReviewTab({ t, onOpen, onChanged }) {
921
- // Only the kinds that could actually reach the prompt are reviewable. An
922
- // episodic session summary is history: it is never injected, so parking it
923
- // here forever is how the queue became a wall nobody reads.
924
- const reviewable = 'kinds=rule,semantic,procedural';
925
- const candidates = useJson(`/list?status=candidate&limit=50&${reviewable}`);
926
- const disputed = useJson(`/list?status=disputed&limit=50&${reviewable}`);
927
- const [busy, setBusy] = useState(undefined);
928
- const [failed, setFailed] = useState(undefined);
929
- const act = async (path, body, id) => {
930
- setBusy(id);
931
- setFailed(undefined);
932
- try {
933
- await apiFetch(path, { method: 'POST', body: JSON.stringify(body) });
934
- candidates.reload();
935
- disputed.reload();
936
- onChanged?.();
937
- } catch (failure) {
938
- // A swallowed click is worse than a visible error: the user cannot tell
939
- // "it worked" from "nothing happened".
940
- setFailed(`${body.id ?? 'bulk'}: ${String(failure?.message ?? failure)}`);
941
- } finally {
942
- setBusy(undefined);
943
- }
944
- };
945
- const verifyMany = async (items) => {
946
- setBusy('bulk');
947
- setFailed(undefined);
948
- try {
949
- for (const item of items) {
950
- await apiFetch('/verify', { method: 'POST', body: JSON.stringify({ id: item.id, status: 'verified' }) });
951
- }
952
- candidates.reload();
953
- disputed.reload();
954
- onChanged?.();
955
- } catch (failure) {
956
- setFailed(String(failure?.message ?? failure));
957
- } finally {
958
- setBusy(undefined);
959
- }
960
- };
961
- const row = (item, actions) =>
962
- h(
963
- 'div',
964
- { key: item.id, className: 'loci-row' },
965
- h('span', { className: 'loci-id' }, item.id.slice(0, 10)),
966
- h(
967
- 'div',
968
- { className: 'loci-main' },
969
- h('div', { className: 'loci-name' }, item.title),
970
- item.snippet ? h('div', { className: 'loci-snippet' }, item.snippet) : null,
971
- h('div', { className: 'loci-meta' }, h('span', null, t(`kind.${item.kind}`)), item.scope ? h('span', null, item.scope) : null),
972
- ),
973
- h('div', { className: 'loci-actions' }, actions, h('button', { type: 'button', className: 'loci-btn is-quiet', onClick: () => onOpen(item.id) }, '→')),
974
- );
975
- const block = (title, state, actions) =>
976
- h(
977
- 'div',
978
- null,
979
- h(
980
- 'div',
981
- { className: 'loci-meta', style: { margin: '18px 0 8px', alignItems: 'center' } },
982
- h('div', { className: 'loci-section', style: { margin: 0 } }, `${title} (${state.data?.total ?? 0})`),
983
- h('span', { className: 'loci-grow' }),
984
- state.data?.items?.length
985
- ? h(
986
- 'button',
987
- {
988
- type: 'button',
989
- className: 'loci-btn',
990
- disabled: busy !== undefined,
991
- onClick: () => verifyMany(state.data.items),
992
- },
993
- t('panel.verifyAll').replace('{n}', String(state.data.items.length)),
994
- )
995
- : null,
996
- ),
997
- state.data?.items?.length
998
- ? state.data.items.map((item) => row(item, actions(item)))
999
- : h('div', { className: 'loci-muted' }, t('panel.noCandidates')),
1000
- );
1001
- const candidateActions = (item) => [
1002
- h(
1003
- 'button',
1004
- { key: 'v', type: 'button', className: 'loci-btn is-primary', disabled: busy !== undefined, onClick: () => act('/verify', { id: item.id, status: 'verified' }, item.id) },
1005
- t('panel.verify'),
1006
- ),
1007
- h(
1008
- 'button',
1009
- {
1010
- key: 'r',
1011
- type: 'button',
1012
- className: 'loci-btn is-danger',
1013
- disabled: busy !== undefined,
1014
- onClick: () => act('/delete', { id: item.id, mode: 'soft', reason: 'rejected in review' }, item.id),
1015
- },
1016
- t('panel.reject'),
1017
- ),
1018
- ];
1019
- const disputedActions = (item) => [
1020
- h(
1021
- 'button',
1022
- { key: 'v', type: 'button', className: 'loci-btn is-primary', disabled: busy !== undefined, onClick: () => act('/verify', { id: item.id, status: 'verified' }, item.id) },
1023
- t('panel.verify'),
1024
- ),
1025
- ];
1026
- return h(
1027
- 'div',
1028
- null,
1029
- h(ErrorLine, { error: candidates.error ?? disputed.error, t }),
1030
- failed !== undefined ? h('div', { className: 'loci-error' }, `${t('panel.actionFailed')}: ${failed}`) : null,
1031
- h('div', { className: 'loci-muted' }, t('panel.reviewHint')),
1032
- block(t('panel.candidates'), candidates, candidateActions),
1033
- block(t('panel.disputed'), disputed, disputedActions),
1034
- );
1035
- }
1036
-
1037
- function StatsTab({ t }) {
1038
- const { data, error, loading, reload } = useJson('/stats', { intervalMs: 15_000 });
1039
- if (loading && data === undefined) return h('div', { className: 'loci-muted' }, t('panel.loading'));
1040
- const cell = (label, value) => h('div', { className: 'loci-cell' }, h('span', { className: 'loci-label' }, label), h('b', null, value));
1041
- const vs = data?.vector ?? {};
1042
- return h(
1043
- 'div',
1044
- null,
1045
- h(ErrorLine, { error, t }),
1046
- data
1047
- ? h(
1048
- 'div',
1049
- null,
1050
- h(
1051
- 'div',
1052
- { className: 'loci-grid' },
1053
- cell('entries', data.heads ?? 0),
1054
- cell('revisions', data.revisions ?? 0),
1055
- cell(t('panel.candidates'), data.pendingCandidates ?? 0),
1056
- cell('disputed', data.disputed ?? 0),
1057
- cell('db', fmtBytes(data.dbBytes)),
1058
- cell('index rev', data.indexRev ?? 0),
1059
- ),
1060
- h('div', { className: 'loci-section' }, t('panel.store')),
1061
- h(
1062
- 'dl',
1063
- { className: 'loci-kv' },
1064
- h('dt', null, 'path'), h('dd', null, data.memoryRoot ?? '—'),
1065
- h('dt', null, 'schema'), h('dd', null, `v${data.schemaVersion}`),
1066
- h('dt', null, 'vector'), h('dd', null, `${vs.mode ?? 'off'}${vs.healthy ? ' · healthy' : ''}${vs.lastError ? ` · ${vs.lastError}` : ''}`),
1067
- h('dt', null, 'last injection'),
1068
- h('dd', null, data.lastInjection ? `${data.lastInjection.bytes} B · ${data.lastInjection.listed} listed · ${data.lastInjection.hidden} hidden` : '—'),
1069
- ),
1070
- h('div', { className: 'loci-section' }, t('panel.injections')),
1071
- (data.injections ?? []).slice(0, 20).map((row, index) =>
1072
- h(
1073
- 'div',
1074
- { key: `${row.ts}-${index}`, className: 'loci-row' },
1075
- h('span', { className: 'loci-id' }, new Date(row.ts).toLocaleTimeString()),
1076
- h('div', { className: 'loci-main' }, h('div', { className: 'loci-name' }, `${row.sectionBytes} ${t('panel.bytes')}`), h('div', { className: 'loci-meta' }, `${row.listed} listed · ${row.hidden} hidden`)),
1077
- ),
1078
- ),
1079
- h('div', { className: 'loci-meta', style: { marginTop: '14px' } }, `${t('panel.version')} v${data.pluginVersion ?? '—'}`),
1080
- )
1081
- : null,
1082
- );
1083
- }
1084
-
1085
- function MemoryPanel({ t }) {
1086
- const [tab, setTab] = useState('feed');
1087
- const [selected, setSelected] = useState(undefined);
1088
- const [nonce, setNonce] = useState(0);
1089
- const tabs = useMemo(
1090
- () => [
1091
- { id: 'feed', label: t('panel.feed') },
1092
- { id: 'index', label: t('panel.index') },
1093
- { id: 'search', label: t('panel.search') },
1094
- { id: 'review', label: t('panel.review') },
1095
- { id: 'stats', label: t('panel.stats') },
1096
- ],
1097
- [t],
1098
- );
1099
- const changed = useCallback(() => setNonce((value) => value + 1), []);
1100
- return h(
1101
- 'div',
1102
- { className: 'loci-root' },
1103
- h('style', null, CSS),
1104
- h(
1105
- 'div',
1106
- { className: 'loci-bar' },
1107
- h('span', { className: 'loci-title' }, t('panel.title')),
1108
- h('span', { className: 'loci-grow' }),
1109
- selected === undefined ? h(Tabs, { value: tab, onChange: setTab, tabs }) : null,
1110
- ),
1111
- h(
1112
- 'div',
1113
- { className: 'loci-body' },
1114
- selected !== undefined
1115
- ? h(Detail, { id: selected, t, onBack: () => setSelected(undefined), onChange: changed })
1116
- : tab === 'feed'
1117
- ? h(FeedTab, { t, onOpen: setSelected, key: `feed-${nonce}` })
1118
- : tab === 'index'
1119
- ? h(IndexTab, { t, key: `index-${nonce}` })
1120
- : tab === 'search'
1121
- ? h(SearchTab, { t, onOpen: setSelected })
1122
- : tab === 'review'
1123
- ? h(ReviewTab, { t, onOpen: setSelected, onChanged: changed })
1124
- : h(StatsTab, { t }),
1125
- ),
1126
- );
1127
- }
1128
-
1129
- function PanelIcon({ size, active }) {
1130
- return h(
1131
- 'svg',
1132
- {
1133
- width: size,
1134
- height: size,
1135
- viewBox: '0 0 24 24',
1136
- fill: 'none',
1137
- 'aria-hidden': true,
1138
- style: { opacity: active ? 1 : 0.75, display: 'block' },
1139
- },
1140
- h('path', { d: 'M4 6.5h16M4 12h16M4 17.5h10', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }),
1141
- h('circle', { cx: 18.5, cy: 17.5, r: 2.2, stroke: 'currentColor', strokeWidth: 1.5 }),
1142
- );
1143
- }
1144
-
1145
- function SettingsIcon({ size }) {
1146
- return h(
1147
- 'svg',
1148
- { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
1149
- h('path', { d: 'M5 7h14M5 12h14M5 17h9', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }),
1150
- );
1151
- }
1152
-
1153
- // --------------------------------------------------------------- settings
1154
-
1155
- function Field({ label, hint, children }) {
1156
- return h(
1157
- 'div',
1158
- { className: 'loci-field' },
1159
- h('div', null, h('div', null, label), hint ? h('div', { className: 'loci-muted' }, hint) : null),
1160
- h('div', null, children),
1161
- );
1162
- }
1163
-
1164
- function SettingsSection(props) {
1165
- const t = props?.t ?? ((key) => key);
1166
- const ctx = props?.ctx;
1167
- const effective = useJson('/config');
1168
- const [status, setStatus] = useState(undefined);
1169
- const [forgetPhrase, setForgetPhrase] = useState('');
1170
- const [forgetBusy, setForgetBusy] = useState(false);
1171
- const [forgetResult, setForgetResult] = useState(undefined);
1172
- const forms = ctx?.get?.('configForms');
1173
- const form = forms?.get?.(ENTRY_ID);
1174
- const [snapshot, setSnapshot] = useState(() => form?.getSnapshot?.() ?? { status: 'unavailable', value: undefined, writable: false });
1175
- useEffect(() => {
1176
- if (!form?.subscribe) return undefined;
1177
- setSnapshot(form.getSnapshot());
1178
- return form.subscribe(() => setSnapshot(form.getSnapshot()));
1179
- }, [form]);
1180
- const values = { ...(effective.data?.config ?? {}), ...(snapshot?.value ?? {}) };
1181
- const write = async (field, value) => {
1182
- if (!form?.set) return;
1183
- setStatus('saving');
1184
- try {
1185
- const ok = await form.set(field, value);
1186
- setStatus(ok ? 'saved' : 'rejected');
1187
- } catch {
1188
- setStatus('failed');
1189
- }
1190
- setTimeout(() => setStatus(undefined), 2500);
1191
- };
1192
- const select = (field, options, current) =>
1193
- h(
1194
- 'select',
1195
- { value: String(current), disabled: !form?.set, onChange: (event) => write(field, event.target.value) },
1196
- options.map((option) => h('option', { key: option.value, value: option.value }, option.label)),
1197
- );
1198
- return h(
1199
- 'div',
1200
- { className: 'loci-root', style: { height: 'auto' } },
1201
- h('style', null, CSS),
1202
- h('div', { className: 'loci-muted', style: { marginBottom: '8px' } }, t('settings.intro')),
1203
- form?.set ? null : h('div', { className: 'loci-muted' }, t('settings.unavailable')),
1204
- h(ErrorLine, { error: effective.error, t }),
1205
- h(
1206
- 'div',
1207
- null,
1208
- h(Field, {
1209
- label: t('settings.budget'),
1210
- hint: t('settings.budgetHint'),
1211
- children: h('input', {
1212
- type: 'number',
1213
- min: 1024,
1214
- max: 16384,
1215
- step: 256,
1216
- value: values.sectionMaxBytes ?? 3584,
1217
- disabled: !form?.set,
1218
- onChange: (event) => write('sectionMaxBytes', Number(event.target.value)),
1219
- }),
1220
- }),
1221
- h(Field, {
1222
- label: t('settings.scope'),
1223
- children: select('defaultScope', [
1224
- { value: 'workspace', label: 'workspace' },
1225
- { value: 'project', label: 'project' },
1226
- { value: 'global', label: 'global' },
1227
- ], values.defaultScope ?? 'workspace'),
1228
- }),
1229
- h(Field, {
1230
- label: t('settings.capture'),
1231
- children: select('captureMode', [
1232
- { value: 'rules', label: t('captureRules') },
1233
- { value: 'off', label: t('off') },
1234
- ], values.captureMode ?? 'rules'),
1235
- }),
1236
- h(Field, {
1237
- label: t('settings.assist'),
1238
- children: select('recallAssist', [
1239
- { value: 'off', label: t('off') },
1240
- { value: 'session-start', label: 'session-start' },
1241
- ], values.recallAssist ?? 'off'),
1242
- }),
1243
- h(Field, {
1244
- label: t('settings.maintenance'),
1245
- children: select('maintenanceMode', [
1246
- { value: 'sweep', label: 'every 6h' },
1247
- { value: 'off', label: t('off') },
1248
- ], values.maintenanceMode ?? 'sweep'),
1249
- }),
1250
- h(Field, {
1251
- label: t('settings.secret'),
1252
- children: select('secretPolicy', [
1253
- { value: 'redact', label: t('secretRedact') },
1254
- { value: 'reject', label: t('secretReject') },
1255
- ], values.secretPolicy ?? 'redact'),
1256
- }),
1257
- h(Field, {
1258
- label: t('settings.vector'),
1259
- children: select('vectorMode', [
1260
- { value: 'off', label: t('vectorOff') },
1261
- { value: 'http', label: t('vectorHttp') },
1262
- ], values.vectorMode ?? 'off'),
1263
- }),
1264
- ),
1265
- h('div', { className: 'loci-section' }, t('settings.danger')),
1266
- h('div', { className: 'loci-muted' }, t('settings.dangerHint')),
1267
- h(
1268
- 'div',
1269
- { className: 'loci-row', style: { borderBottom: 0 } },
1270
- h('input', {
1271
- className: 'loci-input',
1272
- value: forgetPhrase,
1273
- placeholder: t('settings.dangerPhrase'),
1274
- onChange: (event) => setForgetPhrase(event.target.value),
1275
- style: { maxWidth: '260px' },
1276
- }),
1277
- h(
1278
- 'button',
1279
- {
1280
- type: 'button',
1281
- className: 'loci-btn is-danger',
1282
- disabled: forgetPhrase.trim().length === 0 || forgetBusy,
1283
- onClick: async () => {
1284
- setForgetBusy(true);
1285
- setForgetResult(undefined);
1286
- try {
1287
- const answer = await apiFetch('/forget-all', {
1288
- method: 'POST',
1289
- body: JSON.stringify({ confirm: forgetPhrase.trim(), mode: 'soft' }),
1290
- });
1291
- setForgetResult(t('settings.dangerDone').replace('{n}', String(answer.count ?? 0)));
1292
- setForgetPhrase('');
1293
- } catch (failure) {
1294
- setForgetResult(`${t('panel.actionFailed')}: ${String(failure?.message ?? failure)}`);
1295
- } finally {
1296
- setForgetBusy(false);
1297
- }
1298
- },
1299
- },
1300
- forgetBusy ? t('settings.dangerBusy') : t('settings.dangerRun'),
1301
- ),
1302
- ),
1303
- forgetResult !== undefined ? h('div', { className: 'loci-muted' }, forgetResult) : null,
1304
- h('div', { className: 'loci-section' }, t('settings.diagnostics')),
1305
- h(
1306
- 'dl',
1307
- { className: 'loci-kv' },
1308
- h('dt', null, 'store'), h('dd', null, effective.data?.config?.home ? String(effective.data.config.home) : 'DSH_HOME/memory/loci'),
1309
- h('dt', null, 'schema'), h('dd', null, 'v1'),
1310
- h('dt', null, 'status'), h('dd', null, status ?? '—'),
1311
- ),
1312
- );
1313
- }
1314
-
1315
- function apply(ctx) {
1316
- ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-loci: locale dictionaries');
1317
- const bind = ctx.locale.bind(NS);
1318
- const t = tOf(bind);
1319
-
1320
- // The "副屏": a card beside Settings, always visible while the sidebar is.
1321
- ctx.slots.inject('sidebar.footer.action', () =>
1322
- ctx.slots.register(
1323
- {
1324
- name: 'sidebar.footer.action',
1325
- id: 'loci-card',
1326
- order: 10,
1327
- label: () => t('card.title'),
1328
- locale: NS,
1329
- inject: () => ({ t, openPanel: ctx.layout ? () => ctx.layout.selectPanel(PANEL_ID) : undefined }),
1330
- },
1331
- MemoryCard,
1332
- ),
1333
- );
1334
- ctx.slots.inject('settings.section', () =>
1335
- ctx.slots.register(
1336
- {
1337
- name: 'settings.section',
1338
- id: NS,
1339
- order: 25,
1340
- label: () => t('settings.title'),
1341
- locale: NS,
1342
- inject: () => ({ t, ctx }),
1343
- },
1344
- SettingsSection,
1345
- ),
1346
- );
1347
- ctx.slots.inject('settings.section.icon', () =>
1348
- ctx.slots.register({ name: 'settings.section.icon', id: NS, locale: NS }, SettingsIcon),
1349
- );
1350
- ctx.slots.inject('sidebar.panellist', () =>
1351
- ctx.slots.register(
1352
- {
1353
- name: 'sidebar.panellist',
1354
- id: PANEL_ID,
1355
- order: 20,
1356
- label: () => t('panel.title'),
1357
- locale: NS,
1358
- },
1359
- PanelIcon,
1360
- ),
1361
- );
1362
- ctx.slots.inject('main', () =>
1363
- ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS, inject: () => ({ t }) }, MemoryPanel),
1364
- );
1365
- }
1366
-
1367
- return { inject: ['slots', 'locale', 'layout'], apply };
1368
- },
1369
- });
249
+ 'panel.source': '出处',
250
+ 'panel.candidates': '候选',
251
+ 'panel.disputed': '争议',
252
+ 'panel.noCandidates': '没有待审条目。',
253
+ 'panel.reviewHint': '这里只列可能被注入的条目;过去会话的摘要属于历史,不是主张。',
254
+ 'panel.reject': '驳回',
255
+ 'panel.verifyAll': '全部核实({n})',
256
+ 'panel.actionFailed': '操作失败',
257
+ 'panel.injections': '最近注入',
258
+ 'panel.store': '存储',
259
+ 'panel.confirmDelete': '软删除这条记忆?',
260
+ 'panel.error': '请求失败',
261
+ 'panel.updated': '更新于',
262
+ 'panel.version': '版本',
263
+ };
264
+
265
+ const CSS = `
266
+ .loci-root { display: flex; flex-direction: column; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); font-size: 13px; }
267
+ .loci-card-root { display: flex; flex-direction: column; gap: 6px; width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 8px; cursor: pointer; text-align: left; font: inherit; color: inherit; }
268
+ .loci-card-root:hover { background: var(--dsw-alias-bg-layer-2); }
269
+ .loci-card-root.is-rail { padding: 6px; align-items: center; justify-content: center; }
270
+ .loci-card-head { display: flex; align-items: center; gap: 6px; width: 100%; }
271
+ .loci-card-title { font-size: 12px; font-weight: 600; letter-spacing: .01em; }
272
+ .loci-card-grow { flex: 1 1 auto; }
273
+ .loci-card-ver { font-size: 10px; color: var(--dsw-alias-label-secondary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
274
+ .loci-card-mark { display: flex; align-items: center; color: var(--dsw-alias-brand-primary); flex: 0 0 auto; }
275
+ .loci-card-note { display: flex; flex-direction: column; gap: 5px; }
276
+ .loci-card-stamp { font-size: 10.5px; color: var(--dsw-alias-label-secondary); letter-spacing: .01em; }
277
+ .loci-card-prose { margin: 0; font-size: 12px; line-height: 1.62; color: var(--dsw-alias-label-primary); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
278
+ .loci-card-hint { font-size: 10.5px; color: var(--dsw-alias-label-secondary); margin-top: 1px; }
279
+ .loci-card-empty { font-size: 11px; color: var(--dsw-alias-label-secondary); }
280
+ .loci-card-line { display: flex; gap: 6px; align-items: baseline; min-width: 0; }
281
+ .loci-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-brand-primary); flex: 0 0 auto; }
282
+
283
+ .loci-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
284
+ .loci-title { font-size: 14px; font-weight: 600; }
285
+ .loci-tabs { display: flex; gap: 2px; }
286
+ .loci-tab { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); padding: 5px 10px; border-radius: 6px; cursor: pointer; font: inherit; }
287
+ .loci-tab:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
288
+ .loci-tab.is-active { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
289
+ .loci-grow { flex: 1 1 auto; }
290
+ .loci-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 12px 16px 24px; }
291
+ .loci-btn { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 3px 9px; font: inherit; cursor: pointer; }
292
+ .loci-btn:hover { border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); }
293
+ .loci-btn:disabled { opacity: .5; cursor: default; }
294
+ .loci-btn.is-primary { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
295
+ .loci-btn.is-danger { color: var(--dsw-alias-state-error-primary); }
296
+ .loci-btn.is-quiet { border-color: transparent; background: transparent; color: var(--dsw-alias-label-secondary); padding: 1px 6px; font-size: 11px; }
297
+ .loci-btn.is-quiet:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
298
+ .loci-input { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 6px 9px; font: inherit; }
299
+ .loci-row { display: flex; gap: 10px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
300
+ .loci-row:last-child { border-bottom: 0; }
301
+ .loci-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-label-secondary); flex: 0 0 auto; }
302
+ .loci-main { min-width: 0; flex: 1 1 auto; }
303
+ .loci-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
304
+ .loci-meta { font-size: 11px; color: var(--dsw-alias-label-secondary); display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
305
+ .loci-snippet { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 3px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
306
+ .loci-actions { display: flex; gap: 6px; flex: 0 0 auto; align-items: center; }
307
+ .loci-pill { font-size: 11px; border-radius: 999px; padding: 1px 7px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); }
308
+ .loci-pill.is-verified { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }
309
+ .loci-pill.is-candidate { color: var(--dsw-alias-state-warn-primary); border-color: var(--dsw-alias-state-warn-primary); }
310
+ .loci-pill.is-disputed { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); }
311
+ .loci-pill.is-deleted { color: var(--dsw-alias-state-idle-primary); }
312
+ .loci-tag { font-size: 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 5px; color: var(--dsw-alias-label-secondary); }
313
+ .loci-tag.is-added { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }
314
+ .loci-tag.is-revised { color: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
315
+ .loci-tag.is-verified { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }
316
+ .loci-tag.is-deleted, .loci-tag.is-purged { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); }
317
+ .loci-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
318
+ .loci-cell { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); border-radius: 8px; padding: 10px 12px; }
319
+ .loci-cell b { display: block; font-size: 18px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
320
+ .loci-label { font-size: 11px; color: var(--dsw-alias-label-secondary); }
321
+ .loci-pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px 12px; margin: 0; }
322
+ .loci-section { margin: 18px 0 8px; font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); text-transform: uppercase; letter-spacing: .06em; }
323
+ .loci-field { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
324
+ .loci-field:last-child { border-bottom: 0; }
325
+ .loci-field select, .loci-field input[type="number"] { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); border-radius: 6px; padding: 4px 8px; font: inherit; }
326
+ .loci-error { color: var(--dsw-alias-state-error-primary); font-size: 12px; margin: 6px 0; }
327
+ .loci-muted { color: var(--dsw-alias-label-secondary); font-size: 12px; }
328
+ .loci-kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 12px; }
329
+ .loci-kv dt { color: var(--dsw-alias-label-secondary); }
330
+ .loci-kv dd { margin: 0; }
331
+ .loci-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
332
+ .loci-evidence { border-left: 2px solid var(--dsw-alias-brand-primary); padding-left: 10px; color: var(--dsw-alias-label-secondary); font-size: 12px; white-space: pre-wrap; }
333
+ `;
334
+
335
+ /** Locale binding that never throws on a missing key. */
336
+ function tOf(bind) {
337
+ return (key) => {
338
+ try {
339
+ const value = bind(key);
340
+ return typeof value === 'string' && value.length > 0 ? value : key;
341
+ } catch {
342
+ return key;
343
+ }
344
+ };
345
+ }
346
+
347
+ /** Fetch JSON with the session cookie; throws a readable error. */
348
+ async function apiFetch(path, options = {}) {
349
+ const response = await fetch(`${API}${path}`, {
350
+ credentials: 'same-origin',
351
+ headers: options.body === undefined ? {} : { 'content-type': 'application/json' },
352
+ ...options,
353
+ });
354
+ const text = await response.text();
355
+ let payload;
356
+ try {
357
+ payload = text.length === 0 ? {} : JSON.parse(text);
358
+ } catch {
359
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 160)}`);
360
+ }
361
+ if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`);
362
+ return payload;
363
+ }
364
+
365
+ /** Subscribe to one GET endpoint with an optional poll interval. */
366
+ function useJson(path, options = {}) {
367
+ const { intervalMs, enabled = true } = options;
368
+ const [state, setState] = useState({ loading: true, data: undefined, error: undefined });
369
+ const [nonce, setNonce] = useState(0);
370
+ const reload = useCallback(() => setNonce((value) => value + 1), []);
371
+ useEffect(() => {
372
+ if (path === undefined || enabled !== true) return undefined;
373
+ const controller = new AbortController();
374
+ setState((previous) => ({ ...previous, loading: true }));
375
+ apiFetch(path, { signal: controller.signal })
376
+ .then((data) => setState({ loading: false, data, error: undefined }))
377
+ .catch((error) => {
378
+ if (controller.signal.aborted) return;
379
+ setState({ loading: false, data: undefined, error: String(error?.message ?? error) });
380
+ });
381
+ return () => controller.abort();
382
+ }, [path, nonce, enabled]);
383
+ useEffect(() => {
384
+ if (intervalMs === undefined || enabled !== true) return undefined;
385
+ const timer = setInterval(reload, intervalMs);
386
+ return () => clearInterval(timer);
387
+ }, [intervalMs, reload, enabled]);
388
+ return { ...state, reload };
389
+ }
390
+
391
+ const fmtDate = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : '—');
392
+ const fmtBytes = (n) => `${Math.round(Number(n) || 0)} B`;
393
+
394
+ /**
395
+ * Turn the few things currently understood into one paragraph.
396
+ *
397
+ * The card is a page of notes, so this reads as prose rather than a list: one
398
+ * clause per entry, joined with 。or , and cut when it stops fitting. Consensus
399
+ * items lead with 已定 and a fact leads with its own subject, because "we
400
+ * agreed" and "it happens to be true" are different kinds of knowing.
401
+ */
402
+ function proseOf(notes, t) {
403
+ const clauses = [];
404
+ for (const note of notes) {
405
+ const title = String(note.title ?? '').trim().replace(/[。.]$/, '');
406
+ if (title.length === 0) continue;
407
+ const gist = typeof note.gist === 'string' && note.gist !== title ? note.gist : null;
408
+ const body = gist === null ? title : `${title},${gist}`;
409
+ if (note.kind === 'rule') clauses.push(`${t('prose.agreed')}${body}`);
410
+ else if (note.kind === 'procedural') clauses.push(`${t('prose.lesson')}${body}`);
411
+ else clauses.push(body);
412
+ // The card is narrow; three clauses already fill it. A fourth only shows
413
+ // up when the first ones are unusually short.
414
+ if (clauses.join(';').length > 90) break;
415
+ }
416
+ return `${clauses.join(';')}。`;
417
+ }
418
+
419
+ /** `2026-11-13 21:11`, matching the note style of the other sidebars. */
420
+ function stampOf(ms) {
421
+ if (!Number.isFinite(ms)) return '';
422
+ const date = new Date(ms);
423
+ const pad = (value) => String(value).padStart(2, '0');
424
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
425
+ }
426
+
427
+
428
+ /**
429
+ * How long ago something was understood, in words rather than numbers.
430
+ *
431
+ * "昨天" and "刚才" are the whole point of the card: they are what makes a
432
+ * reader believe the memory is alive. A bare timestamp makes them do the
433
+ * arithmetic instead.
434
+ */
435
+ function fmtSince(ms, t, now = Date.now()) {
436
+ if (!Number.isFinite(ms)) return '';
437
+ const delta = Math.max(0, now - ms);
438
+ const minute = 60_000;
439
+ if (delta < minute) return t('since.now');
440
+ if (delta < 60 * minute) return t('since.minutes').replace('{n}', String(Math.floor(delta / minute)));
441
+ if (delta < 24 * 60 * minute) return t('since.hours').replace('{n}', String(Math.floor(delta / (60 * minute))));
442
+ if (delta < 48 * 60 * minute) return t('since.yesterday');
443
+ if (delta < 30 * 24 * 60 * minute) {
444
+ return t('since.days').replace('{n}', String(Math.floor(delta / (24 * 60 * minute))));
445
+ }
446
+ return new Date(ms).toISOString().slice(0, 10);
447
+ }
448
+
449
+ const ACTION_KEY = (action) => `action.${action}`;
450
+
451
+ function StatusPill({ status, t }) {
452
+ return h('span', { className: `loci-pill is-${status}` }, status);
453
+ }
454
+
455
+ function ErrorLine({ error, t }) {
456
+ if (error === undefined) return null;
457
+ return h('div', { className: 'loci-error' }, `${t('panel.error')}: ${error}`);
458
+ }
459
+
460
+ function Tabs({ value, onChange, tabs }) {
461
+ return h(
462
+ 'div',
463
+ { className: 'loci-tabs', role: 'tablist' },
464
+ tabs.map((tab) =>
465
+ h(
466
+ 'button',
467
+ {
468
+ key: tab.id,
469
+ type: 'button',
470
+ role: 'tab',
471
+ 'aria-selected': value === tab.id,
472
+ className: `loci-tab${value === tab.id ? ' is-active' : ''}`,
473
+ onClick: () => onChange(tab.id),
474
+ },
475
+ tab.label,
476
+ ),
477
+ ),
478
+ );
479
+ }
480
+
481
+ /** The small version line shared by the card and the panel footer. */
482
+ function VersionLine({ t, update, onCheck, checking }) {
483
+ const version = typeof update?.version === 'string' ? update.version.replace(/^v/i, '') : null;
484
+ const hasUpdate = update?.updateAvailable === true;
485
+ const unknown = update !== undefined && update.status === 'unavailable';
486
+ const text = hasUpdate
487
+ ? `${t('card.updateTo')}${update.latestVersion ? ` · v${update.latestVersion}` : ''}`
488
+ : unknown
489
+ ? t('card.unknown')
490
+ : update?.status === 'ok'
491
+ ? t('card.upToDate')
492
+ : t('card.check');
493
+ return h(
494
+ 'span',
495
+ { className: 'loci-card-line', style: { gap: '5px' } },
496
+ version ? h('span', { className: 'loci-card-ver' }, `v${version}`) : null,
497
+ h(
498
+ 'button',
499
+ {
500
+ type: 'button',
501
+ className: 'loci-btn is-quiet',
502
+ title: text,
503
+ disabled: checking === true,
504
+ onClick: (event) => {
505
+ event.stopPropagation();
506
+ onCheck?.();
507
+ },
508
+ },
509
+ checking === true ? t('card.checking') : text,
510
+ ),
511
+ hasUpdate ? h('span', { className: 'loci-dot' }) : null,
512
+ );
513
+ }
514
+
515
+ // ------------------------------------------------------------ sidebar card
516
+
517
+ function MemoryCard(props) {
518
+ const t = props?.t ?? ((key) => key);
519
+ const wide = props?.wide !== false;
520
+ const open = props?.openPanel;
521
+ // The card asks "what does it know", not "what happened": a page of notes,
522
+ // not a dashboard. No counters — a number tells you something changed, a
523
+ // sentence tells you what was understood.
524
+ const knowledge = useJson('/knowledge?limit=4', { intervalMs: 30_000 });
525
+ const update = useJson('/update');
526
+ const [checking, setChecking] = useState(false);
527
+ const [fresh, setFresh] = useState(undefined);
528
+
529
+ const check = useCallback(async () => {
530
+ setChecking(true);
531
+ try {
532
+ const answer = await apiFetch('/update?refresh=1');
533
+ setFresh(answer);
534
+ } catch {
535
+ setFresh({ version: undefined, status: 'unavailable', updateAvailable: false });
536
+ } finally {
537
+ setChecking(false);
538
+ }
539
+ }, []);
540
+
541
+ const data = knowledge.data;
542
+ const notes = data?.items ?? [];
543
+ const pending = Number(data?.pending ?? 0);
544
+
545
+ if (!wide) {
546
+ return h(
547
+ 'button',
548
+ {
549
+ type: 'button',
550
+ className: 'loci-card-root is-rail',
551
+ title: t('card.open'),
552
+ 'aria-label': t('card.open'),
553
+ onClick: () => open?.(),
554
+ },
555
+ h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
556
+ h('path', { d: 'M4 6.5h16M4 12h16M4 17.5h10', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }),
557
+ h('circle', { cx: 18.5, cy: 17.5, r: 2.2, stroke: 'currentColor', strokeWidth: 1.5 }),
558
+ ),
559
+ );
560
+ }
561
+
562
+ return h(
563
+ 'div',
564
+ {
565
+ className: 'loci-card-root',
566
+ role: 'button',
567
+ tabIndex: 0,
568
+ onClick: () => open?.(),
569
+ onKeyDown: (event) => {
570
+ if (event.key === 'Enter' || event.key === ' ') open?.();
571
+ },
572
+ },
573
+ h(
574
+ 'div',
575
+ { className: 'loci-card-head' },
576
+ h(
577
+ 'span',
578
+ { className: 'loci-card-mark', 'aria-hidden': true },
579
+ h('svg', { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none' },
580
+ h('path', {
581
+ d: 'M20 4c0 9-6.5 15-16 16 0 0 .5-7 4.5-11S20 4 20 4Z',
582
+ stroke: 'currentColor',
583
+ strokeWidth: 1.6,
584
+ strokeLinejoin: 'round',
585
+ }),
586
+ h('path', { d: 'M5 20 14 10', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }),
587
+ ),
588
+ ),
589
+ h('span', { className: 'loci-card-title' }, t('card.title')),
590
+ h('span', { className: 'loci-card-grow' }),
591
+ h(VersionLine, { t, update: fresh ?? update.data, onCheck: check, checking }),
592
+ ),
593
+ data === undefined
594
+ ? h('div', { className: 'loci-card-empty' }, knowledge.error ?? t('panel.loading'))
595
+ : notes.length === 0
596
+ ? h(
597
+ 'div',
598
+ null,
599
+ h('div', { className: 'loci-card-empty' }, t('card.empty')),
600
+ h('div', { className: 'loci-card-hint' }, t('card.emptyHint')),
601
+ )
602
+ : h(
603
+ 'div',
604
+ { className: 'loci-card-note' },
605
+ h(
606
+ 'div',
607
+ { className: 'loci-card-stamp' },
608
+ `${t('card.updated')} ${stampOf(notes[0]?.at ?? data.generatedAt)}`,
609
+ ),
610
+ h('p', { className: 'loci-card-prose' }, proseOf(notes, t)),
611
+ pending > 0
612
+ ? h('div', { className: 'loci-card-hint' }, t('card.pending').replace('{n}', String(pending)))
613
+ : null,
614
+ ),
615
+ );
616
+ }
617
+
618
+ // ---------------------------------------------------------------- panel
619
+
620
+ function FeedTab({ t, onOpen }) {
621
+ const { data, error, loading, reload } = useJson('/activity?limit=60', { intervalMs: 20_000 });
622
+ // One row per entry, not per event: "added earlier, verified just now" is
623
+ // one story. Events for the same id collapse into a chain of tags and the
624
+ // row keeps the position of its newest event.
625
+ const rows = [];
626
+ const byId = new Map();
627
+ for (const item of data?.items ?? []) {
628
+ const seen = item.id === null ? undefined : byId.get(item.id);
629
+ if (seen !== undefined) {
630
+ if (!seen.chain.includes(item.action)) seen.chain.push(item.action);
631
+ continue;
632
+ }
633
+ const row = { ...item, chain: [item.action] };
634
+ rows.push(row);
635
+ if (item.id !== null) byId.set(item.id, row);
636
+ }
637
+ return h(
638
+ 'div',
639
+ null,
640
+ h(
641
+ 'div',
642
+ { className: 'loci-meta', style: { marginBottom: '10px', alignItems: 'center' } },
643
+ h('span', { className: 'loci-muted' }, t('panel.feedHint')),
644
+ h('span', { className: 'loci-grow' }),
645
+ h('button', { type: 'button', className: 'loci-btn', onClick: reload }, t('panel.refresh')),
646
+ ),
647
+ h(ErrorLine, { error, t }),
648
+ loading && data === undefined ? h('div', { className: 'loci-muted' }, t('panel.loading')) : null,
649
+ data !== undefined && rows.length === 0 ? h('div', { className: 'loci-muted' }, t('panel.empty')) : null,
650
+ rows.map((item, index) =>
651
+ h(
652
+ 'div',
653
+ { key: `${item.ts}-${item.id ?? 'x'}-${index}`, className: 'loci-row' },
654
+ h(
655
+ 'span',
656
+ { className: 'loci-actions', style: { gap: '3px' } },
657
+ item.chain.map((action, step) =>
658
+ h(
659
+ 'span',
660
+ { key: `${action}-${step}`, className: 'loci-tag is-' + action, style: { display: 'flex', gap: '3px' } },
661
+ step > 0 ? h('span', { className: 'loci-muted' }, '→') : null,
662
+ t(ACTION_KEY(action)),
663
+ ),
664
+ ),
665
+ ),
666
+ h(
667
+ 'div',
668
+ { className: 'loci-main' },
669
+ h('div', { className: 'loci-name' }, item.title ?? (item.action === 'maintained' ? t('action.maintained') : '—')),
670
+ h(
671
+ 'div',
672
+ { className: 'loci-meta' },
673
+ h('span', null, fmtSince(item.ts, t)),
674
+ item.kind ? h('span', null, t(`kind.${item.kind}`)) : null,
675
+ item.scope ? h('span', null, item.scope) : null,
676
+ item.detail
677
+ ? h(
678
+ 'span',
679
+ null,
680
+ `${item.detail.closedCandidates} closed${item.detail.reindexed ? ' · reindexed' : ''}`,
681
+ )
682
+ : null,
683
+ ),
684
+ ),
685
+ h(
686
+ 'div',
687
+ { className: 'loci-actions' },
688
+ item.id
689
+ ? h('button', { type: 'button', className: 'loci-btn is-quiet', onClick: () => onOpen(item.id) }, '→')
690
+ : null,
691
+ ),
692
+ ),
693
+ ),
694
+ );
695
+ }
696
+
697
+ function Row({ item, t, onOpen }) {
698
+ return h(
699
+ 'div',
700
+ { className: 'loci-row' },
701
+ h('span', { className: 'loci-id' }, item.id.slice(0, 10)),
702
+ h(
703
+ 'div',
704
+ { className: 'loci-main' },
705
+ h('div', { className: 'loci-name' }, item.title),
706
+ h(
707
+ 'div',
708
+ { className: 'loci-meta' },
709
+ h('span', null, t(`kind.${item.kind}`)),
710
+ h('span', null, item.scope),
711
+ h('span', null, fmtDate(item.validFrom)),
712
+ item.pinned ? h('span', null, '★') : null,
713
+ item.total !== undefined ? h('span', null, `${t('panel.total')}: ${item.total}`) : null,
714
+ ),
715
+ item.snippet ? h('div', { className: 'loci-snippet' }, item.snippet) : null,
716
+ ),
717
+ h(
718
+ 'div',
719
+ { className: 'loci-actions' },
720
+ h(StatusPill, { status: item.status, t }),
721
+ h('button', { type: 'button', className: 'loci-btn', onClick: () => onOpen(item.id) }, '→'),
722
+ ),
723
+ );
724
+ }
725
+
726
+ function Detail({ id, t, onBack, onChange }) {
727
+ const { data, error, loading, reload } = useJson(`/item?id=${encodeURIComponent(id)}&history=1`);
728
+ const entry = data?.entry;
729
+ const act = async (path, body) => {
730
+ try {
731
+ await apiFetch(path, { method: 'POST', body: JSON.stringify(body) });
732
+ reload();
733
+ onChange?.();
734
+ } catch (failure) {
735
+ window.console?.warn?.('loci: action failed', failure);
736
+ }
737
+ };
738
+ return h(
739
+ 'div',
740
+ null,
741
+ h(
742
+ 'div',
743
+ { className: 'loci-detail-head' },
744
+ h('button', { type: 'button', className: 'loci-btn', onClick: onBack }, `← ${t('panel.back')}`),
745
+ h('span', { className: 'loci-grow' }),
746
+ entry
747
+ ? h(
748
+ 'div',
749
+ { className: 'loci-actions' },
750
+ entry.status !== 'verified'
751
+ ? h('button', { type: 'button', className: 'loci-btn is-primary', onClick: () => act('/verify', { id, status: 'verified' }) }, t('panel.verify'))
752
+ : null,
753
+ entry.status !== 'disputed'
754
+ ? h('button', { type: 'button', className: 'loci-btn', onClick: () => act('/verify', { id, status: 'disputed' }) }, t('panel.dispute'))
755
+ : null,
756
+ h(
757
+ 'button',
758
+ { type: 'button', className: 'loci-btn', onClick: () => act('/verify', { id, status: entry.pinned ? 'unpin' : 'pin' }) },
759
+ entry.pinned ? t('panel.unpin') : t('panel.pin'),
760
+ ),
761
+ entry.status === 'deleted'
762
+ ? h('button', { type: 'button', className: 'loci-btn', onClick: () => act('/delete', { id, mode: 'restore' }) }, t('panel.restore'))
763
+ : h(
764
+ 'button',
765
+ {
766
+ type: 'button',
767
+ className: 'loci-btn is-danger',
768
+ onClick: () => {
769
+ if (window.confirm?.(t('panel.confirmDelete')) === false) return;
770
+ act('/delete', { id, mode: 'soft' });
771
+ },
772
+ },
773
+ t('panel.delete'),
774
+ ),
775
+ )
776
+ : null,
777
+ ),
778
+ h(ErrorLine, { error, t }),
779
+ loading && entry === undefined ? h('div', { className: 'loci-muted' }, t('panel.loading')) : null,
780
+ entry
781
+ ? h(
782
+ 'div',
783
+ null,
784
+ h('div', { className: 'loci-name', style: { fontSize: '15px', fontWeight: 600 } }, entry.title),
785
+ h(
786
+ 'div',
787
+ { className: 'loci-meta' },
788
+ h('span', { className: 'loci-id' }, `${entry.id}@${entry.revision}`),
789
+ h(StatusPill, { status: entry.status, t }),
790
+ h('span', null, entry.kind),
791
+ h('span', null, `${entry.scope}:${entry.scopeLabel}`),
792
+ h('span', null, `${fmtDate(entry.validFrom)} → ${entry.validTo === null ? 'now' : fmtDate(entry.validTo)}`),
793
+ ),
794
+ h('div', { className: 'loci-section' }, 'Body'),
795
+ h('pre', { className: 'loci-pre' }, entry.body),
796
+ entry.sourceRef
797
+ ? h(
798
+ 'div',
799
+ null,
800
+ h('div', { className: 'loci-section' }, t('panel.source')),
801
+ h(
802
+ 'div',
803
+ { className: 'loci-meta' },
804
+ h('code', { className: 'loci-id' }, entry.sourceRef),
805
+ ),
806
+ )
807
+ : null,
808
+ entry.evidence?.quote
809
+ ? h(
810
+ 'div',
811
+ null,
812
+ h('div', { className: 'loci-section' }, t('panel.evidence')),
813
+ h('div', { className: 'loci-evidence' }, entry.evidence.quote),
814
+ )
815
+ : null,
816
+ h('div', { className: 'loci-section' }, t('panel.history')),
817
+ h(
818
+ 'div',
819
+ { className: 'loci-kv' },
820
+ (data.history ?? []).map((revision) =>
821
+ h(
822
+ 'div',
823
+ { key: revision.revision, style: { display: 'contents' } },
824
+ h('dt', null, `r${revision.revision}`),
825
+ h(
826
+ 'dd',
827
+ null,
828
+ `${revision.status} · ${fmtDate(revision.validFrom)} → ${revision.validTo === null ? 'now' : fmtDate(revision.validTo)}${revision.supersededBy ? ` → ${revision.supersededBy}` : ''}`,
829
+ ),
830
+ ),
831
+ ),
832
+ ),
833
+ )
834
+ : null,
835
+ );
836
+ }
837
+
838
+ function IndexTab({ t }) {
839
+ const { data, error, loading, reload } = useJson('/preview');
840
+ return h(
841
+ 'div',
842
+ null,
843
+ h('div', { className: 'loci-muted', style: { marginBottom: '8px' } }, t('panel.indexHint')),
844
+ h(
845
+ 'div',
846
+ { className: 'loci-actions', style: { marginBottom: '10px' } },
847
+ h('button', { type: 'button', className: 'loci-btn', onClick: reload }, t('panel.refresh')),
848
+ data
849
+ ? h(
850
+ 'span',
851
+ { className: 'loci-meta' },
852
+ h('span', null, `${fmtBytes(data.bytes)} ${t('panel.bytes')}`),
853
+ h('span', null, `${t('panel.listed')}: ${data.listed}`),
854
+ h('span', null, `${t('panel.hidden')}: ${data.hidden}`),
855
+ )
856
+ : null,
857
+ ),
858
+ h(ErrorLine, { error, t }),
859
+ loading && data === undefined ? h('div', { className: 'loci-muted' }, t('panel.loading')) : null,
860
+ data ? h('pre', { className: 'loci-pre' }, data.text.length === 0 ? t('panel.empty') : data.text) : null,
861
+ );
862
+ }
863
+
864
+ function SearchTab({ t, onOpen }) {
865
+ const [draft, setDraft] = useState('');
866
+ const [query, setQuery] = useState('');
867
+ const [pages, setPages] = useState([]);
868
+ const [error, setError] = useState(undefined);
869
+ const [loading, setLoading] = useState(false);
870
+ const request = async (cursor) => {
871
+ setLoading(true);
872
+ setError(undefined);
873
+ try {
874
+ const params = new URLSearchParams({ q: query, limit: '20', status: 'any' });
875
+ if (cursor) params.set('cursor', cursor);
876
+ const data = await apiFetch(`/list?${params.toString()}`);
877
+ setPages((previous) => (cursor ? [...previous, data] : [data]));
878
+ } catch (failure) {
879
+ setError(String(failure?.message ?? failure));
880
+ } finally {
881
+ setLoading(false);
882
+ }
883
+ };
884
+ useEffect(() => {
885
+ if (query.length === 0) return;
886
+ request(undefined);
887
+ // eslint-disable-next-line react-hooks/exhaustive-deps
888
+ }, [query]);
889
+ const hits = pages.flatMap((page) => page.hits ?? []);
890
+ const last = pages.at(-1);
891
+ return h(
892
+ 'div',
893
+ null,
894
+ h(
895
+ 'form',
896
+ {
897
+ onSubmit: (event) => {
898
+ event.preventDefault();
899
+ setQuery(draft.trim());
900
+ },
901
+ style: { marginBottom: '10px' },
902
+ },
903
+ h('input', {
904
+ className: 'loci-input',
905
+ value: draft,
906
+ placeholder: t('panel.searchPlaceholder'),
907
+ onChange: (event) => setDraft(event.target.value),
908
+ }),
909
+ ),
910
+ h(ErrorLine, { error, t }),
911
+ last ? h('div', { className: 'loci-meta' }, `${t('panel.total')}: ${last.total}`) : null,
912
+ hits.length === 0 && last !== undefined && !loading ? h('div', { className: 'loci-muted' }, t('panel.searchEmpty')) : null,
913
+ hits.map((hit) => h(Row, { key: hit.id, t, item: hit, onOpen })),
914
+ last?.hasMore
915
+ ? h('button', { type: 'button', className: 'loci-btn', disabled: loading, onClick: () => request(last.cursor) }, t('panel.more'))
916
+ : null,
917
+ );
918
+ }
919
+
920
+ function ReviewTab({ t, onOpen, onChanged }) {
921
+ // Only the kinds that could actually reach the prompt are reviewable. An
922
+ // episodic session summary is history: it is never injected, so parking it
923
+ // here forever is how the queue became a wall nobody reads.
924
+ const reviewable = 'kinds=rule,semantic,procedural';
925
+ const candidates = useJson(`/list?status=candidate&limit=50&${reviewable}`);
926
+ const disputed = useJson(`/list?status=disputed&limit=50&${reviewable}`);
927
+ const [busy, setBusy] = useState(undefined);
928
+ const [failed, setFailed] = useState(undefined);
929
+ const act = async (path, body, id) => {
930
+ setBusy(id);
931
+ setFailed(undefined);
932
+ try {
933
+ await apiFetch(path, { method: 'POST', body: JSON.stringify(body) });
934
+ candidates.reload();
935
+ disputed.reload();
936
+ onChanged?.();
937
+ } catch (failure) {
938
+ // A swallowed click is worse than a visible error: the user cannot tell
939
+ // "it worked" from "nothing happened".
940
+ setFailed(`${body.id ?? 'bulk'}: ${String(failure?.message ?? failure)}`);
941
+ } finally {
942
+ setBusy(undefined);
943
+ }
944
+ };
945
+ const verifyMany = async (items) => {
946
+ setBusy('bulk');
947
+ setFailed(undefined);
948
+ try {
949
+ for (const item of items) {
950
+ await apiFetch('/verify', { method: 'POST', body: JSON.stringify({ id: item.id, status: 'verified' }) });
951
+ }
952
+ candidates.reload();
953
+ disputed.reload();
954
+ onChanged?.();
955
+ } catch (failure) {
956
+ setFailed(String(failure?.message ?? failure));
957
+ } finally {
958
+ setBusy(undefined);
959
+ }
960
+ };
961
+ const row = (item, actions) =>
962
+ h(
963
+ 'div',
964
+ { key: item.id, className: 'loci-row' },
965
+ h('span', { className: 'loci-id' }, item.id.slice(0, 10)),
966
+ h(
967
+ 'div',
968
+ { className: 'loci-main' },
969
+ h('div', { className: 'loci-name' }, item.title),
970
+ item.snippet ? h('div', { className: 'loci-snippet' }, item.snippet) : null,
971
+ h('div', { className: 'loci-meta' }, h('span', null, t(`kind.${item.kind}`)), item.scope ? h('span', null, item.scope) : null),
972
+ ),
973
+ h('div', { className: 'loci-actions' }, actions, h('button', { type: 'button', className: 'loci-btn is-quiet', onClick: () => onOpen(item.id) }, '→')),
974
+ );
975
+ const block = (title, state, actions) =>
976
+ h(
977
+ 'div',
978
+ null,
979
+ h(
980
+ 'div',
981
+ { className: 'loci-meta', style: { margin: '18px 0 8px', alignItems: 'center' } },
982
+ h('div', { className: 'loci-section', style: { margin: 0 } }, `${title} (${state.data?.total ?? 0})`),
983
+ h('span', { className: 'loci-grow' }),
984
+ state.data?.items?.length
985
+ ? h(
986
+ 'button',
987
+ {
988
+ type: 'button',
989
+ className: 'loci-btn',
990
+ disabled: busy !== undefined,
991
+ onClick: () => verifyMany(state.data.items),
992
+ },
993
+ t('panel.verifyAll').replace('{n}', String(state.data.items.length)),
994
+ )
995
+ : null,
996
+ ),
997
+ state.data?.items?.length
998
+ ? state.data.items.map((item) => row(item, actions(item)))
999
+ : h('div', { className: 'loci-muted' }, t('panel.noCandidates')),
1000
+ );
1001
+ const candidateActions = (item) => [
1002
+ h(
1003
+ 'button',
1004
+ { key: 'v', type: 'button', className: 'loci-btn is-primary', disabled: busy !== undefined, onClick: () => act('/verify', { id: item.id, status: 'verified' }, item.id) },
1005
+ t('panel.verify'),
1006
+ ),
1007
+ h(
1008
+ 'button',
1009
+ {
1010
+ key: 'r',
1011
+ type: 'button',
1012
+ className: 'loci-btn is-danger',
1013
+ disabled: busy !== undefined,
1014
+ onClick: () => act('/delete', { id: item.id, mode: 'soft', reason: 'rejected in review' }, item.id),
1015
+ },
1016
+ t('panel.reject'),
1017
+ ),
1018
+ ];
1019
+ const disputedActions = (item) => [
1020
+ h(
1021
+ 'button',
1022
+ { key: 'v', type: 'button', className: 'loci-btn is-primary', disabled: busy !== undefined, onClick: () => act('/verify', { id: item.id, status: 'verified' }, item.id) },
1023
+ t('panel.verify'),
1024
+ ),
1025
+ ];
1026
+ return h(
1027
+ 'div',
1028
+ null,
1029
+ h(ErrorLine, { error: candidates.error ?? disputed.error, t }),
1030
+ failed !== undefined ? h('div', { className: 'loci-error' }, `${t('panel.actionFailed')}: ${failed}`) : null,
1031
+ h('div', { className: 'loci-muted' }, t('panel.reviewHint')),
1032
+ block(t('panel.candidates'), candidates, candidateActions),
1033
+ block(t('panel.disputed'), disputed, disputedActions),
1034
+ );
1035
+ }
1036
+
1037
+ function StatsTab({ t }) {
1038
+ const { data, error, loading, reload } = useJson('/stats', { intervalMs: 15_000 });
1039
+ if (loading && data === undefined) return h('div', { className: 'loci-muted' }, t('panel.loading'));
1040
+ const cell = (label, value) => h('div', { className: 'loci-cell' }, h('span', { className: 'loci-label' }, label), h('b', null, value));
1041
+ const vs = data?.vector ?? {};
1042
+ return h(
1043
+ 'div',
1044
+ null,
1045
+ h(ErrorLine, { error, t }),
1046
+ data
1047
+ ? h(
1048
+ 'div',
1049
+ null,
1050
+ h(
1051
+ 'div',
1052
+ { className: 'loci-grid' },
1053
+ cell('entries', data.heads ?? 0),
1054
+ cell('revisions', data.revisions ?? 0),
1055
+ cell(t('panel.candidates'), data.pendingCandidates ?? 0),
1056
+ cell('disputed', data.disputed ?? 0),
1057
+ cell('db', fmtBytes(data.dbBytes)),
1058
+ cell('index rev', data.indexRev ?? 0),
1059
+ ),
1060
+ h('div', { className: 'loci-section' }, t('panel.store')),
1061
+ h(
1062
+ 'dl',
1063
+ { className: 'loci-kv' },
1064
+ h('dt', null, 'path'), h('dd', null, data.memoryRoot ?? '—'),
1065
+ h('dt', null, 'schema'), h('dd', null, `v${data.schemaVersion}`),
1066
+ h('dt', null, 'vector'), h('dd', null, `${vs.mode ?? 'off'}${vs.healthy ? ' · healthy' : ''}${vs.lastError ? ` · ${vs.lastError}` : ''}`),
1067
+ h('dt', null, 'last injection'),
1068
+ h('dd', null, data.lastInjection ? `${data.lastInjection.bytes} B · ${data.lastInjection.listed} listed · ${data.lastInjection.hidden} hidden` : '—'),
1069
+ ),
1070
+ h('div', { className: 'loci-section' }, t('panel.injections')),
1071
+ (data.injections ?? []).slice(0, 20).map((row, index) =>
1072
+ h(
1073
+ 'div',
1074
+ { key: `${row.ts}-${index}`, className: 'loci-row' },
1075
+ h('span', { className: 'loci-id' }, new Date(row.ts).toLocaleTimeString()),
1076
+ h('div', { className: 'loci-main' }, h('div', { className: 'loci-name' }, `${row.sectionBytes} ${t('panel.bytes')}`), h('div', { className: 'loci-meta' }, `${row.listed} listed · ${row.hidden} hidden`)),
1077
+ ),
1078
+ ),
1079
+ h('div', { className: 'loci-meta', style: { marginTop: '14px' } }, `${t('panel.version')} v${data.pluginVersion ?? '—'}`),
1080
+ )
1081
+ : null,
1082
+ );
1083
+ }
1084
+
1085
+ function MemoryPanel({ t }) {
1086
+ const [tab, setTab] = useState('feed');
1087
+ const [selected, setSelected] = useState(undefined);
1088
+ const [nonce, setNonce] = useState(0);
1089
+ const tabs = useMemo(
1090
+ () => [
1091
+ { id: 'feed', label: t('panel.feed') },
1092
+ { id: 'index', label: t('panel.index') },
1093
+ { id: 'search', label: t('panel.search') },
1094
+ { id: 'review', label: t('panel.review') },
1095
+ { id: 'stats', label: t('panel.stats') },
1096
+ ],
1097
+ [t],
1098
+ );
1099
+ const changed = useCallback(() => setNonce((value) => value + 1), []);
1100
+ return h(
1101
+ 'div',
1102
+ { className: 'loci-root' },
1103
+ h('style', null, CSS),
1104
+ h(
1105
+ 'div',
1106
+ { className: 'loci-bar' },
1107
+ h('span', { className: 'loci-title' }, t('panel.title')),
1108
+ h('span', { className: 'loci-grow' }),
1109
+ selected === undefined ? h(Tabs, { value: tab, onChange: setTab, tabs }) : null,
1110
+ ),
1111
+ h(
1112
+ 'div',
1113
+ { className: 'loci-body' },
1114
+ selected !== undefined
1115
+ ? h(Detail, { id: selected, t, onBack: () => setSelected(undefined), onChange: changed })
1116
+ : tab === 'feed'
1117
+ ? h(FeedTab, { t, onOpen: setSelected, key: `feed-${nonce}` })
1118
+ : tab === 'index'
1119
+ ? h(IndexTab, { t, key: `index-${nonce}` })
1120
+ : tab === 'search'
1121
+ ? h(SearchTab, { t, onOpen: setSelected })
1122
+ : tab === 'review'
1123
+ ? h(ReviewTab, { t, onOpen: setSelected, onChanged: changed })
1124
+ : h(StatsTab, { t }),
1125
+ ),
1126
+ );
1127
+ }
1128
+
1129
+ function PanelIcon({ size, active }) {
1130
+ return h(
1131
+ 'svg',
1132
+ {
1133
+ width: size,
1134
+ height: size,
1135
+ viewBox: '0 0 24 24',
1136
+ fill: 'none',
1137
+ 'aria-hidden': true,
1138
+ style: { opacity: active ? 1 : 0.75, display: 'block' },
1139
+ },
1140
+ h('path', { d: 'M4 6.5h16M4 12h16M4 17.5h10', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }),
1141
+ h('circle', { cx: 18.5, cy: 17.5, r: 2.2, stroke: 'currentColor', strokeWidth: 1.5 }),
1142
+ );
1143
+ }
1144
+
1145
+ function SettingsIcon({ size }) {
1146
+ return h(
1147
+ 'svg',
1148
+ { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
1149
+ h('path', { d: 'M5 7h14M5 12h14M5 17h9', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }),
1150
+ );
1151
+ }
1152
+
1153
+ // --------------------------------------------------------------- settings
1154
+
1155
+ function Field({ label, hint, children }) {
1156
+ return h(
1157
+ 'div',
1158
+ { className: 'loci-field' },
1159
+ h('div', null, h('div', null, label), hint ? h('div', { className: 'loci-muted' }, hint) : null),
1160
+ h('div', null, children),
1161
+ );
1162
+ }
1163
+
1164
+ function SettingsSection(props) {
1165
+ const t = props?.t ?? ((key) => key);
1166
+ const ctx = props?.ctx;
1167
+ const effective = useJson('/config');
1168
+ const [status, setStatus] = useState(undefined);
1169
+ const [forgetPhrase, setForgetPhrase] = useState('');
1170
+ const [forgetBusy, setForgetBusy] = useState(false);
1171
+ const [forgetResult, setForgetResult] = useState(undefined);
1172
+ const forms = ctx?.get?.('configForms');
1173
+ const form = forms?.get?.(ENTRY_ID);
1174
+ const [snapshot, setSnapshot] = useState(() => form?.getSnapshot?.() ?? { status: 'unavailable', value: undefined, writable: false });
1175
+ useEffect(() => {
1176
+ if (!form?.subscribe) return undefined;
1177
+ setSnapshot(form.getSnapshot());
1178
+ return form.subscribe(() => setSnapshot(form.getSnapshot()));
1179
+ }, [form]);
1180
+ const values = { ...(effective.data?.config ?? {}), ...(snapshot?.value ?? {}) };
1181
+ const write = async (field, value) => {
1182
+ if (!form?.set) return;
1183
+ setStatus('saving');
1184
+ try {
1185
+ const ok = await form.set(field, value);
1186
+ setStatus(ok ? 'saved' : 'rejected');
1187
+ } catch {
1188
+ setStatus('failed');
1189
+ }
1190
+ setTimeout(() => setStatus(undefined), 2500);
1191
+ };
1192
+ const select = (field, options, current) =>
1193
+ h(
1194
+ 'select',
1195
+ { value: String(current), disabled: !form?.set, onChange: (event) => write(field, event.target.value) },
1196
+ options.map((option) => h('option', { key: option.value, value: option.value }, option.label)),
1197
+ );
1198
+ return h(
1199
+ 'div',
1200
+ { className: 'loci-root', style: { height: 'auto' } },
1201
+ h('style', null, CSS),
1202
+ h('div', { className: 'loci-muted', style: { marginBottom: '8px' } }, t('settings.intro')),
1203
+ form?.set ? null : h('div', { className: 'loci-muted' }, t('settings.unavailable')),
1204
+ h(ErrorLine, { error: effective.error, t }),
1205
+ h(
1206
+ 'div',
1207
+ null,
1208
+ h(Field, {
1209
+ label: t('settings.budget'),
1210
+ hint: t('settings.budgetHint'),
1211
+ children: h('input', {
1212
+ type: 'number',
1213
+ min: 1024,
1214
+ max: 16384,
1215
+ step: 256,
1216
+ value: values.sectionMaxBytes ?? 3584,
1217
+ disabled: !form?.set,
1218
+ onChange: (event) => write('sectionMaxBytes', Number(event.target.value)),
1219
+ }),
1220
+ }),
1221
+ h(Field, {
1222
+ label: t('settings.scope'),
1223
+ children: select('defaultScope', [
1224
+ { value: 'workspace', label: 'workspace' },
1225
+ { value: 'project', label: 'project' },
1226
+ { value: 'global', label: 'global' },
1227
+ ], values.defaultScope ?? 'workspace'),
1228
+ }),
1229
+ h(Field, {
1230
+ label: t('settings.capture'),
1231
+ children: select('captureMode', [
1232
+ { value: 'rules', label: t('captureRules') },
1233
+ { value: 'off', label: t('off') },
1234
+ ], values.captureMode ?? 'rules'),
1235
+ }),
1236
+ h(Field, {
1237
+ label: t('settings.assist'),
1238
+ children: select('recallAssist', [
1239
+ { value: 'off', label: t('off') },
1240
+ { value: 'session-start', label: 'session-start' },
1241
+ ], values.recallAssist ?? 'off'),
1242
+ }),
1243
+ h(Field, {
1244
+ label: t('settings.maintenance'),
1245
+ children: select('maintenanceMode', [
1246
+ { value: 'sweep', label: 'every 6h' },
1247
+ { value: 'off', label: t('off') },
1248
+ ], values.maintenanceMode ?? 'sweep'),
1249
+ }),
1250
+ h(Field, {
1251
+ label: t('settings.secret'),
1252
+ children: select('secretPolicy', [
1253
+ { value: 'redact', label: t('secretRedact') },
1254
+ { value: 'reject', label: t('secretReject') },
1255
+ ], values.secretPolicy ?? 'redact'),
1256
+ }),
1257
+ h(Field, {
1258
+ label: t('settings.vector'),
1259
+ children: select('vectorMode', [
1260
+ { value: 'off', label: t('vectorOff') },
1261
+ { value: 'http', label: t('vectorHttp') },
1262
+ ], values.vectorMode ?? 'off'),
1263
+ }),
1264
+ ),
1265
+ h('div', { className: 'loci-section' }, t('settings.danger')),
1266
+ h('div', { className: 'loci-muted' }, t('settings.dangerHint')),
1267
+ h(
1268
+ 'div',
1269
+ { className: 'loci-row', style: { borderBottom: 0 } },
1270
+ h('input', {
1271
+ className: 'loci-input',
1272
+ value: forgetPhrase,
1273
+ placeholder: t('settings.dangerPhrase'),
1274
+ onChange: (event) => setForgetPhrase(event.target.value),
1275
+ style: { maxWidth: '260px' },
1276
+ }),
1277
+ h(
1278
+ 'button',
1279
+ {
1280
+ type: 'button',
1281
+ className: 'loci-btn is-danger',
1282
+ disabled: forgetPhrase.trim().length === 0 || forgetBusy,
1283
+ onClick: async () => {
1284
+ setForgetBusy(true);
1285
+ setForgetResult(undefined);
1286
+ try {
1287
+ const answer = await apiFetch('/forget-all', {
1288
+ method: 'POST',
1289
+ body: JSON.stringify({ confirm: forgetPhrase.trim(), mode: 'soft' }),
1290
+ });
1291
+ setForgetResult(t('settings.dangerDone').replace('{n}', String(answer.count ?? 0)));
1292
+ setForgetPhrase('');
1293
+ } catch (failure) {
1294
+ setForgetResult(`${t('panel.actionFailed')}: ${String(failure?.message ?? failure)}`);
1295
+ } finally {
1296
+ setForgetBusy(false);
1297
+ }
1298
+ },
1299
+ },
1300
+ forgetBusy ? t('settings.dangerBusy') : t('settings.dangerRun'),
1301
+ ),
1302
+ ),
1303
+ forgetResult !== undefined ? h('div', { className: 'loci-muted' }, forgetResult) : null,
1304
+ h('div', { className: 'loci-section' }, t('settings.diagnostics')),
1305
+ h(
1306
+ 'dl',
1307
+ { className: 'loci-kv' },
1308
+ h('dt', null, 'store'), h('dd', null, effective.data?.config?.home ? String(effective.data.config.home) : 'DSH_HOME/memory/loci'),
1309
+ h('dt', null, 'schema'), h('dd', null, 'v1'),
1310
+ h('dt', null, 'status'), h('dd', null, status ?? '—'),
1311
+ ),
1312
+ );
1313
+ }
1314
+
1315
+ function apply(ctx) {
1316
+ ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'dsh-loci: locale dictionaries');
1317
+ const bind = ctx.locale.bind(NS);
1318
+ const t = tOf(bind);
1319
+
1320
+ // The "副屏": a card beside Settings, always visible while the sidebar is.
1321
+ ctx.slots.inject('sidebar.footer.action', () =>
1322
+ ctx.slots.register(
1323
+ {
1324
+ name: 'sidebar.footer.action',
1325
+ id: 'loci-card',
1326
+ order: 10,
1327
+ label: () => t('card.title'),
1328
+ locale: NS,
1329
+ inject: () => ({ t, openPanel: ctx.layout ? () => ctx.layout.selectPanel(PANEL_ID) : undefined }),
1330
+ },
1331
+ MemoryCard,
1332
+ ),
1333
+ );
1334
+ ctx.slots.inject('settings.section', () =>
1335
+ ctx.slots.register(
1336
+ {
1337
+ name: 'settings.section',
1338
+ id: NS,
1339
+ order: 25,
1340
+ label: () => t('settings.title'),
1341
+ locale: NS,
1342
+ inject: () => ({ t, ctx }),
1343
+ },
1344
+ SettingsSection,
1345
+ ),
1346
+ );
1347
+ ctx.slots.inject('settings.section.icon', () =>
1348
+ ctx.slots.register({ name: 'settings.section.icon', id: NS, locale: NS }, SettingsIcon),
1349
+ );
1350
+ ctx.slots.inject('sidebar.panellist', () =>
1351
+ ctx.slots.register(
1352
+ {
1353
+ name: 'sidebar.panellist',
1354
+ id: PANEL_ID,
1355
+ order: 20,
1356
+ label: () => t('panel.title'),
1357
+ locale: NS,
1358
+ },
1359
+ PanelIcon,
1360
+ ),
1361
+ );
1362
+ ctx.slots.inject('main', () =>
1363
+ ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS, inject: () => ({ t }) }, MemoryPanel),
1364
+ );
1365
+ }
1366
+
1367
+ return { inject: ['slots', 'locale', 'layout'], apply };
1368
+ },
1369
+ });