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.
- package/README.md +68 -7
- package/README.zh-CN.md +54 -9
- package/lib/client.js +720 -40
- package/lib/client.js.map +1 -1
- package/package.json +3 -2
- package/src/cascade.js +29 -4
- package/src/client/MemoryView.module.css +312 -0
- package/src/client/MemoryView.tsx +335 -0
- package/src/client/index.tsx +32 -2
- package/src/client/locales.ts +80 -0
- package/src/client/memory-client.ts +246 -0
- package/src/consolidator.js +159 -37
- package/src/index.js +74 -6
- package/src/memory-api.js +557 -0
- package/src/memory-status.js +167 -0
- package/src/model-log.js +246 -0
|
@@ -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
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -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
|
}
|