dsh-hypatia-auto-memory 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ /* The conversation Memory tab: a diagnostic panel in the shell's own vocabulary. */
2
+
3
+ .panel {
4
+ display: flex;
5
+ height: 100%;
6
+ min-height: 0;
7
+ width: 100%;
8
+ box-sizing: border-box;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ overflow-y: auto;
12
+ /* ConversationRoot publishes the floating composer's live height; the view
13
+ owns its scroller under the overlay, so the last line has to clear it. */
14
+ padding: 16px;
15
+ padding-bottom: calc(var(--dsh-composer-height, 152px) + 16px);
16
+ color: var(--dsw-alias-label-primary);
17
+ background: var(--dsw-alias-bg-layer-1);
18
+ }
19
+
20
+ /*
21
+ * One measured column instead of the full panel width. A key/value row spread
22
+ * across a 27-inch window puts a label and its value a screen apart, which is
23
+ * what this bounds; the same limit keeps summary prose at a readable line
24
+ * length.
25
+ */
26
+ .column {
27
+ display: flex;
28
+ width: 100%;
29
+ max-width: 880px;
30
+ min-width: 0;
31
+ flex-direction: column;
32
+ gap: 20px;
33
+ }
34
+
35
+ .head {
36
+ display: flex;
37
+ align-items: baseline;
38
+ justify-content: space-between;
39
+ gap: 12px;
40
+ }
41
+
42
+ .title {
43
+ margin: 0;
44
+ color: var(--dsw-alias-label-primary);
45
+ font-size: 15px;
46
+ font-weight: 600;
47
+ line-height: 1.4;
48
+ }
49
+
50
+ .headMeta {
51
+ display: flex;
52
+ align-items: center;
53
+ gap: 10px;
54
+ }
55
+
56
+ .updated {
57
+ color: var(--dsw-alias-label-tertiary);
58
+ font-size: 12px;
59
+ }
60
+
61
+ .refresh {
62
+ appearance: none;
63
+ border: 0.5px solid var(--dsw-alias-border-l4);
64
+ border-radius: 8px;
65
+ background: none;
66
+ color: var(--dsw-alias-label-secondary);
67
+ cursor: pointer;
68
+ font: inherit;
69
+ font-size: 12px;
70
+ padding: 2px 8px;
71
+ }
72
+
73
+ .refresh:hover {
74
+ border-color: var(--dsw-alias-label-dimmed);
75
+ color: var(--dsw-alias-label-primary);
76
+ }
77
+
78
+ .refresh:focus-visible {
79
+ outline: 2px solid var(--dsw-alias-brand-primary);
80
+ outline-offset: 1px;
81
+ }
82
+
83
+ .status,
84
+ .hint,
85
+ .error {
86
+ margin: 0;
87
+ font-size: 12px;
88
+ line-height: 1.6;
89
+ }
90
+
91
+ .status,
92
+ .hint {
93
+ color: var(--dsw-alias-label-tertiary);
94
+ }
95
+
96
+ .error {
97
+ color: var(--dsw-alias-label-error);
98
+ }
99
+
100
+ .group {
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 10px;
104
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
105
+ padding-top: 16px;
106
+ }
107
+
108
+ .groupTitle {
109
+ display: flex;
110
+ margin: 0;
111
+ align-items: baseline;
112
+ gap: 8px;
113
+ color: var(--dsw-alias-label-primary);
114
+ font-size: 13px;
115
+ font-weight: 600;
116
+ }
117
+
118
+ .count {
119
+ color: var(--dsw-alias-label-tertiary);
120
+ font-size: 11px;
121
+ font-weight: 400;
122
+ font-variant-numeric: tabular-nums;
123
+ }
124
+
125
+ /*
126
+ * A definition list laid out as ONE grid, not one flex row per pair: `max-content`
127
+ * keeps each value immediately after its label whatever the panel's width, and
128
+ * the shared grid keeps the values aligned in a column. `space-between` was the
129
+ * bug — on a wide screen it pushed the two ends of every row to the edges.
130
+ */
131
+ .rows {
132
+ display: grid;
133
+ grid-template-columns: max-content minmax(0, 1fr);
134
+ align-items: baseline;
135
+ column-gap: 20px;
136
+ row-gap: 7px;
137
+ margin: 0;
138
+ }
139
+
140
+ .row {
141
+ display: contents;
142
+ }
143
+
144
+ .label {
145
+ color: var(--dsw-alias-label-tertiary);
146
+ font-size: 12px;
147
+ white-space: nowrap;
148
+ }
149
+
150
+ .value {
151
+ display: flex;
152
+ min-width: 0;
153
+ align-items: baseline;
154
+ gap: 8px;
155
+ margin: 0;
156
+ color: var(--dsw-alias-label-primary);
157
+ font-size: 12px;
158
+ font-variant-numeric: tabular-nums;
159
+ }
160
+
161
+ .calm,
162
+ .busy {
163
+ border-radius: 6px;
164
+ font-size: 11px;
165
+ padding: 1px 6px;
166
+ white-space: nowrap;
167
+ }
168
+
169
+ .calm {
170
+ color: var(--dsw-alias-label-tertiary);
171
+ background: var(--dsw-alias-bg-layer-2);
172
+ }
173
+
174
+ .busy {
175
+ color: var(--dsw-alias-label-primary);
176
+ border: 0.5px solid var(--dsw-alias-border-l4);
177
+ background: var(--dsw-alias-bg-layer-3);
178
+ }
179
+
180
+ .failures {
181
+ display: grid;
182
+ gap: 6px;
183
+ margin: 0;
184
+ padding: 0;
185
+ list-style: none;
186
+ }
187
+
188
+ .failure {
189
+ display: grid;
190
+ gap: 2px;
191
+ border-radius: 8px;
192
+ background: var(--dsw-alias-bg-layer-2);
193
+ padding: 8px 10px;
194
+ }
195
+
196
+ .failureKind {
197
+ color: var(--dsw-alias-label-primary);
198
+ font-size: 12px;
199
+ }
200
+
201
+ .failureError {
202
+ color: var(--dsw-alias-label-tertiary);
203
+ font-size: 11px;
204
+ line-height: 1.5;
205
+ overflow-wrap: anywhere;
206
+ }
207
+
208
+ /* -------------------------------------------------------- distilled entries -- */
209
+
210
+ .entries {
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: 8px;
214
+ margin: 0;
215
+ padding: 0;
216
+ list-style: none;
217
+ }
218
+
219
+ .entry {
220
+ border: 0.5px solid var(--dsw-alias-border-l2);
221
+ border-radius: 10px;
222
+ background: var(--dsw-alias-bg-layer-2);
223
+ overflow: hidden;
224
+ }
225
+
226
+ .entryHead {
227
+ display: flex;
228
+ width: 100%;
229
+ align-items: baseline;
230
+ justify-content: space-between;
231
+ gap: 12px;
232
+ appearance: none;
233
+ border: 0;
234
+ background: none;
235
+ color: inherit;
236
+ cursor: pointer;
237
+ font: inherit;
238
+ padding: 8px 12px;
239
+ text-align: left;
240
+ }
241
+
242
+ .entryHead:hover {
243
+ background: var(--dsw-alias-bg-layer-3);
244
+ }
245
+
246
+ .entryHead:focus-visible {
247
+ outline: 2px solid var(--dsw-alias-brand-primary);
248
+ outline-offset: -2px;
249
+ }
250
+
251
+ .entryName {
252
+ min-width: 0;
253
+ overflow: hidden;
254
+ color: var(--dsw-alias-label-secondary);
255
+ font-family: var(--dsw-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
256
+ font-size: 11px;
257
+ text-overflow: ellipsis;
258
+ white-space: nowrap;
259
+ }
260
+
261
+ .entryMeta {
262
+ display: flex;
263
+ flex: 0 0 auto;
264
+ align-items: baseline;
265
+ gap: 8px;
266
+ }
267
+
268
+ .badge {
269
+ border-radius: 6px;
270
+ background: var(--dsw-alias-bg-layer-1);
271
+ color: var(--dsw-alias-label-tertiary);
272
+ font-size: 11px;
273
+ padding: 1px 6px;
274
+ white-space: nowrap;
275
+ }
276
+
277
+ .entryTime {
278
+ color: var(--dsw-alias-label-quaternary);
279
+ font-size: 11px;
280
+ font-variant-numeric: tabular-nums;
281
+ white-space: nowrap;
282
+ }
283
+
284
+ .entryToggle {
285
+ color: var(--dsw-alias-label-tertiary);
286
+ font-size: 11px;
287
+ white-space: nowrap;
288
+ }
289
+
290
+ .entryBody {
291
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
292
+ padding: 10px 12px;
293
+ font-size: 13px;
294
+ line-height: 1.65;
295
+ overflow-wrap: anywhere;
296
+ }
297
+
298
+ .entryPreview {
299
+ margin: 0;
300
+ overflow: hidden;
301
+ padding: 0 12px 10px;
302
+ color: var(--dsw-alias-label-tertiary);
303
+ font-size: 12px;
304
+ line-height: 1.5;
305
+ text-overflow: ellipsis;
306
+ white-space: nowrap;
307
+ }
308
+
309
+ .hint {
310
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
311
+ padding-top: 12px;
312
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The conversation's Memory tab: what this session has been remembered as, what
3
+ * it was remembered into, and what is stuck.
4
+ *
5
+ * Three sections, in the order a reader wants them: the pipeline's status
6
+ * (watermarks, backlog, failures), then the span summaries consolidation
7
+ * produced, then the work units derived from them. Bodies render through the
8
+ * shell's own `MarkdownText`, so a summary reads exactly like assistant Markdown
9
+ * elsewhere in the GUI and costs no bundle weight.
10
+ *
11
+ * Raw `msg-*` entries are deliberately absent: they hold the conversation
12
+ * verbatim, the reader just wrote them, and the agent is the right reader for
13
+ * them (see `src/memory-api.js` for the same rule on the Host side).
14
+ *
15
+ * Status is polled; content is fetched only when it can have changed — on mount,
16
+ * on a session switch, on a manual refresh, and whenever consolidation advances
17
+ * the session's watermark. Reading the shelf costs hypatia round trips, and
18
+ * consolidation is the only thing that changes it.
19
+ */
20
+
21
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
+ import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
23
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
24
+ import {
25
+ consolidationGap,
26
+ mergeMemory,
27
+ type FetchMemory,
28
+ type MemoryEntry,
29
+ type MemoryPayload,
30
+ } from './memory-client'
31
+ import { NS } from './locales'
32
+ import css from './MemoryView.module.css'
33
+ import './slot-contract'
34
+
35
+ /** How often the tab re-reads the status half while it is on screen. */
36
+ const POLL_MS = 5000
37
+
38
+ export type MemoryViewProps = {
39
+ /** The session this tab is bound to; supplied by the session-scoped slot. */
40
+ sessionId: string
41
+ fetch: FetchMemory
42
+ } & PropsLocale<typeof NS>
43
+
44
+ /** Local wall-clock stamp for the freshness line. */
45
+ function formatTime(epochMs: number): string {
46
+ if (!Number.isFinite(epochMs) || epochMs <= 0) return '—'
47
+ return new Date(epochMs).toLocaleTimeString()
48
+ }
49
+
50
+ /**
51
+ * hypatia stamps `created_at` as `YYYY-MM-DD HH:MM:SS.ffffff`; the microseconds
52
+ * are noise in a row that is 11px tall, so the tab shows down to the minute.
53
+ * Slice rather than `new Date`: the space-separated form is not the ISO shape
54
+ * every engine is required to parse.
55
+ */
56
+ function formatEntryTime(stamp: string): string {
57
+ return stamp.length >= 16 ? stamp.slice(0, 16).replace('T', ' ') : stamp
58
+ }
59
+
60
+ /** The first non-empty line of a body, for a collapsed row's preview. */
61
+ function firstLine(markdown: string): string {
62
+ for (const line of markdown.split('\n')) {
63
+ const trimmed = line.replace(/^[#>\-*\s]+/, '').trim()
64
+ if (trimmed !== '') return trimmed
65
+ }
66
+ return ''
67
+ }
68
+
69
+ /** One collapsible distilled entry. */
70
+ function Entry({ entry, badge, open, onToggle, labels, t }: {
71
+ entry: MemoryEntry
72
+ badge: string
73
+ open: boolean
74
+ onToggle: () => void
75
+ labels: { code: { copyLabel: string; copiedLabel: string }; footnotes: string }
76
+ t: MemoryViewProps['t']
77
+ }) {
78
+ return (
79
+ <li className={css.entry}>
80
+ <button type="button" className={css.entryHead} aria-expanded={open} onClick={onToggle}>
81
+ <span className={css.entryName}>{entry.name}</span>
82
+ <span className={css.entryMeta}>
83
+ <span className={css.badge}>{badge}</span>
84
+ {entry.createdAt !== '' ? <span className={css.entryTime}>{formatEntryTime(entry.createdAt)}</span> : null}
85
+ <span className={css.entryToggle}>{open ? t('memoryEntryCollapse') : t('memoryEntryExpand')}</span>
86
+ </span>
87
+ </button>
88
+ {open
89
+ ? (
90
+ <div className={css.entryBody}>
91
+ {entry.markdown === ''
92
+ ? <p className={css.status}>{t('memoryEntryEmpty')}</p>
93
+ : <MarkdownText text={entry.markdown} labels={labels} />}
94
+ </div>
95
+ )
96
+ : <p className={css.entryPreview}>{firstLine(entry.markdown)}</p>}
97
+ </li>
98
+ )
99
+ }
100
+
101
+ export function MemoryView({ sessionId, fetch, t }: MemoryViewProps) {
102
+ const [payload, setPayload] = useState<MemoryPayload | undefined>(undefined)
103
+ const [loading, setLoading] = useState(true)
104
+ const [error, setError] = useState('')
105
+ /**
106
+ * The entries the reader opened, by name. Absent means closed, which is the
107
+ * default: a toggle survives the poll that replaces the payload, and an entry
108
+ * a later poll adds starts closed rather than inheriting a neighbour's state.
109
+ */
110
+ const [overrides, setOverrides] = useState<Readonly<Record<string, boolean>>>({})
111
+ /** The watermark whose content is already in hand, so a poll never re-reads it. */
112
+ const [contentAt, setContentAt] = useState<number | undefined>(undefined)
113
+
114
+ const copyLabel = t('memoryCopyCode')
115
+ const copiedLabel = t('memoryCopiedCode')
116
+ const footnotes = t('memoryFootnotes')
117
+ // MarkdownText caches its render against the labels identity, so this must be
118
+ // one object per locale, not a fresh one per render.
119
+ const labels = useMemo(
120
+ () => ({ code: { copyLabel, copiedLabel }, footnotes }),
121
+ [copyLabel, copiedLabel, footnotes],
122
+ )
123
+
124
+ /**
125
+ * The session the tab is showing now. A request outlives the session it was
126
+ * made for — a slow shelf read, or a poll already in flight at a switch — and
127
+ * its answer must be dropped rather than folded into the next session's view.
128
+ */
129
+ const activeSession = useRef(sessionId)
130
+
131
+ const load = useCallback(async (content: boolean) => {
132
+ const current = () => activeSession.current === sessionId
133
+ try {
134
+ const next = await fetch(sessionId, { content })
135
+ if (!current()) return
136
+ // Folded, not assigned: a status-only poll must not erase the shelf
137
+ // content it did not ask for (see `mergeMemory`).
138
+ setPayload((previous) => mergeMemory(previous, next))
139
+ setError('')
140
+ if (content) setContentAt(next.session.consolidatedSeq)
141
+ } catch (failure) {
142
+ if (!current()) return
143
+ setError(failure instanceof Error ? failure.message : String(failure))
144
+ } finally {
145
+ if (current()) setLoading(false)
146
+ }
147
+ }, [fetch, sessionId])
148
+
149
+ // A session switch starts over: the previous session's content and the
150
+ // previous session's collapsed rows must not leak into this one.
151
+ useEffect(() => {
152
+ activeSession.current = sessionId
153
+ setPayload(undefined)
154
+ setOverrides({})
155
+ setContentAt(undefined)
156
+ setLoading(true)
157
+ void load(true)
158
+ }, [load, sessionId])
159
+
160
+ useEffect(() => {
161
+ const timer = setInterval(() => {
162
+ // A hidden tab has nobody reading it; the panel is a snapshot, not a stream.
163
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
164
+ void load(false)
165
+ }, POLL_MS)
166
+ return () => clearInterval(timer)
167
+ }, [load])
168
+
169
+ // Consolidation is the only thing that changes the shelf, so a watermark that
170
+ // moved is the signal to read it again.
171
+ useEffect(() => {
172
+ const at = payload?.session.consolidatedSeq
173
+ if (at === undefined || contentAt === undefined) return
174
+ if (at !== contentAt) void load(true)
175
+ }, [payload?.session.consolidatedSeq, contentAt, load])
176
+
177
+ const toggle = useCallback((name: string, open: boolean) => {
178
+ setOverrides((current) => ({ ...current, [name]: !open }))
179
+ }, [])
180
+
181
+ const session = payload?.session
182
+ const content = payload?.content
183
+ const summaries = content?.summaries ?? []
184
+ const workUnits = content?.workUnits ?? []
185
+
186
+ // Every entry starts closed and previews its first line: the list stays
187
+ // scannable at any length, and the bodies are one click away. `overrides`
188
+ // holds what the reader opened, so a poll that replaces the payload does not
189
+ // close it again.
190
+ const renderEntries = (entries: MemoryEntry[], kind: 'summary' | 'workUnit') => (
191
+ <ul className={css.entries}>
192
+ {entries.map((entry) => {
193
+ const open = overrides[entry.name] === true
194
+ return (
195
+ <Entry
196
+ key={entry.name}
197
+ entry={entry}
198
+ badge={kind === 'summary' ? t('memoryTier', { level: String(entry.level) }) : t('memoryWorkUnit')}
199
+ open={open}
200
+ onToggle={() => toggle(entry.name, open)}
201
+ labels={labels}
202
+ t={t}
203
+ />
204
+ )
205
+ })}
206
+ </ul>
207
+ )
208
+
209
+ return (
210
+ // Opts into the composer overlay the way the trajectory ledger does: the
211
+ // view owns its scroller and gets the full height, with the composer
212
+ // floating over it. Without the marker the Conversation shell seats the
213
+ // composer below a short panel instead.
214
+ <div className={css.panel} data-conversation-composer-overlay="">
215
+ <div className={css.column}>
216
+ <header className={css.head}>
217
+ <h2 className={css.title}>{t('memoryTitle')}</h2>
218
+ <div className={css.headMeta}>
219
+ <span className={css.updated}>{t('memoryUpdatedAt', { time: formatTime(payload?.updatedAt ?? 0) })}</span>
220
+ <button type="button" className={css.refresh} onClick={() => { void load(true) }}>
221
+ {t('memoryRefresh')}
222
+ </button>
223
+ </div>
224
+ </header>
225
+
226
+ {error !== '' ? <p className={css.error} role="status">{t('memoryLoadFailed', { message: error })}</p> : null}
227
+
228
+ {loading && payload === undefined
229
+ ? <p className={css.status}>{t('memoryLoading')}</p>
230
+ : session === undefined
231
+ ? <p className={css.status}>{t('memoryNoSnapshot')}</p>
232
+ : !session.known
233
+ ? <p className={css.status}>{t('memoryUnknown')}</p>
234
+ : (
235
+ <section className={css.group} aria-label={t('memoryTitle')}>
236
+ <dl className={css.rows}>
237
+ <div className={css.row}>
238
+ <dt className={css.label}>{t('memoryLogged')}</dt>
239
+ <dd className={css.value}>{t('memorySeqValue', { seq: String(session.loggedSeq) })}</dd>
240
+ </div>
241
+ <div className={css.row}>
242
+ <dt className={css.label}>{t('memoryConsolidated')}</dt>
243
+ <dd className={css.value}>
244
+ {t('memorySeqValue', { seq: String(session.consolidatedSeq) })}
245
+ {/* Quantified, not a bare "pending": a reader has to know HOW
246
+ far behind to judge whether to wait. */}
247
+ <span className={session.caughtUp ? css.calm : css.busy}>
248
+ {session.caughtUp
249
+ ? t('memoryCaughtUp')
250
+ : t('memoryBehind', { behind: String(consolidationGap(session)) })}
251
+ </span>
252
+ </dd>
253
+ </div>
254
+ <div className={css.row}>
255
+ <dt className={css.label}>{t('memoryPending')}</dt>
256
+ <dd className={css.value}>{t('memoryPendingValue', { tokens: String(session.pendingTokens) })}</dd>
257
+ </div>
258
+ <div className={css.row}>
259
+ <dt className={css.label}>{t('memorySessionNode')}</dt>
260
+ <dd className={css.value}>
261
+ {session.sessionNode ? t('memorySessionNodeYes') : t('memorySessionNodeNo')}
262
+ </dd>
263
+ </div>
264
+ <div className={css.row}>
265
+ <dt className={css.label}>{t('memoryBelongTo')}</dt>
266
+ <dd className={css.value}>{t('memoryBelongToValue', { count: String(session.belongTo) })}</dd>
267
+ </div>
268
+ {session.deferred > 0 ? (
269
+ <div className={css.row}>
270
+ <dt className={css.label}>{t('memoryDeferred')}</dt>
271
+ <dd className={css.value}>{String(session.deferred)}</dd>
272
+ </div>
273
+ ) : null}
274
+ </dl>
275
+ </section>
276
+ )}
277
+
278
+ {session !== undefined && (session.failed > 0 || session.failedTasks.length > 0) ? (
279
+ <section className={css.group} aria-label={t('memoryFailedTitle')}>
280
+ <h3 className={css.groupTitle}>{t('memoryFailedTitle')}</h3>
281
+ <ul className={css.failures}>
282
+ {session.failedTasks.map((task, index) => (
283
+ <li key={`${task.kind}-${String(index)}`} className={css.failure}>
284
+ <span className={css.failureKind}>
285
+ {t('memoryFailedItem', { kind: task.kind, attempts: String(task.attempts) })}
286
+ </span>
287
+ {task.error !== '' ? <span className={css.failureError}>{task.error}</span> : null}
288
+ </li>
289
+ ))}
290
+ {session.failedTasks.length === 0 ? <li className={css.failure}>{session.error}</li> : null}
291
+ </ul>
292
+ </section>
293
+ ) : null}
294
+
295
+ {content !== undefined && content.error !== '' ? (
296
+ <section className={css.group} aria-label={t('memoryContentTitle')}>
297
+ <h3 className={css.groupTitle}>{t('memoryContentTitle')}</h3>
298
+ <p className={css.error} role="status">{t('memoryContentFailed', { message: content.error })}</p>
299
+ </section>
300
+ ) : null}
301
+
302
+ {content !== undefined ? (
303
+ <>
304
+ <section className={css.group} aria-label={t('memorySummariesTitle')}>
305
+ <h3 className={css.groupTitle}>
306
+ {t('memorySummariesTitle')}
307
+ <span className={css.count}>
308
+ {t('memoryCount', { shown: String(summaries.length), total: String(content.summaryCount) })}
309
+ </span>
310
+ </h3>
311
+ {summaries.length === 0
312
+ ? <p className={css.status}>{t('memoryNoSummaries')}</p>
313
+ : renderEntries(summaries, 'summary')}
314
+ </section>
315
+
316
+ <section className={css.group} aria-label={t('memoryWorkUnitsTitle')}>
317
+ <h3 className={css.groupTitle}>
318
+ {t('memoryWorkUnitsTitle')}
319
+ <span className={css.count}>
320
+ {t('memoryCount', { shown: String(workUnits.length), total: String(content.workUnitCount) })}
321
+ </span>
322
+ </h3>
323
+ {workUnits.length === 0
324
+ ? <p className={css.status}>{t('memoryNoWorkUnits')}</p>
325
+ : renderEntries(workUnits, 'workUnit')}
326
+ </section>
327
+
328
+ {content.truncated ? <p className={css.hint}>{t('memoryTruncated')}</p> : null}
329
+ <p className={css.hint}>{t('memoryHint')}</p>
330
+ </>
331
+ ) : null}
332
+ </div>
333
+ </div>
334
+ )
335
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Browser half of dsh-hypatia-auto-memory: contributes a settings card under
3
- * Settings → Plugins, keyed by the `hypatia-auto-memory` namespace.
3
+ * Settings → Plugins, keyed by the `hypatia-auto-memory` namespace, and a
4
+ * per-session Memory tab beside the conversation's Chat and Trajectory tabs.
4
5
  *
5
6
  * The Host plugin already registers the schema and namespace; this half only
6
- * renders the form and routes edits through `ctx.settingsScope`.
7
+ * renders the form and routes edits through `ctx.settingsScope`. The Memory tab
8
+ * reads the Host's read-only status namespace (see `src/memory-status.js`).
7
9
  */
8
10
 
9
11
  import type { Context } from '@deepseek-ai/cordis'
@@ -15,10 +17,15 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
15
17
  import type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
16
18
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
17
19
  import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
20
+ // Type-only: the 'conversation.view' SlotMap row is declared by the conversation
21
+ // shell, so the registration below only type-checks with its merge in the program.
22
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
18
23
  import { createElement } from 'react'
19
24
  import { SettingsCard } from './SettingsCard'
20
25
  import type { ConfigShape } from './SettingsCard'
21
26
  import type { LoadConsolidationModelCatalog } from './consolidation-models'
27
+ import { MemoryView } from './MemoryView'
28
+ import { fetchMemory } from './memory-client'
22
29
  import { readShelfInventory, type LoadShelfInventory } from './shelves'
23
30
  import { en, NS, zh } from './locales'
24
31
 
@@ -99,8 +106,31 @@ export function apply(ctx: Context): void {
99
106
  ),
100
107
  )
101
108
 
109
+ // The Memory tab's data: one same-origin request to the route this plugin's
110
+ // Host half registers (see `src/memory-api.js`), parameterized by the session
111
+ // the tab is bound to — which is why it is a route and not the settings
112
+ // descriptor read the shelf listing uses.
113
+ // The tab label reads through a bound translate so it follows the active
114
+ // locale without re-registration, exactly as ui-trajectory's does.
115
+ const t = locale.bind(NS)
116
+ const unregisterMemoryView = ctx.slots.inject('conversation.view', () =>
117
+ ctx.slots.register(
118
+ {
119
+ name: 'conversation.view',
120
+ id: 'memory',
121
+ // After Chat (0) and Trajectory (10).
122
+ order: 20,
123
+ locale: NS,
124
+ label: () => t('viewMemory'),
125
+ inject: () => ({ fetch: fetchMemory }),
126
+ },
127
+ MemoryView,
128
+ ),
129
+ )
130
+
102
131
  ctx.effect(() => () => {
103
132
  unregister()
133
+ unregisterMemoryView()
104
134
  releaseApply()
105
135
  })
106
136
  }