@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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -866
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. package/src/workspace.js +87 -87
@@ -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
+ })