@try-works/dsh-memory 0.1.19 → 0.1.20
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/LICENSE +21 -21
- package/README.md +585 -585
- package/STATUS.md +287 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/src/SYSTEM.template.md +311 -311
- package/src/capture.js +242 -242
- package/src/client/index.js +886 -886
- package/src/config.js +57 -57
- package/src/consolidate-run.js +132 -132
- package/src/consolidate.js +330 -330
- package/src/context.js +88 -88
- package/src/decisions.js +106 -106
- package/src/defaults.js +93 -93
- package/src/digest.js +255 -255
- package/src/entry.js +179 -179
- package/src/eval.js +72 -72
- package/src/files.js +60 -60
- package/src/fixture.js +56 -56
- package/src/forget.js +71 -71
- package/src/freshness.js +65 -65
- package/src/fts.js +166 -166
- package/src/git.js +53 -53
- package/src/history.js +226 -226
- package/src/host.js +866 -866
- package/src/index.js +115 -94
- package/src/inject.js +108 -108
- package/src/layers.js +178 -178
- package/src/ledger.js +19 -19
- package/src/map.js +184 -184
- package/src/models.js +136 -136
- package/src/queue.js +602 -602
- package/src/rank.js +31 -31
- package/src/recover.js +91 -91
- package/src/refresh.js +80 -80
- package/src/remember.js +58 -58
- package/src/rerank.js +90 -90
- package/src/search.js +75 -75
- package/src/select.js +57 -57
- package/src/session.js +60 -60
- package/src/state.js +240 -240
- package/src/store.js +128 -128
- package/src/summarize.js +195 -195
- package/src/system-doc.js +171 -171
- package/src/tasks.js +617 -617
- package/src/telemetry.js +122 -122
- package/src/tools.js +828 -818
- package/src/working.js +288 -288
- package/src/workspace-key.js +68 -68
- package/src/workspace.js +87 -87
package/src/client/index.js
CHANGED
|
@@ -1,886 +1,886 @@
|
|
|
1
|
-
// dsh-memory browser half: the Settings -> Memory page.
|
|
2
|
-
//
|
|
3
|
-
// Plain JavaScript by necessity: DSH's client module system serves this file to the
|
|
4
|
-
// page as a lazy-CJS bundle, so it IS the shipped artifact. The factory receives the
|
|
5
|
-
// browser module table's require and returns { inject, apply }.
|
|
6
|
-
//
|
|
7
|
-
// Only 'react' is requested. Harness client packages are deliberately not imported:
|
|
8
|
-
// they change without notice, plain JS gets no type check against them, and a throwing
|
|
9
|
-
// component blanks the slot entry. Controls and styles are written here, using only
|
|
10
|
-
// theme tokens that actually exist on the shipped settings pages -- a token that does
|
|
11
|
-
// not exist is an invalid declaration, the browser drops it, and the page degrades
|
|
12
|
-
// into undifferentiated prose. The page mirrors a shipped settings section: the same
|
|
13
|
-
// column width, card chrome, type scale and control metrics.
|
|
14
|
-
window.__ModuleLoader__.load({
|
|
15
|
-
// Must be the package name: the web client loader looks up this exact id.
|
|
16
|
-
// The Cordis entry id (`dsh-memory`) is a different string used only for settings.
|
|
17
|
-
id: '@try-works/dsh-memory',
|
|
18
|
-
factory: (require) => {
|
|
19
|
-
var module = { exports: {} }
|
|
20
|
-
var exports = module.exports
|
|
21
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
22
|
-
|
|
23
|
-
const react = require('react')
|
|
24
|
-
|
|
25
|
-
/** Page styles, prefixed to avoid colliding with host styles. */
|
|
26
|
-
const CSS = [
|
|
27
|
-
'.dsm-page {',
|
|
28
|
-
' display: flex;',
|
|
29
|
-
' flex-direction: column;',
|
|
30
|
-
' gap: 12px;',
|
|
31
|
-
' max-width: 720px;',
|
|
32
|
-
' color: var(--dsw-alias-label-primary);',
|
|
33
|
-
'}',
|
|
34
|
-
'.dsm-title {',
|
|
35
|
-
' margin: 0;',
|
|
36
|
-
' font-size: 16px;',
|
|
37
|
-
' line-height: 24px;',
|
|
38
|
-
' font-weight: 500;',
|
|
39
|
-
' color: var(--dsw-alias-label-primary);',
|
|
40
|
-
'}',
|
|
41
|
-
'.dsm-intro {',
|
|
42
|
-
' margin: 0;',
|
|
43
|
-
' font-size: 14px;',
|
|
44
|
-
' line-height: 22px;',
|
|
45
|
-
' color: var(--dsw-alias-label-tertiary);',
|
|
46
|
-
'}',
|
|
47
|
-
'.dsm-card {',
|
|
48
|
-
' border: 0.5px solid var(--dsw-alias-settings-card-stroke);',
|
|
49
|
-
' background: var(--dsw-alias-settings-card-fill);',
|
|
50
|
-
' border-radius: var(--dsw-radius-xl);',
|
|
51
|
-
' padding: 12px 14px;',
|
|
52
|
-
' display: flex;',
|
|
53
|
-
' flex-direction: column;',
|
|
54
|
-
' gap: 2px;',
|
|
55
|
-
'}',
|
|
56
|
-
'.dsm-field {',
|
|
57
|
-
' display: flex;',
|
|
58
|
-
' flex-direction: column;',
|
|
59
|
-
' gap: 4px;',
|
|
60
|
-
' padding: 6px 0;',
|
|
61
|
-
'}',
|
|
62
|
-
'.dsm-field-label {',
|
|
63
|
-
' font-size: 12px;',
|
|
64
|
-
' line-height: 18px;',
|
|
65
|
-
' color: var(--dsw-alias-label-secondary);',
|
|
66
|
-
'}',
|
|
67
|
-
'.dsm-field-help {',
|
|
68
|
-
' margin: 0;',
|
|
69
|
-
' font-size: 12px;',
|
|
70
|
-
' line-height: 18px;',
|
|
71
|
-
' color: var(--dsw-alias-label-tertiary);',
|
|
72
|
-
'}',
|
|
73
|
-
'.dsm-input,',
|
|
74
|
-
'.dsm-select {',
|
|
75
|
-
' box-sizing: border-box;',
|
|
76
|
-
' width: 100%;',
|
|
77
|
-
' height: 32px;',
|
|
78
|
-
' padding: 0 10px;',
|
|
79
|
-
' border: 1px solid var(--dsw-alias-border-l2);',
|
|
80
|
-
' border-radius: var(--dsw-radius-sm);',
|
|
81
|
-
' background: var(--dsw-alias-bg-layer-1);',
|
|
82
|
-
' color: var(--dsw-alias-label-primary);',
|
|
83
|
-
' font-family: var(--ds-font-family-code);',
|
|
84
|
-
' font-size: 12px;',
|
|
85
|
-
'}',
|
|
86
|
-
'.dsm-select {',
|
|
87
|
-
' font-family: inherit;',
|
|
88
|
-
' cursor: pointer;',
|
|
89
|
-
'}',
|
|
90
|
-
'.dsm-input:focus-visible,',
|
|
91
|
-
'.dsm-select:focus-visible {',
|
|
92
|
-
' outline: 2px solid var(--dsw-alias-brand-primary);',
|
|
93
|
-
' outline-offset: 1px;',
|
|
94
|
-
'}',
|
|
95
|
-
'.dsm-input:disabled,',
|
|
96
|
-
'.dsm-select:disabled {',
|
|
97
|
-
' opacity: 0.6;',
|
|
98
|
-
' cursor: default;',
|
|
99
|
-
'}',
|
|
100
|
-
'.dsm-actions {',
|
|
101
|
-
' display: flex;',
|
|
102
|
-
' align-items: center;',
|
|
103
|
-
' gap: 10px;',
|
|
104
|
-
' padding-top: 10px;',
|
|
105
|
-
'}',
|
|
106
|
-
'.dsm-button {',
|
|
107
|
-
' height: 30px;',
|
|
108
|
-
' padding: 0 14px;',
|
|
109
|
-
' border: none;',
|
|
110
|
-
' border-radius: var(--dsw-radius-sm);',
|
|
111
|
-
' background: var(--dsw-alias-button-primary-fill);',
|
|
112
|
-
' color: var(--dsw-alias-label-primary-inverted);',
|
|
113
|
-
' font-family: inherit;',
|
|
114
|
-
' font-size: 13px;',
|
|
115
|
-
' cursor: pointer;',
|
|
116
|
-
'}',
|
|
117
|
-
'.dsm-button:hover:not(:disabled) {',
|
|
118
|
-
' background: var(--dsw-alias-button-primary-hover);',
|
|
119
|
-
'}',
|
|
120
|
-
'.dsm-button:disabled {',
|
|
121
|
-
' opacity: 0.6;',
|
|
122
|
-
' cursor: default;',
|
|
123
|
-
'}',
|
|
124
|
-
'.dsm-button:focus-visible {',
|
|
125
|
-
' outline: 2px solid var(--dsw-alias-brand-primary);',
|
|
126
|
-
' outline-offset: 1px;',
|
|
127
|
-
'}',
|
|
128
|
-
'.dsm-status {',
|
|
129
|
-
' margin: 0;',
|
|
130
|
-
' font-size: 12px;',
|
|
131
|
-
' line-height: 18px;',
|
|
132
|
-
' color: var(--dsw-alias-label-tertiary);',
|
|
133
|
-
'}',
|
|
134
|
-
'.dsm-status-ok {',
|
|
135
|
-
' color: var(--dsw-alias-state-success-primary);',
|
|
136
|
-
'}',
|
|
137
|
-
'.dsm-status-error {',
|
|
138
|
-
' color: var(--dsw-alias-state-error-primary);',
|
|
139
|
-
'}',
|
|
140
|
-
'.dsm-note {',
|
|
141
|
-
' margin: 0;',
|
|
142
|
-
' font-size: 12px;',
|
|
143
|
-
' line-height: 18px;',
|
|
144
|
-
' color: var(--dsw-alias-label-tertiary);',
|
|
145
|
-
'}',
|
|
146
|
-
'.dsm-code {',
|
|
147
|
-
' font-family: var(--ds-font-family-code);',
|
|
148
|
-
'}',
|
|
149
|
-
].join('\n')
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// ---------------------------------------------------------------------------------
|
|
153
|
-
// Memory map: the inline face of the memory_map tool.
|
|
154
|
-
//
|
|
155
|
-
// It renders the tool result's structured `meta`, which is the value the tool returned.
|
|
156
|
-
// That is not a shortcut, it is the only honest source: a browser half cannot ask the
|
|
157
|
-
// host for anything (dsh.client has no data channel, remotes are an allowlisted
|
|
158
|
-
// application assembly, and the plugin route serves only client JS), so the tool result
|
|
159
|
-
// IS the channel. Recomputing the numbers here would invent a second source that could
|
|
160
|
-
// disagree with the one the agent was shown, and the disagreement would be invisible.
|
|
161
|
-
// ---------------------------------------------------------------------------------
|
|
162
|
-
const INK = '#1A1A18'
|
|
163
|
-
const MUTED = '#6E6E6B'
|
|
164
|
-
const FAINT = '#8A8A87'
|
|
165
|
-
const LINE = '#EFEFEC'
|
|
166
|
-
const RUST = '#8A3F1E'
|
|
167
|
-
const COPPER = '#C77E4A'
|
|
168
|
-
const QUIET = '#6B6B68'
|
|
169
|
-
const GEIST = 'Geist, system-ui, -apple-system, sans-serif'
|
|
170
|
-
const MONO = 'Geist Mono, ui-monospace, SFMono-Regular, monospace'
|
|
171
|
-
// Deepest tone for the largest layer, so the ramp itself encodes proportion and seven
|
|
172
|
-
// layers need seven TONES rather than seven hues.
|
|
173
|
-
const TONES = ['#2A3644', '#3A4757', '#4C5A6B', '#64717F', '#808C99', '#9DA8B3', '#BCC4CC']
|
|
174
|
-
|
|
175
|
-
/** Thousands separators, so a four-digit count is readable at a glance. */
|
|
176
|
-
function fmt(value) {
|
|
177
|
-
return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('en-US') : String(value)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** How a verdict is written and coloured: the WORD carries the meaning, colour agrees. */
|
|
181
|
-
function verdictStyle(verdict) {
|
|
182
|
-
if (verdict === 'stale') return { color: RUST, weight: 500 }
|
|
183
|
-
if (verdict === 'aging') return { color: COPPER, weight: 500 }
|
|
184
|
-
if (verdict === 'unverified' || verdict === 'empty') return { color: QUIET, weight: 500 }
|
|
185
|
-
return { color: QUIET, weight: 400 }
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function box(style, children) { return react.createElement('div', { style: style }, children) }
|
|
189
|
-
|
|
190
|
-
/** The stacked layer bar. Each width is a count as a fraction of the total. */
|
|
191
|
-
function LayerBar(props) {
|
|
192
|
-
const total = props.layers.reduce(function (sum, l) { return sum + l.count }, 0)
|
|
193
|
-
if (!total) return null
|
|
194
|
-
const segs = props.layers.map(function (layer, i) {
|
|
195
|
-
return react.createElement('div', {
|
|
196
|
-
key: layer.name,
|
|
197
|
-
style: { width: (layer.count / total * 100).toFixed(3) + '%', flexShrink: 0, background: TONES[i % TONES.length] },
|
|
198
|
-
})
|
|
199
|
-
})
|
|
200
|
-
return box({ display: 'flex', height: props.height || '26px', borderRadius: '7px', overflow: 'hidden' }, segs)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** The legend: fixed dot and name slots, so every count shares one vertical lane. */
|
|
204
|
-
function Legend(props) {
|
|
205
|
-
const items = props.layers.map(function (layer, i) {
|
|
206
|
-
return box({ key: layer.name, display: 'flex', alignItems: 'center', gap: '7px' }, [
|
|
207
|
-
react.createElement('div', { key: 'd', style: { width: '6px', height: '6px', flexShrink: 0, borderRadius: '2px', background: TONES[i % TONES.length] } }),
|
|
208
|
-
react.createElement('div', { key: 'n', style: { fontFamily: GEIST, fontSize: '11px', color: '#3A3A38', width: '62px', flexShrink: 0 } }, layer.name),
|
|
209
|
-
react.createElement('div', { key: 'c', style: { fontFamily: MONO, fontSize: '10px', color: QUIET } }, fmt(layer.count)),
|
|
210
|
-
])
|
|
211
|
-
})
|
|
212
|
-
const half = Math.ceil(items.length / 2)
|
|
213
|
-
return box({ display: 'flex', flexDirection: 'column', gap: '7px' }, [
|
|
214
|
-
box({ key: 'a', display: 'flex', gap: '22px' }, items.slice(0, half)),
|
|
215
|
-
box({ key: 'b', display: 'flex', gap: '22px' }, items.slice(half)),
|
|
216
|
-
])
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function Stat(props) {
|
|
220
|
-
return box({ display: 'flex', alignItems: 'baseline', gap: '6px' }, [
|
|
221
|
-
react.createElement('div', { key: 'n', style: { fontFamily: MONO, fontSize: props.size || '15px', color: props.color || INK } }, props.value),
|
|
222
|
-
react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', color: MUTED } }, props.label),
|
|
223
|
-
])
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function Chip(props) {
|
|
227
|
-
return react.createElement('div', {
|
|
228
|
-
style: { padding: '3px 8px', borderRadius: '5px', fontFamily: MONO, fontSize: '10px', background: props.hot ? '#FAEDE6' : '#F4F4F2', color: props.hot ? RUST : '#3A3A38' },
|
|
229
|
-
}, props.text)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Section label, with an optional right-hand note. */
|
|
233
|
-
function sectionLabel(text, note) {
|
|
234
|
-
return box({ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }, [
|
|
235
|
-
react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', fontWeight: 500, letterSpacing: '0.08em', color: MUTED } }, text),
|
|
236
|
-
note ? react.createElement('div', { key: 'r', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT } }, note) : null,
|
|
237
|
-
])
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** The three-bar mark: a miniature of the layer bar, so the card is identifiable at a glance. */
|
|
241
|
-
function Glyph() {
|
|
242
|
-
const bar = function (key, width, tone) {
|
|
243
|
-
return react.createElement('div', { key: key, style: { height: '2px', width: width, borderRadius: '1px', background: tone } })
|
|
244
|
-
}
|
|
245
|
-
return box({ width: '14px', height: '14px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '2px', justifyContent: 'center' }, [
|
|
246
|
-
bar('a', '14px', TONES[0]), bar('b', '10px', TONES[3]), bar('c', '6px', TONES[5]),
|
|
247
|
-
])
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** The RECEIVED panel: what memory put in front of this session, with its freshness. */
|
|
251
|
-
function ReceivedPanel(props) {
|
|
252
|
-
const sections = (props.received && props.received.sections) || []
|
|
253
|
-
if (!sections.length) return null
|
|
254
|
-
const rows = sections.map(function (section) {
|
|
255
|
-
const style = verdictStyle(section.verdict)
|
|
256
|
-
return box({ key: section.name, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }, [
|
|
257
|
-
react.createElement('div', { key: 'n', style: { fontFamily: GEIST, fontSize: '12px', color: '#3A3A38' } }, section.name),
|
|
258
|
-
react.createElement('div', { key: 'v', style: { fontFamily: MONO, fontSize: '11px', color: style.color, fontWeight: style.weight } }, section.marker ? section.marker.replace(/[\[\]]/g, '').trim() : 'fresh'),
|
|
259
|
-
])
|
|
260
|
-
})
|
|
261
|
-
return box({ display: 'flex', flexDirection: 'column', gap: '10px' }, [
|
|
262
|
-
sectionLabel('RECEIVED', fmt(props.received.chars) + ' chars'),
|
|
263
|
-
box({ key: 'rows', display: 'flex', flexDirection: 'column', gap: '7px' }, rows),
|
|
264
|
-
])
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** The WRITTEN panel: what THIS session recorded, by layer and by entry. */
|
|
268
|
-
function WrittenPanel(props) {
|
|
269
|
-
const written = props.written
|
|
270
|
-
if (!written || !written.total) return null
|
|
271
|
-
const chips = Object.keys(written.byLayer).map(function (layer) {
|
|
272
|
-
return react.createElement(Chip, { key: layer, text: layer + ' ' + written.byLayer[layer] })
|
|
273
|
-
})
|
|
274
|
-
const entries = written.entries.map(function (entry) {
|
|
275
|
-
return box({ key: entry.id, display: 'flex', alignItems: 'baseline' }, [
|
|
276
|
-
react.createElement('div', { key: 'i', style: { fontFamily: MONO, fontSize: '11px', color: QUIET, width: '186px', flexShrink: 0 } }, entry.id),
|
|
277
|
-
react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT, width: '72px', flexShrink: 0 } }, entry.layer),
|
|
278
|
-
react.createElement('div', { key: 'h', style: { fontFamily: GEIST, fontSize: '12px', color: '#3A3A38' } }, entry.heading),
|
|
279
|
-
])
|
|
280
|
-
})
|
|
281
|
-
const more = written.total > written.entries.length
|
|
282
|
-
return box({ display: 'flex', flexDirection: 'column', gap: '12px' }, [
|
|
283
|
-
sectionLabel('WRITTEN', fmt(written.total) + ' this session'),
|
|
284
|
-
box({ key: 'c', display: 'flex', gap: '5px' }, chips),
|
|
285
|
-
box({ key: 'e', display: 'flex', flexDirection: 'column', gap: '7px' }, entries),
|
|
286
|
-
more ? react.createElement('div', { key: 'm', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT } }, '+ ' + fmt(written.total - written.entries.length) + ' more') : null,
|
|
287
|
-
])
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* The memory_map tool row.
|
|
292
|
-
*
|
|
293
|
-
* Every value comes from the result's own `meta`. Nothing is fetched and nothing is
|
|
294
|
-
* inferred from the rendered text: when the snapshot is absent the card says so, rather
|
|
295
|
-
* than drawing a shape that merely looks like data.
|
|
296
|
-
*/
|
|
297
|
-
function MemoryMapCard(props) {
|
|
298
|
-
const block = props ? props.block : null
|
|
299
|
-
const meta = block && typeof block.meta === 'object' && block.meta !== null ? block.meta : null
|
|
300
|
-
const store = meta && meta.store ? meta.store : null
|
|
301
|
-
const session = meta && meta.session ? meta.session : null
|
|
302
|
-
const summary = store ? fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes' : ''
|
|
303
|
-
const headStyle = { height: '46px', flexShrink: 0, display: 'flex', alignItems: 'center', gap: '10px', padding: '0 16px', borderBottom: '1px solid ' + LINE }
|
|
304
|
-
const head = box(headStyle, [
|
|
305
|
-
react.createElement(Glyph, { key: 'g' }),
|
|
306
|
-
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '13px', fontWeight: 500, color: INK } }, 'Memory map'),
|
|
307
|
-
react.createElement('div', { key: 's', style: { flex: 1 } }),
|
|
308
|
-
react.createElement('div', { key: 'c', style: { fontFamily: GEIST, fontSize: '12px', color: MUTED } }, summary),
|
|
309
|
-
])
|
|
310
|
-
const body = []
|
|
311
|
-
if (store) {
|
|
312
|
-
body.push(box({ key: 'l', display: 'flex', flexDirection: 'column', gap: '12px' }, [
|
|
313
|
-
react.createElement(LayerBar, { key: 'bar', layers: store.layers }),
|
|
314
|
-
react.createElement(Legend, { key: 'leg', layers: store.layers }),
|
|
315
|
-
]))
|
|
316
|
-
const attention = (store.queue && store.queue.attention) || []
|
|
317
|
-
const chips = attention.slice(0, 3).map(function (row) {
|
|
318
|
-
return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true })
|
|
319
|
-
})
|
|
320
|
-
if (store.queue && store.queue.waiting) chips.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }))
|
|
321
|
-
const stats = [
|
|
322
|
-
react.createElement(Stat, { key: 'a', value: fmt(store.freshness.fresh), label: 'fresh' }),
|
|
323
|
-
store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
|
|
324
|
-
store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
|
|
325
|
-
]
|
|
326
|
-
const footStyle = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid ' + LINE, paddingTop: '13px', gap: '16px' }
|
|
327
|
-
body.push(box({ key: 'f', display: 'flex', flexDirection: 'column' }, [
|
|
328
|
-
box(Object.assign({ key: 'row' }, footStyle), [
|
|
329
|
-
box({ key: 's', display: 'flex', gap: '18px' }, stats),
|
|
330
|
-
box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips),
|
|
331
|
-
]),
|
|
332
|
-
]))
|
|
333
|
-
}
|
|
334
|
-
if (session && session.received) body.push(react.createElement(ReceivedPanel, { key: 'r', received: session.received }))
|
|
335
|
-
if (session && session.written) body.push(react.createElement(WrittenPanel, { key: 'w', written: session.written }))
|
|
336
|
-
if (!body.length) body.push(react.createElement('div', { key: 'none', style: { fontFamily: GEIST, fontSize: '12px', color: MUTED } }, 'No snapshot was recorded for this call.'))
|
|
337
|
-
const cardStyle = { maxWidth: '760px', border: '1px solid #E7E7E4', borderRadius: '12px', background: '#FFFFFF', display: 'flex', flexDirection: 'column' }
|
|
338
|
-
const bodyStyle = { padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' }
|
|
339
|
-
return box(cardStyle, [head, box(Object.assign({ key: 'b' }, bodyStyle), body)]);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* The Memory tab: this session's memory activity, in one place.
|
|
344
|
-
*
|
|
345
|
-
* Its data is the newest memory snapshot in this conversation, from either of two routes.
|
|
346
|
-
*
|
|
347
|
-
* The one that matters is memory/map: a browser half cannot ask the host for anything, so the
|
|
348
|
-
* session record is the only channel - and rather than wait for someone to call a tool, the HOST
|
|
349
|
-
* publishes the map into that record itself. SessionEventMap is merge-extensible by design and a
|
|
350
|
-
* log-only event never reaches the model surface, so this is the harness's own answer for a view
|
|
351
|
-
* that must report state nobody asked about.
|
|
352
|
-
*
|
|
353
|
-
* A memory_map TOOL RESULT is still honoured, newest wins, because a session whose snapshots
|
|
354
|
-
* predate the publisher would otherwise draw nothing. That route carries its value on `meta` -
|
|
355
|
-
* the object ui-chat's conversation-nodes/tool.ts forwards as `meta: match.event.data.meta`.
|
|
356
|
-
*
|
|
357
|
-
* READING IT: the session's contiguous event window is published as the documented
|
|
358
|
-
* public member SessionBinding.eventSource (ObservableSnapshot<SessionEventWindow>, see
|
|
359
|
-
* api/session-controller/src/client/contract/events.ts), reached through
|
|
360
|
-
* ctx.sessions.binding(sessionId). That is the same source the Chat and Trajectory tabs
|
|
361
|
-
* are assembled from, so the tab adds no new channel and cannot disagree with them.
|
|
362
|
-
* Sessions is reached with ctx.get() because it is a reachable service, not a required
|
|
363
|
-
* one: making it an inject dependency would hold the whole plugin - Settings included -
|
|
364
|
-
* pending behind a service the settings page does not need.
|
|
365
|
-
*/
|
|
366
|
-
const MEMORY_STATE_NONE = { kind: 'none', meta: null, seq: null, time: null }
|
|
367
|
-
const MEMORY_STATE_UNAVAILABLE = { kind: 'unavailable', meta: null, seq: null, time: null }
|
|
368
|
-
// Distinct from NONE on purpose. PTC mode dispatches a tool from inside a run_code program and
|
|
369
|
-
// records tool/ptc-dispatch, which carries the rendered text but NO structured value, so there
|
|
370
|
-
// is genuinely nothing to draw - and telling the reader 'memory_map has not run here' would be
|
|
371
|
-
// false.
|
|
372
|
-
const MEMORY_STATE_NESTED = { kind: 'nested', meta: null, seq: null, time: null }
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Find the newest memory snapshot in the window and say what it carried.
|
|
376
|
-
*
|
|
377
|
-
* Two routes, newest wins. The host publishes a memory/map event on its own cadence, which is
|
|
378
|
-
* what lets the tab draw with no tool call. A memory_map tool/result is also honoured: a
|
|
379
|
-
* tool/result event names its call only by id, so call events are indexed first, and a result
|
|
380
|
-
* whose pair has been paged out is still recognised by the shape of its own value, since
|
|
381
|
-
* nothing else produces { scope, generatedAt }.
|
|
382
|
-
*/
|
|
383
|
-
function readMemoryState(window) {
|
|
384
|
-
const entries = window && Array.isArray(window.entries) ? window.entries : null
|
|
385
|
-
if (!entries) return MEMORY_STATE_NONE
|
|
386
|
-
const calls = Object.create(null)
|
|
387
|
-
let nested = false
|
|
388
|
-
for (let i = 0; i < entries.length; i += 1) {
|
|
389
|
-
const entry = entries[i]
|
|
390
|
-
const event = entry && entry.type === 'event' ? entry.event : null
|
|
391
|
-
if (event && event.type === 'tool/call' && event.data && event.data.name === 'memory_map') calls[event.data.callId] = true
|
|
392
|
-
// A nested dispatch is a real call the agent made; it simply has no structured result.
|
|
393
|
-
if (event && event.type === 'tool/ptc-dispatch' && event.data && event.data.name === 'memory_map') nested = true
|
|
394
|
-
}
|
|
395
|
-
// Newest first: the tab describes the session as it stands now, not as it first was.
|
|
396
|
-
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
397
|
-
const entry = entries[i]
|
|
398
|
-
const event = entry && entry.type === 'event' ? entry.event : null
|
|
399
|
-
if (!event) continue
|
|
400
|
-
// The host PUBLISHES the map into the log itself, so the tab fills in on its own and needs
|
|
401
|
-
// nobody to call a tool first. This is the route that matters; the tool/result route below
|
|
402
|
-
// is kept so a session whose snapshots predate the publisher still draws.
|
|
403
|
-
if (event.type === 'memory/map') {
|
|
404
|
-
const published = event.data
|
|
405
|
-
if (published === null || typeof published !== 'object') continue
|
|
406
|
-
if (typeof published.generatedAt !== 'string' || typeof published.scope !== 'string') continue
|
|
407
|
-
return { kind: 'ready', meta: published, seq: event.seq, time: event.time }
|
|
408
|
-
}
|
|
409
|
-
if (event.type !== 'tool/result') continue
|
|
410
|
-
const data = event.data || {}
|
|
411
|
-
const message = data.message || null
|
|
412
|
-
const callId = message && message.source ? message.source.callId : undefined
|
|
413
|
-
const meta = data.meta
|
|
414
|
-
const shaped = meta !== null && typeof meta === 'object'
|
|
415
|
-
&& typeof meta.generatedAt === 'string' && typeof meta.scope === 'string'
|
|
416
|
-
if (calls[callId] !== true && !shaped) continue
|
|
417
|
-
// A failed call is not a snapshot: it is the absence of one, and it is said so.
|
|
418
|
-
if (message && message.isError === true) continue
|
|
419
|
-
if (meta === null || typeof meta !== 'object') return { kind: 'empty', meta: null, seq: event.seq, time: event.time }
|
|
420
|
-
return { kind: 'ready', meta: meta, seq: event.seq, time: event.time }
|
|
421
|
-
}
|
|
422
|
-
return nested ? MEMORY_STATE_NESTED : MEMORY_STATE_NONE
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* The tab's observable source over one session's event window.
|
|
427
|
-
*
|
|
428
|
-
* It re-resolves the binding on every read, so a session whose binding materializes
|
|
429
|
-
* after the tab has already rendered still fills in; while no window exists it listens
|
|
430
|
-
* to the session list, which publishes when a generation is retained or opened, so the
|
|
431
|
-
* tab wakes up instead of staying dark until an unrelated re-render. Values are cached by
|
|
432
|
-
* window identity, which keeps getSnapshot referentially stable between publications.
|
|
433
|
-
*/
|
|
434
|
-
function createMemorySource(ctx, sessionId) {
|
|
435
|
-
const listeners = []
|
|
436
|
-
let subscribedTo = null
|
|
437
|
-
let unsubscribe = null
|
|
438
|
-
let cachedWindow = null
|
|
439
|
-
let cachedState = MEMORY_STATE_NONE
|
|
440
|
-
const wake = () => {
|
|
441
|
-
cachedWindow = null
|
|
442
|
-
for (let i = 0; i < listeners.length; i += 1) listeners[i]()
|
|
443
|
-
}
|
|
444
|
-
const eventSourceOf = () => {
|
|
445
|
-
const sessions = ctx.get('sessions')
|
|
446
|
-
if (!sessions || typeof sessions.binding !== 'function') return null
|
|
447
|
-
const binding = sessions.binding(sessionId)
|
|
448
|
-
return binding && binding.eventSource ? binding.eventSource : null
|
|
449
|
-
}
|
|
450
|
-
const attach = (source) => {
|
|
451
|
-
if (unsubscribe) { unsubscribe(); unsubscribe = null }
|
|
452
|
-
subscribedTo = source
|
|
453
|
-
if (source) { unsubscribe = source.subscribe(wake); return }
|
|
454
|
-
const sessions = ctx.get('sessions')
|
|
455
|
-
const list = sessions && sessions.list
|
|
456
|
-
if (list && typeof list.subscribe === 'function' && listeners.length) unsubscribe = list.subscribe(wake)
|
|
457
|
-
}
|
|
458
|
-
const sync = () => {
|
|
459
|
-
const source = eventSourceOf()
|
|
460
|
-
if (listeners.length) { if (source !== subscribedTo) attach(source) }
|
|
461
|
-
else if (subscribedTo) attach(null)
|
|
462
|
-
return source
|
|
463
|
-
}
|
|
464
|
-
return {
|
|
465
|
-
getSnapshot: () => {
|
|
466
|
-
const source = sync()
|
|
467
|
-
if (!source) return MEMORY_STATE_UNAVAILABLE
|
|
468
|
-
const window = source.getSnapshot()
|
|
469
|
-
if (window !== cachedWindow) { cachedWindow = window; cachedState = readMemoryState(window) }
|
|
470
|
-
return cachedState
|
|
471
|
-
},
|
|
472
|
-
subscribe: (listener) => {
|
|
473
|
-
listeners.push(listener)
|
|
474
|
-
sync()
|
|
475
|
-
return () => {
|
|
476
|
-
const index = listeners.indexOf(listener)
|
|
477
|
-
if (index >= 0) listeners.splice(index, 1)
|
|
478
|
-
if (!listeners.length) attach(null)
|
|
479
|
-
}
|
|
480
|
-
},
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/** Selector for the injected memory hook: the source caches by window, so identity holds. */
|
|
485
|
-
function memoryStateOf(value) { return value === undefined ? MEMORY_STATE_NONE : value }
|
|
486
|
-
|
|
487
|
-
/** The sheet the panels sit on: their ink colours are fixed, so the surface must be too. */
|
|
488
|
-
function sheet(key, children) {
|
|
489
|
-
return box({ key: key, border: '1px solid #E7E7E4', borderRadius: '12px', background: '#FFFFFF', padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' }, children)
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** The snapshot's OWN stamp, never the clock: a snapshot taken an hour ago must read so. */
|
|
493
|
-
function whenText(iso) {
|
|
494
|
-
const at = new Date(iso)
|
|
495
|
-
return Number.isNaN(at.getTime()) ? String(iso) : at.toLocaleString()
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* What the tab says when there is nothing to draw.
|
|
500
|
-
*
|
|
501
|
-
* The distinctions matter: 'nothing has been published for this session yet' and 'a call ran but
|
|
502
|
-
* its result carries no snapshot' are different facts, and neither of them is zero entries.
|
|
503
|
-
* None of them is ever fixed by asking the reader to do something - the host publishes on its
|
|
504
|
-
* own, so an empty tab is a statement about the session, not a task for the user.
|
|
505
|
-
*/
|
|
506
|
-
function emptyMemoryCopy(state) {
|
|
507
|
-
if (state.kind === 'unavailable') {
|
|
508
|
-
return {
|
|
509
|
-
title: 'The session record is not attached yet',
|
|
510
|
-
lines: [
|
|
511
|
-
'This tab reads memory from the conversation it belongs to - the event window of the open session - and that window is not available here.',
|
|
512
|
-
'Settings, Memory still reads the live store through the host, so the store itself stays visible either way.',
|
|
513
|
-
],
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
if (state.kind === 'empty') {
|
|
517
|
-
return {
|
|
518
|
-
title: 'The last memory_map result carried no snapshot',
|
|
519
|
-
lines: [
|
|
520
|
-
'The call is part of this session, but no structured value is attached to its result: a call that failed, or one whose result was replaced while the context was compacted.',
|
|
521
|
-
'The host publishes a fresh snapshot on its own cadence, so this clears itself.',
|
|
522
|
-
],
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
if (state.kind === 'nested') {
|
|
526
|
-
return {
|
|
527
|
-
title: 'memory_map ran here, but not as a readable result',
|
|
528
|
-
lines: [
|
|
529
|
-
'This session called memory_map from inside a run_code program. Those calls are recorded as nested dispatches, which keep the rendered text and not the structured value this tab draws.',
|
|
530
|
-
'Calling memory_map directly - scope "session" or "all" - records a result the panels can read.',
|
|
531
|
-
],
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return {
|
|
535
|
-
title: 'Nothing published for this session yet',
|
|
536
|
-
lines: [
|
|
537
|
-
'This tab draws the memory snapshot the host publishes into this conversation as it runs, so nothing here has to be asked for.',
|
|
538
|
-
'Nothing has been published for this session yet. It arrives with the first turn and refreshes as the store changes.',
|
|
539
|
-
],
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/** The honest empty state: prose that says what is missing, never a shape that reads as data. */
|
|
544
|
-
function MemoryEmpty(props) {
|
|
545
|
-
const copy = emptyMemoryCopy(props.state)
|
|
546
|
-
const lines = copy.lines.map(function (line, index) {
|
|
547
|
-
return react.createElement('div', { key: String(index), style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: MUTED } }, line)
|
|
548
|
-
})
|
|
549
|
-
return sheet('empty', [
|
|
550
|
-
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '13px', fontWeight: 500, color: INK } }, copy.title),
|
|
551
|
-
box({ key: 'b', display: 'flex', flexDirection: 'column', gap: '8px' }, lines),
|
|
552
|
-
])
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/** The panels, drawn from the snapshot memory_map actually returned and nothing else. */
|
|
556
|
-
function MemoryPanels(props) {
|
|
557
|
-
const meta = props.meta
|
|
558
|
-
const store = meta.store || null
|
|
559
|
-
const session = meta.session || null
|
|
560
|
-
const parts = []
|
|
561
|
-
const chips = [react.createElement(Chip, { key: 'scope', text: 'scope ' + String(meta.scope) })]
|
|
562
|
-
if (meta.generatedAt) chips.push(react.createElement(Chip, { key: 'at', text: whenText(meta.generatedAt) }))
|
|
563
|
-
if (session) chips.push(react.createElement(Chip, { key: 'where', text: session.workspace ? 'workspace ' + session.workspace : 'global layer' }))
|
|
564
|
-
parts.push(box({ key: 'chips', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips))
|
|
565
|
-
if (store) {
|
|
566
|
-
const attention = (store.queue && store.queue.attention) || []
|
|
567
|
-
const queue = attention.slice(0, 3).map(function (row) {
|
|
568
|
-
return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true })
|
|
569
|
-
})
|
|
570
|
-
if (store.queue && store.queue.waiting) queue.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }))
|
|
571
|
-
parts.push(sheet('store', [
|
|
572
|
-
sectionLabel('STORE', fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes'),
|
|
573
|
-
react.createElement(LayerBar, { key: 'bar', layers: store.layers, height: '30px' }),
|
|
574
|
-
react.createElement(Legend, { key: 'leg', layers: store.layers }),
|
|
575
|
-
box({ key: 'f', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }, [
|
|
576
|
-
box({ key: 'st', display: 'flex', gap: '18px' }, [
|
|
577
|
-
react.createElement(Stat, { key: 'a', value: fmt(store.freshness.fresh), label: 'fresh' }),
|
|
578
|
-
store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
|
|
579
|
-
store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
|
|
580
|
-
]),
|
|
581
|
-
box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, queue),
|
|
582
|
-
]),
|
|
583
|
-
]))
|
|
584
|
-
}
|
|
585
|
-
// A panel with nothing in it is omitted, or said in one line of prose: an empty card is
|
|
586
|
-
// a shape that reads as data, which is exactly what this tab must never draw.
|
|
587
|
-
const received = session ? session.received : null
|
|
588
|
-
if (received && received.sections && received.sections.length) {
|
|
589
|
-
parts.push(sheet('received', [react.createElement(ReceivedPanel, { key: 'r', received: received })]))
|
|
590
|
-
}
|
|
591
|
-
const written = session ? session.written : null
|
|
592
|
-
if (written && written.total) {
|
|
593
|
-
parts.push(sheet('written', [react.createElement(WrittenPanel, { key: 'w', written: written })]))
|
|
594
|
-
} else if (written) {
|
|
595
|
-
parts.push(react.createElement('div', {
|
|
596
|
-
key: 'none',
|
|
597
|
-
style: { fontFamily: GEIST, fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
598
|
-
}, 'This session has not recorded an entry yet.'))
|
|
599
|
-
}
|
|
600
|
-
parts.push(react.createElement('div', {
|
|
601
|
-
key: 'src',
|
|
602
|
-
style: { fontFamily: GEIST, fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.6 },
|
|
603
|
-
}, 'Published by the host into this session (event #' + fmt(props.seq) + '), not re-read from the store. It refreshes on its own as the store changes.'))
|
|
604
|
-
return box({ display: 'flex', flexDirection: 'column', gap: '18px' }, parts)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* The Memory tab.
|
|
609
|
-
*
|
|
610
|
-
* The hook arrives as part of this entry's injected standard kit: the inject face
|
|
611
|
-
* publishes hooks.memory, which the renderer binds to a useMemory prop, exactly as
|
|
612
|
-
* ui-trajectory's view receives useDuration. It is present for the whole life of a mount
|
|
613
|
-
* or absent for the whole of it, so this branch never changes hook order. The selector is
|
|
614
|
-
* required: the bound hook is a selector hook and calls it without a guard.
|
|
615
|
-
*/
|
|
616
|
-
function MemoryTabView(props) {
|
|
617
|
-
const useMemory = props && typeof props.useMemory === 'function' ? props.useMemory : null
|
|
618
|
-
const state = useMemory ? useMemory(memoryStateOf) : MEMORY_STATE_NONE
|
|
619
|
-
const page = { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }
|
|
620
|
-
const inner = { maxWidth: '760px', margin: '0 auto', padding: '26px 28px 44px', display: 'flex', flexDirection: 'column', gap: '18px' }
|
|
621
|
-
const head = box({ display: 'flex', alignItems: 'center', gap: '10px' }, [
|
|
622
|
-
react.createElement(Glyph, { key: 'g' }),
|
|
623
|
-
// Theme token, not the fixed ink palette: this line sits on the shell background,
|
|
624
|
-
// which the palette knows nothing about.
|
|
625
|
-
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '15px', fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, 'Memory'),
|
|
626
|
-
])
|
|
627
|
-
const intro = react.createElement('div', {
|
|
628
|
-
key: 'i',
|
|
629
|
-
style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary)' },
|
|
630
|
-
}, 'What memory put in front of this conversation, what the conversation wrote back, and the state of the store.')
|
|
631
|
-
const body = state.kind === 'ready'
|
|
632
|
-
? react.createElement(MemoryPanels, { key: 'p', meta: state.meta, seq: state.seq })
|
|
633
|
-
: react.createElement(MemoryEmpty, { key: 'e', state: state })
|
|
634
|
-
return box(page, [box(inner, [head, intro, body])])
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/** Fields rendered on this page, in order. */
|
|
638
|
-
const FIELDS = [
|
|
639
|
-
{ key: 'memoryRoot', label: 'Memory root', hint: 'Local folder or drive that holds the store.', placeholder: 'D:\\Memory' },
|
|
640
|
-
{ key: 'gitRemote', label: 'GitHub origin', hint: 'Git remote URL. Leave empty to keep the store local-only.', placeholder: 'git@github.com:you/memory.git' },
|
|
641
|
-
{ key: 'gitBranch', label: 'Branch', hint: 'Branch used for pull and push.', placeholder: 'main' },
|
|
642
|
-
{ key: 'gitToken', label: 'Access token (optional)', kind: 'secret', hint: 'Personal access token for pushing. Stored by the host and never sent back to this page, so the field is always empty: type a new token to replace it, and leave it empty to keep the current one.' },
|
|
643
|
-
]
|
|
644
|
-
|
|
645
|
-
// The settings namespace is the plugin's ENTRY ID, not its package name. The bundle patch mounts
|
|
646
|
-
// the package @try-works/dsh-memory as the row `dsh-memory`, while a hand-written row may use
|
|
647
|
-
// a short id like `memory`. Both must work, so the candidates are tried in order and
|
|
648
|
-
// the first one the host actually serves wins.
|
|
649
|
-
const NAMESPACE_CANDIDATES = ['memory', 'dsh-memory']
|
|
650
|
-
|
|
651
|
-
function apply(ctx) {
|
|
652
|
-
// configForms.get() is lazy: it resolves once the namespace appears in the mirror, so
|
|
653
|
-
// creating a form for a candidate that is never served costs nothing.
|
|
654
|
-
const forms = NAMESPACE_CANDIDATES.map((ns) => ctx.configForms.get(ns))
|
|
655
|
-
const served = () => forms.find((f) => {
|
|
656
|
-
const snap = f.getSnapshot()
|
|
657
|
-
return snap.status === 'ready' || (snap.value !== undefined)
|
|
658
|
-
})
|
|
659
|
-
// Fall back to the first candidate so the page still renders its explanatory copy.
|
|
660
|
-
const form = {
|
|
661
|
-
getSnapshot: () => (served() ?? forms[0]).getSnapshot(),
|
|
662
|
-
subscribe: (listener) => {
|
|
663
|
-
const disposers = forms.map((f) => f.subscribe(listener))
|
|
664
|
-
return () => { for (const dispose of disposers) dispose() }
|
|
665
|
-
},
|
|
666
|
-
set: (field, value) => (served() ?? forms[0]).set(field, value),
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/** Read the value a field should display: the draft, else the saved value. */
|
|
670
|
-
function fieldValue(key, value, draft) {
|
|
671
|
-
if (draft[key] !== undefined) return draft[key]
|
|
672
|
-
const saved = value[key]
|
|
673
|
-
return saved === undefined || saved === null ? '' : saved
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* One secret input. The value is deliberately NEVER read back from the snapshot: the
|
|
678
|
-
* host redacts it, so echoing it would display a placeholder and - worse - a later save
|
|
679
|
-
* could write that placeholder over the real token. Empty therefore means 'leave it'.
|
|
680
|
-
*/
|
|
681
|
-
function secretField(field, draft, edit, disabled) {
|
|
682
|
-
return react.createElement('div', { className: 'dsm-field', key: field.key },
|
|
683
|
-
react.createElement('label', { className: 'dsm-field-label', htmlFor: 'dsm-' + field.key }, field.label),
|
|
684
|
-
react.createElement('input', {
|
|
685
|
-
id: 'dsm-' + field.key,
|
|
686
|
-
className: 'dsm-input',
|
|
687
|
-
type: 'password',
|
|
688
|
-
autoComplete: 'off',
|
|
689
|
-
placeholder: 'unchanged',
|
|
690
|
-
value: draft[field.key] !== undefined ? draft[field.key] : '',
|
|
691
|
-
disabled,
|
|
692
|
-
onChange: (event) => edit(field.key, event.target.value),
|
|
693
|
-
}),
|
|
694
|
-
react.createElement('p', { className: 'dsm-field-help' }, field.hint))
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/** One labelled text input. */
|
|
698
|
-
function textField(field, value, draft, edit, disabled) {
|
|
699
|
-
return react.createElement('div', { className: 'dsm-field', key: field.key },
|
|
700
|
-
react.createElement('label', { className: 'dsm-field-label', htmlFor: 'dsm-' + field.key }, field.label),
|
|
701
|
-
react.createElement('input', {
|
|
702
|
-
id: 'dsm-' + field.key,
|
|
703
|
-
className: 'dsm-input',
|
|
704
|
-
type: 'text',
|
|
705
|
-
placeholder: field.placeholder,
|
|
706
|
-
value: fieldValue(field.key, value, draft),
|
|
707
|
-
disabled,
|
|
708
|
-
onChange: (event) => edit(field.key, event.target.value),
|
|
709
|
-
}),
|
|
710
|
-
react.createElement('p', { className: 'dsm-field-help' }, field.hint))
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/** The model picker: the configured catalog plus "session default" and any stale value. */
|
|
714
|
-
function modelField(value, draft, edit, disabled, models) {
|
|
715
|
-
const selected = fieldValue('summaryModel', value, draft)
|
|
716
|
-
const common = {
|
|
717
|
-
id: 'dsm-summaryModel',
|
|
718
|
-
label: 'Model for memory writes',
|
|
719
|
-
hint: 'Preferred model for summarizing sessions and consolidating them into entries. If it cannot answer, the next available model is tried automatically, so leaving this empty is fine.',
|
|
720
|
-
}
|
|
721
|
-
if (models === null) {
|
|
722
|
-
// Still loading: stay usable as a text field rather than blocking the page.
|
|
723
|
-
return react.createElement('div', { className: 'dsm-field', key: 'summaryModel' },
|
|
724
|
-
react.createElement('label', { className: 'dsm-field-label', htmlFor: common.id }, common.label),
|
|
725
|
-
react.createElement('input', {
|
|
726
|
-
id: common.id, className: 'dsm-input', type: 'text', placeholder: 'provider/model',
|
|
727
|
-
value: selected, disabled, onChange: (event) => edit('summaryModel', event.target.value),
|
|
728
|
-
}),
|
|
729
|
-
react.createElement('p', { className: 'dsm-field-help' }, common.hint))
|
|
730
|
-
}
|
|
731
|
-
const options = [{ value: '', label: 'Session default (same model as the chat)' }].concat(models)
|
|
732
|
-
if (selected && !models.some((option) => option.value === selected)) {
|
|
733
|
-
options.push({ value: selected, label: selected + ' (not currently configured)' })
|
|
734
|
-
}
|
|
735
|
-
return react.createElement('div', { className: 'dsm-field', key: 'summaryModel' },
|
|
736
|
-
react.createElement('label', { className: 'dsm-field-label', htmlFor: common.id }, common.label),
|
|
737
|
-
react.createElement('select', {
|
|
738
|
-
id: common.id, className: 'dsm-select', value: selected, disabled,
|
|
739
|
-
onChange: (event) => edit('summaryModel', event.target.value),
|
|
740
|
-
}, options.map((option) => react.createElement('option', { key: option.value, value: option.value }, option.label))),
|
|
741
|
-
react.createElement('p', { className: 'dsm-field-help' }, common.hint))
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
function MemorySettings() {
|
|
745
|
-
const [snap, setSnap] = react.useState(() => form.getSnapshot())
|
|
746
|
-
const [draft, setDraft] = react.useState({})
|
|
747
|
-
const [status, setStatus] = react.useState(null)
|
|
748
|
-
react.useEffect(() => form.subscribe(() => setSnap(form.getSnapshot())), [])
|
|
749
|
-
|
|
750
|
-
// Model list for the write-model picker, from the same catalog the chat uses.
|
|
751
|
-
const [models, setModels] = react.useState(null)
|
|
752
|
-
react.useEffect(() => {
|
|
753
|
-
let alive = true
|
|
754
|
-
Promise.resolve()
|
|
755
|
-
.then(() => ctx.remote.session.modelCatalog())
|
|
756
|
-
.then((response) => {
|
|
757
|
-
if (!alive) return
|
|
758
|
-
const groups = response && response.ok && response.value ? (response.value.groups || []) : []
|
|
759
|
-
const options = []
|
|
760
|
-
for (const group of groups) {
|
|
761
|
-
for (const model of (group.models || [])) {
|
|
762
|
-
options.push({ value: group.id + '/' + model.id, label: group.name + ' - ' + (model.name || model.id) })
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
setModels(options)
|
|
766
|
-
})
|
|
767
|
-
.catch(() => { if (alive) setModels([]) })
|
|
768
|
-
return () => { alive = false }
|
|
769
|
-
}, [])
|
|
770
|
-
|
|
771
|
-
const value = snap.value || {}
|
|
772
|
-
const edit = (key, next) => setDraft((previous) => {
|
|
773
|
-
const updated = { ...previous }
|
|
774
|
-
updated[key] = next
|
|
775
|
-
return updated
|
|
776
|
-
})
|
|
777
|
-
|
|
778
|
-
if (snap.status === 'unavailable') {
|
|
779
|
-
return react.createElement('div', { className: 'dsm-page' },
|
|
780
|
-
react.createElement('style', null, CSS),
|
|
781
|
-
react.createElement('h2', { className: 'dsm-title' }, 'Memory'),
|
|
782
|
-
react.createElement('p', { className: 'dsm-intro' }, 'The memory configuration namespace is not exposed to this client.'))
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
const save = async () => {
|
|
786
|
-
setStatus({ kind: 'busy', text: 'Saving...' })
|
|
787
|
-
try {
|
|
788
|
-
let refused = false
|
|
789
|
-
for (const field of FIELDS.concat([{ key: 'summaryModel' }])) {
|
|
790
|
-
const next = draft[field.key]
|
|
791
|
-
// An empty draft means 'not edited'. For a secret that is essential: writing an
|
|
792
|
-
// empty value would erase the token, and writing the redacted snapshot value
|
|
793
|
-
// would replace it with a placeholder.
|
|
794
|
-
if (next === undefined || next === '' || next === value[field.key]) continue
|
|
795
|
-
const accepted = await form.set(field.key, next)
|
|
796
|
-
if (accepted === false) refused = true
|
|
797
|
-
}
|
|
798
|
-
setStatus(refused
|
|
799
|
-
? { kind: 'error', text: 'Refused by the host - see the dsh logs.' }
|
|
800
|
-
: { kind: 'ok', text: 'Saved.' })
|
|
801
|
-
} catch (error) {
|
|
802
|
-
setStatus({ kind: 'error', text: String((error && error.message) || error) })
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const disabled = !snap.writable
|
|
807
|
-
return react.createElement('div', { className: 'dsm-page' },
|
|
808
|
-
react.createElement('style', null, CSS),
|
|
809
|
-
react.createElement('h2', { className: 'dsm-title' }, 'Memory'),
|
|
810
|
-
react.createElement('p', { className: 'dsm-intro' },
|
|
811
|
-
'Git-backed memory store, scoped to this workspace. Changes apply live and persist to this profile.'),
|
|
812
|
-
react.createElement('div', { className: 'dsm-card' },
|
|
813
|
-
// Rendered by kind, not by index: index-based rendering silently mis-assigned fields
|
|
814
|
-
// the moment one was inserted in the middle.
|
|
815
|
-
...FIELDS.map((field) => {
|
|
816
|
-
if (field.kind === 'secret') return secretField(field, draft, edit, disabled)
|
|
817
|
-
if (field.kind === 'model') return modelField(value, draft, edit, disabled, models)
|
|
818
|
-
return textField(field, value, draft, edit, disabled)
|
|
819
|
-
})),
|
|
820
|
-
react.createElement('div', { className: 'dsm-actions' },
|
|
821
|
-
react.createElement('button', {
|
|
822
|
-
className: 'dsm-button', type: 'button', disabled, onClick: save,
|
|
823
|
-
}, 'Save'),
|
|
824
|
-
status
|
|
825
|
-
? react.createElement('p', {
|
|
826
|
-
className: 'dsm-status' + (status.kind === 'ok' ? ' dsm-status-ok' : status.kind === 'error' ? ' dsm-status-error' : ''),
|
|
827
|
-
}, status.text)
|
|
828
|
-
: null,
|
|
829
|
-
snap.writable ? null : react.createElement('p', { className: 'dsm-status' }, 'Read-only: connected from a remote browser.')),
|
|
830
|
-
react.createElement('p', { className: 'dsm-note' },
|
|
831
|
-
'Store layout: ',
|
|
832
|
-
react.createElement('span', { className: 'dsm-code' }, 'global/'),
|
|
833
|
-
' (identity, preferences, standing rules) plus ',
|
|
834
|
-
react.createElement('span', { className: 'dsm-code' }, 'workspaces/<key>/'),
|
|
835
|
-
' per associated local folder or repo. Derived state (index/, state/, digests/, logs/) is gitignored and regenerated. Cadence and summarizer budgets are advanced tuning kept in the profile config file, not here.'))
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
ctx.effect(() => {
|
|
839
|
-
const disposers = []
|
|
840
|
-
// The inline face of memory_map, registered by the tool's WIRE NAME, which is how the
|
|
841
|
-
// host finds it. The result carries the data, so the view needs no other channel.
|
|
842
|
-
disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
|
843
|
-
name: 'tool.call.toolview',
|
|
844
|
-
key: 'memory_map',
|
|
845
|
-
}, MemoryMapCard)))
|
|
846
|
-
// The settings page registers FIRST, and deliberately. It is the established surface;
|
|
847
|
-
// the tab below is newer and depends on two services the page does not need. Ordering
|
|
848
|
-
// the proven surface before the unproven one means a failure in the tab cannot take the
|
|
849
|
-
// settings page down with it - both halves share one file and one apply().
|
|
850
|
-
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
851
|
-
name: 'settings.section',
|
|
852
|
-
id: 'memory',
|
|
853
|
-
order: 80,
|
|
854
|
-
label: 'Memory',
|
|
855
|
-
}, () => react.createElement(MemorySettings))))
|
|
856
|
-
// The Memory tab. Its data is injected by THIS entry rather than registered globally
|
|
857
|
-
// through uiSession.provide: the source is per-session and read from the session's own
|
|
858
|
-
// event window, and an entry-owned inject face is cached per (entry x session), so the
|
|
859
|
-
// hook the renderer binds (useMemory) is created once per conversation.
|
|
860
|
-
try {
|
|
861
|
-
disposers.push(ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
862
|
-
name: 'conversation.view',
|
|
863
|
-
id: 'memory',
|
|
864
|
-
// ui-chat holds 0 and ui-trajectory 10, so Memory sits after both without
|
|
865
|
-
// reordering anyone else's tab.
|
|
866
|
-
order: 20,
|
|
867
|
-
label: 'Memory',
|
|
868
|
-
inject: (sessionId) => ({ hooks: { memory: createMemorySource(ctx, sessionId) } }),
|
|
869
|
-
}, MemoryTabView)))
|
|
870
|
-
} catch (error) {
|
|
871
|
-
// Loud, not silent. A tab that fails to register is invisible by definition, so this
|
|
872
|
-
// warning is the only trace the failure can leave.
|
|
873
|
-
ctx.logger.warn('dsh-memory: Memory tab unavailable: ' + (error && error.message ? error.message : String(error)))
|
|
874
|
-
}
|
|
875
|
-
return () => { for (const dispose of disposers) dispose() }
|
|
876
|
-
}, 'dsh-memory: settings section')
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
exports.name = 'dsh-memory'
|
|
880
|
-
// 'sessions' is deliberately NOT here: the tab reaches it with ctx.get() so that a
|
|
881
|
-
// missing service can only blank the tab, never hold back the settings page too.
|
|
882
|
-
exports.inject = ['slots', 'configForms', 'remote.session']
|
|
883
|
-
exports.apply = apply
|
|
884
|
-
return module.exports
|
|
885
|
-
},
|
|
886
|
-
})
|
|
1
|
+
// dsh-memory browser half: the Settings -> Memory page.
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript by necessity: DSH's client module system serves this file to the
|
|
4
|
+
// page as a lazy-CJS bundle, so it IS the shipped artifact. The factory receives the
|
|
5
|
+
// browser module table's require and returns { inject, apply }.
|
|
6
|
+
//
|
|
7
|
+
// Only 'react' is requested. Harness client packages are deliberately not imported:
|
|
8
|
+
// they change without notice, plain JS gets no type check against them, and a throwing
|
|
9
|
+
// component blanks the slot entry. Controls and styles are written here, using only
|
|
10
|
+
// theme tokens that actually exist on the shipped settings pages -- a token that does
|
|
11
|
+
// not exist is an invalid declaration, the browser drops it, and the page degrades
|
|
12
|
+
// into undifferentiated prose. The page mirrors a shipped settings section: the same
|
|
13
|
+
// column width, card chrome, type scale and control metrics.
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
// Must be the package name: the web client loader looks up this exact id.
|
|
16
|
+
// The Cordis entry id (`dsh-memory`) is a different string used only for settings.
|
|
17
|
+
id: '@try-works/dsh-memory',
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
var module = { exports: {} }
|
|
20
|
+
var exports = module.exports
|
|
21
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
22
|
+
|
|
23
|
+
const react = require('react')
|
|
24
|
+
|
|
25
|
+
/** Page styles, prefixed to avoid colliding with host styles. */
|
|
26
|
+
const CSS = [
|
|
27
|
+
'.dsm-page {',
|
|
28
|
+
' display: flex;',
|
|
29
|
+
' flex-direction: column;',
|
|
30
|
+
' gap: 12px;',
|
|
31
|
+
' max-width: 720px;',
|
|
32
|
+
' color: var(--dsw-alias-label-primary);',
|
|
33
|
+
'}',
|
|
34
|
+
'.dsm-title {',
|
|
35
|
+
' margin: 0;',
|
|
36
|
+
' font-size: 16px;',
|
|
37
|
+
' line-height: 24px;',
|
|
38
|
+
' font-weight: 500;',
|
|
39
|
+
' color: var(--dsw-alias-label-primary);',
|
|
40
|
+
'}',
|
|
41
|
+
'.dsm-intro {',
|
|
42
|
+
' margin: 0;',
|
|
43
|
+
' font-size: 14px;',
|
|
44
|
+
' line-height: 22px;',
|
|
45
|
+
' color: var(--dsw-alias-label-tertiary);',
|
|
46
|
+
'}',
|
|
47
|
+
'.dsm-card {',
|
|
48
|
+
' border: 0.5px solid var(--dsw-alias-settings-card-stroke);',
|
|
49
|
+
' background: var(--dsw-alias-settings-card-fill);',
|
|
50
|
+
' border-radius: var(--dsw-radius-xl);',
|
|
51
|
+
' padding: 12px 14px;',
|
|
52
|
+
' display: flex;',
|
|
53
|
+
' flex-direction: column;',
|
|
54
|
+
' gap: 2px;',
|
|
55
|
+
'}',
|
|
56
|
+
'.dsm-field {',
|
|
57
|
+
' display: flex;',
|
|
58
|
+
' flex-direction: column;',
|
|
59
|
+
' gap: 4px;',
|
|
60
|
+
' padding: 6px 0;',
|
|
61
|
+
'}',
|
|
62
|
+
'.dsm-field-label {',
|
|
63
|
+
' font-size: 12px;',
|
|
64
|
+
' line-height: 18px;',
|
|
65
|
+
' color: var(--dsw-alias-label-secondary);',
|
|
66
|
+
'}',
|
|
67
|
+
'.dsm-field-help {',
|
|
68
|
+
' margin: 0;',
|
|
69
|
+
' font-size: 12px;',
|
|
70
|
+
' line-height: 18px;',
|
|
71
|
+
' color: var(--dsw-alias-label-tertiary);',
|
|
72
|
+
'}',
|
|
73
|
+
'.dsm-input,',
|
|
74
|
+
'.dsm-select {',
|
|
75
|
+
' box-sizing: border-box;',
|
|
76
|
+
' width: 100%;',
|
|
77
|
+
' height: 32px;',
|
|
78
|
+
' padding: 0 10px;',
|
|
79
|
+
' border: 1px solid var(--dsw-alias-border-l2);',
|
|
80
|
+
' border-radius: var(--dsw-radius-sm);',
|
|
81
|
+
' background: var(--dsw-alias-bg-layer-1);',
|
|
82
|
+
' color: var(--dsw-alias-label-primary);',
|
|
83
|
+
' font-family: var(--ds-font-family-code);',
|
|
84
|
+
' font-size: 12px;',
|
|
85
|
+
'}',
|
|
86
|
+
'.dsm-select {',
|
|
87
|
+
' font-family: inherit;',
|
|
88
|
+
' cursor: pointer;',
|
|
89
|
+
'}',
|
|
90
|
+
'.dsm-input:focus-visible,',
|
|
91
|
+
'.dsm-select:focus-visible {',
|
|
92
|
+
' outline: 2px solid var(--dsw-alias-brand-primary);',
|
|
93
|
+
' outline-offset: 1px;',
|
|
94
|
+
'}',
|
|
95
|
+
'.dsm-input:disabled,',
|
|
96
|
+
'.dsm-select:disabled {',
|
|
97
|
+
' opacity: 0.6;',
|
|
98
|
+
' cursor: default;',
|
|
99
|
+
'}',
|
|
100
|
+
'.dsm-actions {',
|
|
101
|
+
' display: flex;',
|
|
102
|
+
' align-items: center;',
|
|
103
|
+
' gap: 10px;',
|
|
104
|
+
' padding-top: 10px;',
|
|
105
|
+
'}',
|
|
106
|
+
'.dsm-button {',
|
|
107
|
+
' height: 30px;',
|
|
108
|
+
' padding: 0 14px;',
|
|
109
|
+
' border: none;',
|
|
110
|
+
' border-radius: var(--dsw-radius-sm);',
|
|
111
|
+
' background: var(--dsw-alias-button-primary-fill);',
|
|
112
|
+
' color: var(--dsw-alias-label-primary-inverted);',
|
|
113
|
+
' font-family: inherit;',
|
|
114
|
+
' font-size: 13px;',
|
|
115
|
+
' cursor: pointer;',
|
|
116
|
+
'}',
|
|
117
|
+
'.dsm-button:hover:not(:disabled) {',
|
|
118
|
+
' background: var(--dsw-alias-button-primary-hover);',
|
|
119
|
+
'}',
|
|
120
|
+
'.dsm-button:disabled {',
|
|
121
|
+
' opacity: 0.6;',
|
|
122
|
+
' cursor: default;',
|
|
123
|
+
'}',
|
|
124
|
+
'.dsm-button:focus-visible {',
|
|
125
|
+
' outline: 2px solid var(--dsw-alias-brand-primary);',
|
|
126
|
+
' outline-offset: 1px;',
|
|
127
|
+
'}',
|
|
128
|
+
'.dsm-status {',
|
|
129
|
+
' margin: 0;',
|
|
130
|
+
' font-size: 12px;',
|
|
131
|
+
' line-height: 18px;',
|
|
132
|
+
' color: var(--dsw-alias-label-tertiary);',
|
|
133
|
+
'}',
|
|
134
|
+
'.dsm-status-ok {',
|
|
135
|
+
' color: var(--dsw-alias-state-success-primary);',
|
|
136
|
+
'}',
|
|
137
|
+
'.dsm-status-error {',
|
|
138
|
+
' color: var(--dsw-alias-state-error-primary);',
|
|
139
|
+
'}',
|
|
140
|
+
'.dsm-note {',
|
|
141
|
+
' margin: 0;',
|
|
142
|
+
' font-size: 12px;',
|
|
143
|
+
' line-height: 18px;',
|
|
144
|
+
' color: var(--dsw-alias-label-tertiary);',
|
|
145
|
+
'}',
|
|
146
|
+
'.dsm-code {',
|
|
147
|
+
' font-family: var(--ds-font-family-code);',
|
|
148
|
+
'}',
|
|
149
|
+
].join('\n')
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------------
|
|
153
|
+
// Memory map: the inline face of the memory_map tool.
|
|
154
|
+
//
|
|
155
|
+
// It renders the tool result's structured `meta`, which is the value the tool returned.
|
|
156
|
+
// That is not a shortcut, it is the only honest source: a browser half cannot ask the
|
|
157
|
+
// host for anything (dsh.client has no data channel, remotes are an allowlisted
|
|
158
|
+
// application assembly, and the plugin route serves only client JS), so the tool result
|
|
159
|
+
// IS the channel. Recomputing the numbers here would invent a second source that could
|
|
160
|
+
// disagree with the one the agent was shown, and the disagreement would be invisible.
|
|
161
|
+
// ---------------------------------------------------------------------------------
|
|
162
|
+
const INK = '#1A1A18'
|
|
163
|
+
const MUTED = '#6E6E6B'
|
|
164
|
+
const FAINT = '#8A8A87'
|
|
165
|
+
const LINE = '#EFEFEC'
|
|
166
|
+
const RUST = '#8A3F1E'
|
|
167
|
+
const COPPER = '#C77E4A'
|
|
168
|
+
const QUIET = '#6B6B68'
|
|
169
|
+
const GEIST = 'Geist, system-ui, -apple-system, sans-serif'
|
|
170
|
+
const MONO = 'Geist Mono, ui-monospace, SFMono-Regular, monospace'
|
|
171
|
+
// Deepest tone for the largest layer, so the ramp itself encodes proportion and seven
|
|
172
|
+
// layers need seven TONES rather than seven hues.
|
|
173
|
+
const TONES = ['#2A3644', '#3A4757', '#4C5A6B', '#64717F', '#808C99', '#9DA8B3', '#BCC4CC']
|
|
174
|
+
|
|
175
|
+
/** Thousands separators, so a four-digit count is readable at a glance. */
|
|
176
|
+
function fmt(value) {
|
|
177
|
+
return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('en-US') : String(value)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** How a verdict is written and coloured: the WORD carries the meaning, colour agrees. */
|
|
181
|
+
function verdictStyle(verdict) {
|
|
182
|
+
if (verdict === 'stale') return { color: RUST, weight: 500 }
|
|
183
|
+
if (verdict === 'aging') return { color: COPPER, weight: 500 }
|
|
184
|
+
if (verdict === 'unverified' || verdict === 'empty') return { color: QUIET, weight: 500 }
|
|
185
|
+
return { color: QUIET, weight: 400 }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function box(style, children) { return react.createElement('div', { style: style }, children) }
|
|
189
|
+
|
|
190
|
+
/** The stacked layer bar. Each width is a count as a fraction of the total. */
|
|
191
|
+
function LayerBar(props) {
|
|
192
|
+
const total = props.layers.reduce(function (sum, l) { return sum + l.count }, 0)
|
|
193
|
+
if (!total) return null
|
|
194
|
+
const segs = props.layers.map(function (layer, i) {
|
|
195
|
+
return react.createElement('div', {
|
|
196
|
+
key: layer.name,
|
|
197
|
+
style: { width: (layer.count / total * 100).toFixed(3) + '%', flexShrink: 0, background: TONES[i % TONES.length] },
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
return box({ display: 'flex', height: props.height || '26px', borderRadius: '7px', overflow: 'hidden' }, segs)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The legend: fixed dot and name slots, so every count shares one vertical lane. */
|
|
204
|
+
function Legend(props) {
|
|
205
|
+
const items = props.layers.map(function (layer, i) {
|
|
206
|
+
return box({ key: layer.name, display: 'flex', alignItems: 'center', gap: '7px' }, [
|
|
207
|
+
react.createElement('div', { key: 'd', style: { width: '6px', height: '6px', flexShrink: 0, borderRadius: '2px', background: TONES[i % TONES.length] } }),
|
|
208
|
+
react.createElement('div', { key: 'n', style: { fontFamily: GEIST, fontSize: '11px', color: '#3A3A38', width: '62px', flexShrink: 0 } }, layer.name),
|
|
209
|
+
react.createElement('div', { key: 'c', style: { fontFamily: MONO, fontSize: '10px', color: QUIET } }, fmt(layer.count)),
|
|
210
|
+
])
|
|
211
|
+
})
|
|
212
|
+
const half = Math.ceil(items.length / 2)
|
|
213
|
+
return box({ display: 'flex', flexDirection: 'column', gap: '7px' }, [
|
|
214
|
+
box({ key: 'a', display: 'flex', gap: '22px' }, items.slice(0, half)),
|
|
215
|
+
box({ key: 'b', display: 'flex', gap: '22px' }, items.slice(half)),
|
|
216
|
+
])
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function Stat(props) {
|
|
220
|
+
return box({ display: 'flex', alignItems: 'baseline', gap: '6px' }, [
|
|
221
|
+
react.createElement('div', { key: 'n', style: { fontFamily: MONO, fontSize: props.size || '15px', color: props.color || INK } }, props.value),
|
|
222
|
+
react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', color: MUTED } }, props.label),
|
|
223
|
+
])
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function Chip(props) {
|
|
227
|
+
return react.createElement('div', {
|
|
228
|
+
style: { padding: '3px 8px', borderRadius: '5px', fontFamily: MONO, fontSize: '10px', background: props.hot ? '#FAEDE6' : '#F4F4F2', color: props.hot ? RUST : '#3A3A38' },
|
|
229
|
+
}, props.text)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Section label, with an optional right-hand note. */
|
|
233
|
+
function sectionLabel(text, note) {
|
|
234
|
+
return box({ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }, [
|
|
235
|
+
react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', fontWeight: 500, letterSpacing: '0.08em', color: MUTED } }, text),
|
|
236
|
+
note ? react.createElement('div', { key: 'r', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT } }, note) : null,
|
|
237
|
+
])
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The three-bar mark: a miniature of the layer bar, so the card is identifiable at a glance. */
|
|
241
|
+
function Glyph() {
|
|
242
|
+
const bar = function (key, width, tone) {
|
|
243
|
+
return react.createElement('div', { key: key, style: { height: '2px', width: width, borderRadius: '1px', background: tone } })
|
|
244
|
+
}
|
|
245
|
+
return box({ width: '14px', height: '14px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '2px', justifyContent: 'center' }, [
|
|
246
|
+
bar('a', '14px', TONES[0]), bar('b', '10px', TONES[3]), bar('c', '6px', TONES[5]),
|
|
247
|
+
])
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The RECEIVED panel: what memory put in front of this session, with its freshness. */
|
|
251
|
+
function ReceivedPanel(props) {
|
|
252
|
+
const sections = (props.received && props.received.sections) || []
|
|
253
|
+
if (!sections.length) return null
|
|
254
|
+
const rows = sections.map(function (section) {
|
|
255
|
+
const style = verdictStyle(section.verdict)
|
|
256
|
+
return box({ key: section.name, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }, [
|
|
257
|
+
react.createElement('div', { key: 'n', style: { fontFamily: GEIST, fontSize: '12px', color: '#3A3A38' } }, section.name),
|
|
258
|
+
react.createElement('div', { key: 'v', style: { fontFamily: MONO, fontSize: '11px', color: style.color, fontWeight: style.weight } }, section.marker ? section.marker.replace(/[\[\]]/g, '').trim() : 'fresh'),
|
|
259
|
+
])
|
|
260
|
+
})
|
|
261
|
+
return box({ display: 'flex', flexDirection: 'column', gap: '10px' }, [
|
|
262
|
+
sectionLabel('RECEIVED', fmt(props.received.chars) + ' chars'),
|
|
263
|
+
box({ key: 'rows', display: 'flex', flexDirection: 'column', gap: '7px' }, rows),
|
|
264
|
+
])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The WRITTEN panel: what THIS session recorded, by layer and by entry. */
|
|
268
|
+
function WrittenPanel(props) {
|
|
269
|
+
const written = props.written
|
|
270
|
+
if (!written || !written.total) return null
|
|
271
|
+
const chips = Object.keys(written.byLayer).map(function (layer) {
|
|
272
|
+
return react.createElement(Chip, { key: layer, text: layer + ' ' + written.byLayer[layer] })
|
|
273
|
+
})
|
|
274
|
+
const entries = written.entries.map(function (entry) {
|
|
275
|
+
return box({ key: entry.id, display: 'flex', alignItems: 'baseline' }, [
|
|
276
|
+
react.createElement('div', { key: 'i', style: { fontFamily: MONO, fontSize: '11px', color: QUIET, width: '186px', flexShrink: 0 } }, entry.id),
|
|
277
|
+
react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT, width: '72px', flexShrink: 0 } }, entry.layer),
|
|
278
|
+
react.createElement('div', { key: 'h', style: { fontFamily: GEIST, fontSize: '12px', color: '#3A3A38' } }, entry.heading),
|
|
279
|
+
])
|
|
280
|
+
})
|
|
281
|
+
const more = written.total > written.entries.length
|
|
282
|
+
return box({ display: 'flex', flexDirection: 'column', gap: '12px' }, [
|
|
283
|
+
sectionLabel('WRITTEN', fmt(written.total) + ' this session'),
|
|
284
|
+
box({ key: 'c', display: 'flex', gap: '5px' }, chips),
|
|
285
|
+
box({ key: 'e', display: 'flex', flexDirection: 'column', gap: '7px' }, entries),
|
|
286
|
+
more ? react.createElement('div', { key: 'm', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT } }, '+ ' + fmt(written.total - written.entries.length) + ' more') : null,
|
|
287
|
+
])
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The memory_map tool row.
|
|
292
|
+
*
|
|
293
|
+
* Every value comes from the result's own `meta`. Nothing is fetched and nothing is
|
|
294
|
+
* inferred from the rendered text: when the snapshot is absent the card says so, rather
|
|
295
|
+
* than drawing a shape that merely looks like data.
|
|
296
|
+
*/
|
|
297
|
+
function MemoryMapCard(props) {
|
|
298
|
+
const block = props ? props.block : null
|
|
299
|
+
const meta = block && typeof block.meta === 'object' && block.meta !== null ? block.meta : null
|
|
300
|
+
const store = meta && meta.store ? meta.store : null
|
|
301
|
+
const session = meta && meta.session ? meta.session : null
|
|
302
|
+
const summary = store ? fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes' : ''
|
|
303
|
+
const headStyle = { height: '46px', flexShrink: 0, display: 'flex', alignItems: 'center', gap: '10px', padding: '0 16px', borderBottom: '1px solid ' + LINE }
|
|
304
|
+
const head = box(headStyle, [
|
|
305
|
+
react.createElement(Glyph, { key: 'g' }),
|
|
306
|
+
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '13px', fontWeight: 500, color: INK } }, 'Memory map'),
|
|
307
|
+
react.createElement('div', { key: 's', style: { flex: 1 } }),
|
|
308
|
+
react.createElement('div', { key: 'c', style: { fontFamily: GEIST, fontSize: '12px', color: MUTED } }, summary),
|
|
309
|
+
])
|
|
310
|
+
const body = []
|
|
311
|
+
if (store) {
|
|
312
|
+
body.push(box({ key: 'l', display: 'flex', flexDirection: 'column', gap: '12px' }, [
|
|
313
|
+
react.createElement(LayerBar, { key: 'bar', layers: store.layers }),
|
|
314
|
+
react.createElement(Legend, { key: 'leg', layers: store.layers }),
|
|
315
|
+
]))
|
|
316
|
+
const attention = (store.queue && store.queue.attention) || []
|
|
317
|
+
const chips = attention.slice(0, 3).map(function (row) {
|
|
318
|
+
return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true })
|
|
319
|
+
})
|
|
320
|
+
if (store.queue && store.queue.waiting) chips.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }))
|
|
321
|
+
const stats = [
|
|
322
|
+
react.createElement(Stat, { key: 'a', value: fmt(store.freshness.fresh), label: 'fresh' }),
|
|
323
|
+
store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
|
|
324
|
+
store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
|
|
325
|
+
]
|
|
326
|
+
const footStyle = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid ' + LINE, paddingTop: '13px', gap: '16px' }
|
|
327
|
+
body.push(box({ key: 'f', display: 'flex', flexDirection: 'column' }, [
|
|
328
|
+
box(Object.assign({ key: 'row' }, footStyle), [
|
|
329
|
+
box({ key: 's', display: 'flex', gap: '18px' }, stats),
|
|
330
|
+
box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips),
|
|
331
|
+
]),
|
|
332
|
+
]))
|
|
333
|
+
}
|
|
334
|
+
if (session && session.received) body.push(react.createElement(ReceivedPanel, { key: 'r', received: session.received }))
|
|
335
|
+
if (session && session.written) body.push(react.createElement(WrittenPanel, { key: 'w', written: session.written }))
|
|
336
|
+
if (!body.length) body.push(react.createElement('div', { key: 'none', style: { fontFamily: GEIST, fontSize: '12px', color: MUTED } }, 'No snapshot was recorded for this call.'))
|
|
337
|
+
const cardStyle = { maxWidth: '760px', border: '1px solid #E7E7E4', borderRadius: '12px', background: '#FFFFFF', display: 'flex', flexDirection: 'column' }
|
|
338
|
+
const bodyStyle = { padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' }
|
|
339
|
+
return box(cardStyle, [head, box(Object.assign({ key: 'b' }, bodyStyle), body)]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The Memory tab: this session's memory activity, in one place.
|
|
344
|
+
*
|
|
345
|
+
* Its data is the newest memory snapshot in this conversation, from either of two routes.
|
|
346
|
+
*
|
|
347
|
+
* The one that matters is memory/map: a browser half cannot ask the host for anything, so the
|
|
348
|
+
* session record is the only channel - and rather than wait for someone to call a tool, the HOST
|
|
349
|
+
* publishes the map into that record itself. SessionEventMap is merge-extensible by design and a
|
|
350
|
+
* log-only event never reaches the model surface, so this is the harness's own answer for a view
|
|
351
|
+
* that must report state nobody asked about.
|
|
352
|
+
*
|
|
353
|
+
* A memory_map TOOL RESULT is still honoured, newest wins, because a session whose snapshots
|
|
354
|
+
* predate the publisher would otherwise draw nothing. That route carries its value on `meta` -
|
|
355
|
+
* the object ui-chat's conversation-nodes/tool.ts forwards as `meta: match.event.data.meta`.
|
|
356
|
+
*
|
|
357
|
+
* READING IT: the session's contiguous event window is published as the documented
|
|
358
|
+
* public member SessionBinding.eventSource (ObservableSnapshot<SessionEventWindow>, see
|
|
359
|
+
* api/session-controller/src/client/contract/events.ts), reached through
|
|
360
|
+
* ctx.sessions.binding(sessionId). That is the same source the Chat and Trajectory tabs
|
|
361
|
+
* are assembled from, so the tab adds no new channel and cannot disagree with them.
|
|
362
|
+
* Sessions is reached with ctx.get() because it is a reachable service, not a required
|
|
363
|
+
* one: making it an inject dependency would hold the whole plugin - Settings included -
|
|
364
|
+
* pending behind a service the settings page does not need.
|
|
365
|
+
*/
|
|
366
|
+
const MEMORY_STATE_NONE = { kind: 'none', meta: null, seq: null, time: null }
|
|
367
|
+
const MEMORY_STATE_UNAVAILABLE = { kind: 'unavailable', meta: null, seq: null, time: null }
|
|
368
|
+
// Distinct from NONE on purpose. PTC mode dispatches a tool from inside a run_code program and
|
|
369
|
+
// records tool/ptc-dispatch, which carries the rendered text but NO structured value, so there
|
|
370
|
+
// is genuinely nothing to draw - and telling the reader 'memory_map has not run here' would be
|
|
371
|
+
// false.
|
|
372
|
+
const MEMORY_STATE_NESTED = { kind: 'nested', meta: null, seq: null, time: null }
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Find the newest memory snapshot in the window and say what it carried.
|
|
376
|
+
*
|
|
377
|
+
* Two routes, newest wins. The host publishes a memory/map event on its own cadence, which is
|
|
378
|
+
* what lets the tab draw with no tool call. A memory_map tool/result is also honoured: a
|
|
379
|
+
* tool/result event names its call only by id, so call events are indexed first, and a result
|
|
380
|
+
* whose pair has been paged out is still recognised by the shape of its own value, since
|
|
381
|
+
* nothing else produces { scope, generatedAt }.
|
|
382
|
+
*/
|
|
383
|
+
function readMemoryState(window) {
|
|
384
|
+
const entries = window && Array.isArray(window.entries) ? window.entries : null
|
|
385
|
+
if (!entries) return MEMORY_STATE_NONE
|
|
386
|
+
const calls = Object.create(null)
|
|
387
|
+
let nested = false
|
|
388
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
389
|
+
const entry = entries[i]
|
|
390
|
+
const event = entry && entry.type === 'event' ? entry.event : null
|
|
391
|
+
if (event && event.type === 'tool/call' && event.data && event.data.name === 'memory_map') calls[event.data.callId] = true
|
|
392
|
+
// A nested dispatch is a real call the agent made; it simply has no structured result.
|
|
393
|
+
if (event && event.type === 'tool/ptc-dispatch' && event.data && event.data.name === 'memory_map') nested = true
|
|
394
|
+
}
|
|
395
|
+
// Newest first: the tab describes the session as it stands now, not as it first was.
|
|
396
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
397
|
+
const entry = entries[i]
|
|
398
|
+
const event = entry && entry.type === 'event' ? entry.event : null
|
|
399
|
+
if (!event) continue
|
|
400
|
+
// The host PUBLISHES the map into the log itself, so the tab fills in on its own and needs
|
|
401
|
+
// nobody to call a tool first. This is the route that matters; the tool/result route below
|
|
402
|
+
// is kept so a session whose snapshots predate the publisher still draws.
|
|
403
|
+
if (event.type === 'memory/map') {
|
|
404
|
+
const published = event.data
|
|
405
|
+
if (published === null || typeof published !== 'object') continue
|
|
406
|
+
if (typeof published.generatedAt !== 'string' || typeof published.scope !== 'string') continue
|
|
407
|
+
return { kind: 'ready', meta: published, seq: event.seq, time: event.time }
|
|
408
|
+
}
|
|
409
|
+
if (event.type !== 'tool/result') continue
|
|
410
|
+
const data = event.data || {}
|
|
411
|
+
const message = data.message || null
|
|
412
|
+
const callId = message && message.source ? message.source.callId : undefined
|
|
413
|
+
const meta = data.meta
|
|
414
|
+
const shaped = meta !== null && typeof meta === 'object'
|
|
415
|
+
&& typeof meta.generatedAt === 'string' && typeof meta.scope === 'string'
|
|
416
|
+
if (calls[callId] !== true && !shaped) continue
|
|
417
|
+
// A failed call is not a snapshot: it is the absence of one, and it is said so.
|
|
418
|
+
if (message && message.isError === true) continue
|
|
419
|
+
if (meta === null || typeof meta !== 'object') return { kind: 'empty', meta: null, seq: event.seq, time: event.time }
|
|
420
|
+
return { kind: 'ready', meta: meta, seq: event.seq, time: event.time }
|
|
421
|
+
}
|
|
422
|
+
return nested ? MEMORY_STATE_NESTED : MEMORY_STATE_NONE
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The tab's observable source over one session's event window.
|
|
427
|
+
*
|
|
428
|
+
* It re-resolves the binding on every read, so a session whose binding materializes
|
|
429
|
+
* after the tab has already rendered still fills in; while no window exists it listens
|
|
430
|
+
* to the session list, which publishes when a generation is retained or opened, so the
|
|
431
|
+
* tab wakes up instead of staying dark until an unrelated re-render. Values are cached by
|
|
432
|
+
* window identity, which keeps getSnapshot referentially stable between publications.
|
|
433
|
+
*/
|
|
434
|
+
function createMemorySource(ctx, sessionId) {
|
|
435
|
+
const listeners = []
|
|
436
|
+
let subscribedTo = null
|
|
437
|
+
let unsubscribe = null
|
|
438
|
+
let cachedWindow = null
|
|
439
|
+
let cachedState = MEMORY_STATE_NONE
|
|
440
|
+
const wake = () => {
|
|
441
|
+
cachedWindow = null
|
|
442
|
+
for (let i = 0; i < listeners.length; i += 1) listeners[i]()
|
|
443
|
+
}
|
|
444
|
+
const eventSourceOf = () => {
|
|
445
|
+
const sessions = ctx.get('sessions')
|
|
446
|
+
if (!sessions || typeof sessions.binding !== 'function') return null
|
|
447
|
+
const binding = sessions.binding(sessionId)
|
|
448
|
+
return binding && binding.eventSource ? binding.eventSource : null
|
|
449
|
+
}
|
|
450
|
+
const attach = (source) => {
|
|
451
|
+
if (unsubscribe) { unsubscribe(); unsubscribe = null }
|
|
452
|
+
subscribedTo = source
|
|
453
|
+
if (source) { unsubscribe = source.subscribe(wake); return }
|
|
454
|
+
const sessions = ctx.get('sessions')
|
|
455
|
+
const list = sessions && sessions.list
|
|
456
|
+
if (list && typeof list.subscribe === 'function' && listeners.length) unsubscribe = list.subscribe(wake)
|
|
457
|
+
}
|
|
458
|
+
const sync = () => {
|
|
459
|
+
const source = eventSourceOf()
|
|
460
|
+
if (listeners.length) { if (source !== subscribedTo) attach(source) }
|
|
461
|
+
else if (subscribedTo) attach(null)
|
|
462
|
+
return source
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
getSnapshot: () => {
|
|
466
|
+
const source = sync()
|
|
467
|
+
if (!source) return MEMORY_STATE_UNAVAILABLE
|
|
468
|
+
const window = source.getSnapshot()
|
|
469
|
+
if (window !== cachedWindow) { cachedWindow = window; cachedState = readMemoryState(window) }
|
|
470
|
+
return cachedState
|
|
471
|
+
},
|
|
472
|
+
subscribe: (listener) => {
|
|
473
|
+
listeners.push(listener)
|
|
474
|
+
sync()
|
|
475
|
+
return () => {
|
|
476
|
+
const index = listeners.indexOf(listener)
|
|
477
|
+
if (index >= 0) listeners.splice(index, 1)
|
|
478
|
+
if (!listeners.length) attach(null)
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Selector for the injected memory hook: the source caches by window, so identity holds. */
|
|
485
|
+
function memoryStateOf(value) { return value === undefined ? MEMORY_STATE_NONE : value }
|
|
486
|
+
|
|
487
|
+
/** The sheet the panels sit on: their ink colours are fixed, so the surface must be too. */
|
|
488
|
+
function sheet(key, children) {
|
|
489
|
+
return box({ key: key, border: '1px solid #E7E7E4', borderRadius: '12px', background: '#FFFFFF', padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' }, children)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The snapshot's OWN stamp, never the clock: a snapshot taken an hour ago must read so. */
|
|
493
|
+
function whenText(iso) {
|
|
494
|
+
const at = new Date(iso)
|
|
495
|
+
return Number.isNaN(at.getTime()) ? String(iso) : at.toLocaleString()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* What the tab says when there is nothing to draw.
|
|
500
|
+
*
|
|
501
|
+
* The distinctions matter: 'nothing has been published for this session yet' and 'a call ran but
|
|
502
|
+
* its result carries no snapshot' are different facts, and neither of them is zero entries.
|
|
503
|
+
* None of them is ever fixed by asking the reader to do something - the host publishes on its
|
|
504
|
+
* own, so an empty tab is a statement about the session, not a task for the user.
|
|
505
|
+
*/
|
|
506
|
+
function emptyMemoryCopy(state) {
|
|
507
|
+
if (state.kind === 'unavailable') {
|
|
508
|
+
return {
|
|
509
|
+
title: 'The session record is not attached yet',
|
|
510
|
+
lines: [
|
|
511
|
+
'This tab reads memory from the conversation it belongs to - the event window of the open session - and that window is not available here.',
|
|
512
|
+
'Settings, Memory still reads the live store through the host, so the store itself stays visible either way.',
|
|
513
|
+
],
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (state.kind === 'empty') {
|
|
517
|
+
return {
|
|
518
|
+
title: 'The last memory_map result carried no snapshot',
|
|
519
|
+
lines: [
|
|
520
|
+
'The call is part of this session, but no structured value is attached to its result: a call that failed, or one whose result was replaced while the context was compacted.',
|
|
521
|
+
'The host publishes a fresh snapshot on its own cadence, so this clears itself.',
|
|
522
|
+
],
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (state.kind === 'nested') {
|
|
526
|
+
return {
|
|
527
|
+
title: 'memory_map ran here, but not as a readable result',
|
|
528
|
+
lines: [
|
|
529
|
+
'This session called memory_map from inside a run_code program. Those calls are recorded as nested dispatches, which keep the rendered text and not the structured value this tab draws.',
|
|
530
|
+
'Calling memory_map directly - scope "session" or "all" - records a result the panels can read.',
|
|
531
|
+
],
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
title: 'Nothing published for this session yet',
|
|
536
|
+
lines: [
|
|
537
|
+
'This tab draws the memory snapshot the host publishes into this conversation as it runs, so nothing here has to be asked for.',
|
|
538
|
+
'Nothing has been published for this session yet. It arrives with the first turn and refreshes as the store changes.',
|
|
539
|
+
],
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The honest empty state: prose that says what is missing, never a shape that reads as data. */
|
|
544
|
+
function MemoryEmpty(props) {
|
|
545
|
+
const copy = emptyMemoryCopy(props.state)
|
|
546
|
+
const lines = copy.lines.map(function (line, index) {
|
|
547
|
+
return react.createElement('div', { key: String(index), style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: MUTED } }, line)
|
|
548
|
+
})
|
|
549
|
+
return sheet('empty', [
|
|
550
|
+
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '13px', fontWeight: 500, color: INK } }, copy.title),
|
|
551
|
+
box({ key: 'b', display: 'flex', flexDirection: 'column', gap: '8px' }, lines),
|
|
552
|
+
])
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The panels, drawn from the snapshot memory_map actually returned and nothing else. */
|
|
556
|
+
function MemoryPanels(props) {
|
|
557
|
+
const meta = props.meta
|
|
558
|
+
const store = meta.store || null
|
|
559
|
+
const session = meta.session || null
|
|
560
|
+
const parts = []
|
|
561
|
+
const chips = [react.createElement(Chip, { key: 'scope', text: 'scope ' + String(meta.scope) })]
|
|
562
|
+
if (meta.generatedAt) chips.push(react.createElement(Chip, { key: 'at', text: whenText(meta.generatedAt) }))
|
|
563
|
+
if (session) chips.push(react.createElement(Chip, { key: 'where', text: session.workspace ? 'workspace ' + session.workspace : 'global layer' }))
|
|
564
|
+
parts.push(box({ key: 'chips', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips))
|
|
565
|
+
if (store) {
|
|
566
|
+
const attention = (store.queue && store.queue.attention) || []
|
|
567
|
+
const queue = attention.slice(0, 3).map(function (row) {
|
|
568
|
+
return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true })
|
|
569
|
+
})
|
|
570
|
+
if (store.queue && store.queue.waiting) queue.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }))
|
|
571
|
+
parts.push(sheet('store', [
|
|
572
|
+
sectionLabel('STORE', fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes'),
|
|
573
|
+
react.createElement(LayerBar, { key: 'bar', layers: store.layers, height: '30px' }),
|
|
574
|
+
react.createElement(Legend, { key: 'leg', layers: store.layers }),
|
|
575
|
+
box({ key: 'f', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }, [
|
|
576
|
+
box({ key: 'st', display: 'flex', gap: '18px' }, [
|
|
577
|
+
react.createElement(Stat, { key: 'a', value: fmt(store.freshness.fresh), label: 'fresh' }),
|
|
578
|
+
store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
|
|
579
|
+
store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
|
|
580
|
+
]),
|
|
581
|
+
box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, queue),
|
|
582
|
+
]),
|
|
583
|
+
]))
|
|
584
|
+
}
|
|
585
|
+
// A panel with nothing in it is omitted, or said in one line of prose: an empty card is
|
|
586
|
+
// a shape that reads as data, which is exactly what this tab must never draw.
|
|
587
|
+
const received = session ? session.received : null
|
|
588
|
+
if (received && received.sections && received.sections.length) {
|
|
589
|
+
parts.push(sheet('received', [react.createElement(ReceivedPanel, { key: 'r', received: received })]))
|
|
590
|
+
}
|
|
591
|
+
const written = session ? session.written : null
|
|
592
|
+
if (written && written.total) {
|
|
593
|
+
parts.push(sheet('written', [react.createElement(WrittenPanel, { key: 'w', written: written })]))
|
|
594
|
+
} else if (written) {
|
|
595
|
+
parts.push(react.createElement('div', {
|
|
596
|
+
key: 'none',
|
|
597
|
+
style: { fontFamily: GEIST, fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
598
|
+
}, 'This session has not recorded an entry yet.'))
|
|
599
|
+
}
|
|
600
|
+
parts.push(react.createElement('div', {
|
|
601
|
+
key: 'src',
|
|
602
|
+
style: { fontFamily: GEIST, fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.6 },
|
|
603
|
+
}, 'Published by the host into this session (event #' + fmt(props.seq) + '), not re-read from the store. It refreshes on its own as the store changes.'))
|
|
604
|
+
return box({ display: 'flex', flexDirection: 'column', gap: '18px' }, parts)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The Memory tab.
|
|
609
|
+
*
|
|
610
|
+
* The hook arrives as part of this entry's injected standard kit: the inject face
|
|
611
|
+
* publishes hooks.memory, which the renderer binds to a useMemory prop, exactly as
|
|
612
|
+
* ui-trajectory's view receives useDuration. It is present for the whole life of a mount
|
|
613
|
+
* or absent for the whole of it, so this branch never changes hook order. The selector is
|
|
614
|
+
* required: the bound hook is a selector hook and calls it without a guard.
|
|
615
|
+
*/
|
|
616
|
+
function MemoryTabView(props) {
|
|
617
|
+
const useMemory = props && typeof props.useMemory === 'function' ? props.useMemory : null
|
|
618
|
+
const state = useMemory ? useMemory(memoryStateOf) : MEMORY_STATE_NONE
|
|
619
|
+
const page = { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }
|
|
620
|
+
const inner = { maxWidth: '760px', margin: '0 auto', padding: '26px 28px 44px', display: 'flex', flexDirection: 'column', gap: '18px' }
|
|
621
|
+
const head = box({ display: 'flex', alignItems: 'center', gap: '10px' }, [
|
|
622
|
+
react.createElement(Glyph, { key: 'g' }),
|
|
623
|
+
// Theme token, not the fixed ink palette: this line sits on the shell background,
|
|
624
|
+
// which the palette knows nothing about.
|
|
625
|
+
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '15px', fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, 'Memory'),
|
|
626
|
+
])
|
|
627
|
+
const intro = react.createElement('div', {
|
|
628
|
+
key: 'i',
|
|
629
|
+
style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary)' },
|
|
630
|
+
}, 'What memory put in front of this conversation, what the conversation wrote back, and the state of the store.')
|
|
631
|
+
const body = state.kind === 'ready'
|
|
632
|
+
? react.createElement(MemoryPanels, { key: 'p', meta: state.meta, seq: state.seq })
|
|
633
|
+
: react.createElement(MemoryEmpty, { key: 'e', state: state })
|
|
634
|
+
return box(page, [box(inner, [head, intro, body])])
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Fields rendered on this page, in order. */
|
|
638
|
+
const FIELDS = [
|
|
639
|
+
{ key: 'memoryRoot', label: 'Memory root', hint: 'Local folder or drive that holds the store.', placeholder: 'D:\\Memory' },
|
|
640
|
+
{ key: 'gitRemote', label: 'GitHub origin', hint: 'Git remote URL. Leave empty to keep the store local-only.', placeholder: 'git@github.com:you/memory.git' },
|
|
641
|
+
{ key: 'gitBranch', label: 'Branch', hint: 'Branch used for pull and push.', placeholder: 'main' },
|
|
642
|
+
{ key: 'gitToken', label: 'Access token (optional)', kind: 'secret', hint: 'Personal access token for pushing. Stored by the host and never sent back to this page, so the field is always empty: type a new token to replace it, and leave it empty to keep the current one.' },
|
|
643
|
+
]
|
|
644
|
+
|
|
645
|
+
// The settings namespace is the plugin's ENTRY ID, not its package name. The bundle patch mounts
|
|
646
|
+
// the package @try-works/dsh-memory as the row `dsh-memory`, while a hand-written row may use
|
|
647
|
+
// a short id like `memory`. Both must work, so the candidates are tried in order and
|
|
648
|
+
// the first one the host actually serves wins.
|
|
649
|
+
const NAMESPACE_CANDIDATES = ['memory', 'dsh-memory']
|
|
650
|
+
|
|
651
|
+
function apply(ctx) {
|
|
652
|
+
// configForms.get() is lazy: it resolves once the namespace appears in the mirror, so
|
|
653
|
+
// creating a form for a candidate that is never served costs nothing.
|
|
654
|
+
const forms = NAMESPACE_CANDIDATES.map((ns) => ctx.configForms.get(ns))
|
|
655
|
+
const served = () => forms.find((f) => {
|
|
656
|
+
const snap = f.getSnapshot()
|
|
657
|
+
return snap.status === 'ready' || (snap.value !== undefined)
|
|
658
|
+
})
|
|
659
|
+
// Fall back to the first candidate so the page still renders its explanatory copy.
|
|
660
|
+
const form = {
|
|
661
|
+
getSnapshot: () => (served() ?? forms[0]).getSnapshot(),
|
|
662
|
+
subscribe: (listener) => {
|
|
663
|
+
const disposers = forms.map((f) => f.subscribe(listener))
|
|
664
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
665
|
+
},
|
|
666
|
+
set: (field, value) => (served() ?? forms[0]).set(field, value),
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Read the value a field should display: the draft, else the saved value. */
|
|
670
|
+
function fieldValue(key, value, draft) {
|
|
671
|
+
if (draft[key] !== undefined) return draft[key]
|
|
672
|
+
const saved = value[key]
|
|
673
|
+
return saved === undefined || saved === null ? '' : saved
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* One secret input. The value is deliberately NEVER read back from the snapshot: the
|
|
678
|
+
* host redacts it, so echoing it would display a placeholder and - worse - a later save
|
|
679
|
+
* could write that placeholder over the real token. Empty therefore means 'leave it'.
|
|
680
|
+
*/
|
|
681
|
+
function secretField(field, draft, edit, disabled) {
|
|
682
|
+
return react.createElement('div', { className: 'dsm-field', key: field.key },
|
|
683
|
+
react.createElement('label', { className: 'dsm-field-label', htmlFor: 'dsm-' + field.key }, field.label),
|
|
684
|
+
react.createElement('input', {
|
|
685
|
+
id: 'dsm-' + field.key,
|
|
686
|
+
className: 'dsm-input',
|
|
687
|
+
type: 'password',
|
|
688
|
+
autoComplete: 'off',
|
|
689
|
+
placeholder: 'unchanged',
|
|
690
|
+
value: draft[field.key] !== undefined ? draft[field.key] : '',
|
|
691
|
+
disabled,
|
|
692
|
+
onChange: (event) => edit(field.key, event.target.value),
|
|
693
|
+
}),
|
|
694
|
+
react.createElement('p', { className: 'dsm-field-help' }, field.hint))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** One labelled text input. */
|
|
698
|
+
function textField(field, value, draft, edit, disabled) {
|
|
699
|
+
return react.createElement('div', { className: 'dsm-field', key: field.key },
|
|
700
|
+
react.createElement('label', { className: 'dsm-field-label', htmlFor: 'dsm-' + field.key }, field.label),
|
|
701
|
+
react.createElement('input', {
|
|
702
|
+
id: 'dsm-' + field.key,
|
|
703
|
+
className: 'dsm-input',
|
|
704
|
+
type: 'text',
|
|
705
|
+
placeholder: field.placeholder,
|
|
706
|
+
value: fieldValue(field.key, value, draft),
|
|
707
|
+
disabled,
|
|
708
|
+
onChange: (event) => edit(field.key, event.target.value),
|
|
709
|
+
}),
|
|
710
|
+
react.createElement('p', { className: 'dsm-field-help' }, field.hint))
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The model picker: the configured catalog plus "session default" and any stale value. */
|
|
714
|
+
function modelField(value, draft, edit, disabled, models) {
|
|
715
|
+
const selected = fieldValue('summaryModel', value, draft)
|
|
716
|
+
const common = {
|
|
717
|
+
id: 'dsm-summaryModel',
|
|
718
|
+
label: 'Model for memory writes',
|
|
719
|
+
hint: 'Preferred model for summarizing sessions and consolidating them into entries. If it cannot answer, the next available model is tried automatically, so leaving this empty is fine.',
|
|
720
|
+
}
|
|
721
|
+
if (models === null) {
|
|
722
|
+
// Still loading: stay usable as a text field rather than blocking the page.
|
|
723
|
+
return react.createElement('div', { className: 'dsm-field', key: 'summaryModel' },
|
|
724
|
+
react.createElement('label', { className: 'dsm-field-label', htmlFor: common.id }, common.label),
|
|
725
|
+
react.createElement('input', {
|
|
726
|
+
id: common.id, className: 'dsm-input', type: 'text', placeholder: 'provider/model',
|
|
727
|
+
value: selected, disabled, onChange: (event) => edit('summaryModel', event.target.value),
|
|
728
|
+
}),
|
|
729
|
+
react.createElement('p', { className: 'dsm-field-help' }, common.hint))
|
|
730
|
+
}
|
|
731
|
+
const options = [{ value: '', label: 'Session default (same model as the chat)' }].concat(models)
|
|
732
|
+
if (selected && !models.some((option) => option.value === selected)) {
|
|
733
|
+
options.push({ value: selected, label: selected + ' (not currently configured)' })
|
|
734
|
+
}
|
|
735
|
+
return react.createElement('div', { className: 'dsm-field', key: 'summaryModel' },
|
|
736
|
+
react.createElement('label', { className: 'dsm-field-label', htmlFor: common.id }, common.label),
|
|
737
|
+
react.createElement('select', {
|
|
738
|
+
id: common.id, className: 'dsm-select', value: selected, disabled,
|
|
739
|
+
onChange: (event) => edit('summaryModel', event.target.value),
|
|
740
|
+
}, options.map((option) => react.createElement('option', { key: option.value, value: option.value }, option.label))),
|
|
741
|
+
react.createElement('p', { className: 'dsm-field-help' }, common.hint))
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function MemorySettings() {
|
|
745
|
+
const [snap, setSnap] = react.useState(() => form.getSnapshot())
|
|
746
|
+
const [draft, setDraft] = react.useState({})
|
|
747
|
+
const [status, setStatus] = react.useState(null)
|
|
748
|
+
react.useEffect(() => form.subscribe(() => setSnap(form.getSnapshot())), [])
|
|
749
|
+
|
|
750
|
+
// Model list for the write-model picker, from the same catalog the chat uses.
|
|
751
|
+
const [models, setModels] = react.useState(null)
|
|
752
|
+
react.useEffect(() => {
|
|
753
|
+
let alive = true
|
|
754
|
+
Promise.resolve()
|
|
755
|
+
.then(() => ctx.remote.session.modelCatalog())
|
|
756
|
+
.then((response) => {
|
|
757
|
+
if (!alive) return
|
|
758
|
+
const groups = response && response.ok && response.value ? (response.value.groups || []) : []
|
|
759
|
+
const options = []
|
|
760
|
+
for (const group of groups) {
|
|
761
|
+
for (const model of (group.models || [])) {
|
|
762
|
+
options.push({ value: group.id + '/' + model.id, label: group.name + ' - ' + (model.name || model.id) })
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
setModels(options)
|
|
766
|
+
})
|
|
767
|
+
.catch(() => { if (alive) setModels([]) })
|
|
768
|
+
return () => { alive = false }
|
|
769
|
+
}, [])
|
|
770
|
+
|
|
771
|
+
const value = snap.value || {}
|
|
772
|
+
const edit = (key, next) => setDraft((previous) => {
|
|
773
|
+
const updated = { ...previous }
|
|
774
|
+
updated[key] = next
|
|
775
|
+
return updated
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
if (snap.status === 'unavailable') {
|
|
779
|
+
return react.createElement('div', { className: 'dsm-page' },
|
|
780
|
+
react.createElement('style', null, CSS),
|
|
781
|
+
react.createElement('h2', { className: 'dsm-title' }, 'Memory'),
|
|
782
|
+
react.createElement('p', { className: 'dsm-intro' }, 'The memory configuration namespace is not exposed to this client.'))
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const save = async () => {
|
|
786
|
+
setStatus({ kind: 'busy', text: 'Saving...' })
|
|
787
|
+
try {
|
|
788
|
+
let refused = false
|
|
789
|
+
for (const field of FIELDS.concat([{ key: 'summaryModel' }])) {
|
|
790
|
+
const next = draft[field.key]
|
|
791
|
+
// An empty draft means 'not edited'. For a secret that is essential: writing an
|
|
792
|
+
// empty value would erase the token, and writing the redacted snapshot value
|
|
793
|
+
// would replace it with a placeholder.
|
|
794
|
+
if (next === undefined || next === '' || next === value[field.key]) continue
|
|
795
|
+
const accepted = await form.set(field.key, next)
|
|
796
|
+
if (accepted === false) refused = true
|
|
797
|
+
}
|
|
798
|
+
setStatus(refused
|
|
799
|
+
? { kind: 'error', text: 'Refused by the host - see the dsh logs.' }
|
|
800
|
+
: { kind: 'ok', text: 'Saved.' })
|
|
801
|
+
} catch (error) {
|
|
802
|
+
setStatus({ kind: 'error', text: String((error && error.message) || error) })
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const disabled = !snap.writable
|
|
807
|
+
return react.createElement('div', { className: 'dsm-page' },
|
|
808
|
+
react.createElement('style', null, CSS),
|
|
809
|
+
react.createElement('h2', { className: 'dsm-title' }, 'Memory'),
|
|
810
|
+
react.createElement('p', { className: 'dsm-intro' },
|
|
811
|
+
'Git-backed memory store, scoped to this workspace. Changes apply live and persist to this profile.'),
|
|
812
|
+
react.createElement('div', { className: 'dsm-card' },
|
|
813
|
+
// Rendered by kind, not by index: index-based rendering silently mis-assigned fields
|
|
814
|
+
// the moment one was inserted in the middle.
|
|
815
|
+
...FIELDS.map((field) => {
|
|
816
|
+
if (field.kind === 'secret') return secretField(field, draft, edit, disabled)
|
|
817
|
+
if (field.kind === 'model') return modelField(value, draft, edit, disabled, models)
|
|
818
|
+
return textField(field, value, draft, edit, disabled)
|
|
819
|
+
})),
|
|
820
|
+
react.createElement('div', { className: 'dsm-actions' },
|
|
821
|
+
react.createElement('button', {
|
|
822
|
+
className: 'dsm-button', type: 'button', disabled, onClick: save,
|
|
823
|
+
}, 'Save'),
|
|
824
|
+
status
|
|
825
|
+
? react.createElement('p', {
|
|
826
|
+
className: 'dsm-status' + (status.kind === 'ok' ? ' dsm-status-ok' : status.kind === 'error' ? ' dsm-status-error' : ''),
|
|
827
|
+
}, status.text)
|
|
828
|
+
: null,
|
|
829
|
+
snap.writable ? null : react.createElement('p', { className: 'dsm-status' }, 'Read-only: connected from a remote browser.')),
|
|
830
|
+
react.createElement('p', { className: 'dsm-note' },
|
|
831
|
+
'Store layout: ',
|
|
832
|
+
react.createElement('span', { className: 'dsm-code' }, 'global/'),
|
|
833
|
+
' (identity, preferences, standing rules) plus ',
|
|
834
|
+
react.createElement('span', { className: 'dsm-code' }, 'workspaces/<key>/'),
|
|
835
|
+
' per associated local folder or repo. Derived state (index/, state/, digests/, logs/) is gitignored and regenerated. Cadence and summarizer budgets are advanced tuning kept in the profile config file, not here.'))
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
ctx.effect(() => {
|
|
839
|
+
const disposers = []
|
|
840
|
+
// The inline face of memory_map, registered by the tool's WIRE NAME, which is how the
|
|
841
|
+
// host finds it. The result carries the data, so the view needs no other channel.
|
|
842
|
+
disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
|
843
|
+
name: 'tool.call.toolview',
|
|
844
|
+
key: 'memory_map',
|
|
845
|
+
}, MemoryMapCard)))
|
|
846
|
+
// The settings page registers FIRST, and deliberately. It is the established surface;
|
|
847
|
+
// the tab below is newer and depends on two services the page does not need. Ordering
|
|
848
|
+
// the proven surface before the unproven one means a failure in the tab cannot take the
|
|
849
|
+
// settings page down with it - both halves share one file and one apply().
|
|
850
|
+
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
851
|
+
name: 'settings.section',
|
|
852
|
+
id: 'memory',
|
|
853
|
+
order: 80,
|
|
854
|
+
label: 'Memory',
|
|
855
|
+
}, () => react.createElement(MemorySettings))))
|
|
856
|
+
// The Memory tab. Its data is injected by THIS entry rather than registered globally
|
|
857
|
+
// through uiSession.provide: the source is per-session and read from the session's own
|
|
858
|
+
// event window, and an entry-owned inject face is cached per (entry x session), so the
|
|
859
|
+
// hook the renderer binds (useMemory) is created once per conversation.
|
|
860
|
+
try {
|
|
861
|
+
disposers.push(ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
862
|
+
name: 'conversation.view',
|
|
863
|
+
id: 'memory',
|
|
864
|
+
// ui-chat holds 0 and ui-trajectory 10, so Memory sits after both without
|
|
865
|
+
// reordering anyone else's tab.
|
|
866
|
+
order: 20,
|
|
867
|
+
label: 'Memory',
|
|
868
|
+
inject: (sessionId) => ({ hooks: { memory: createMemorySource(ctx, sessionId) } }),
|
|
869
|
+
}, MemoryTabView)))
|
|
870
|
+
} catch (error) {
|
|
871
|
+
// Loud, not silent. A tab that fails to register is invisible by definition, so this
|
|
872
|
+
// warning is the only trace the failure can leave.
|
|
873
|
+
ctx.logger.warn('dsh-memory: Memory tab unavailable: ' + (error && error.message ? error.message : String(error)))
|
|
874
|
+
}
|
|
875
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
876
|
+
}, 'dsh-memory: settings section')
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
exports.name = 'dsh-memory'
|
|
880
|
+
// 'sessions' is deliberately NOT here: the tab reaches it with ctx.get() so that a
|
|
881
|
+
// missing service can only blank the tab, never hold back the settings page too.
|
|
882
|
+
exports.inject = ['slots', 'configForms', 'remote.session']
|
|
883
|
+
exports.apply = apply
|
|
884
|
+
return module.exports
|
|
885
|
+
},
|
|
886
|
+
})
|