@try-works/dsh-memory 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/client/index.js +294 -1
- package/src/host.js +4 -1
- package/src/index.js +1 -0
- package/src/map.js +184 -0
- package/src/tools.js +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@try-works/dsh-memory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Git-backed, workspace-scoped memory for the DeepSeek Harness (global layer + per-workspace layers)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"client": {
|
|
49
49
|
"platform": "web",
|
|
50
50
|
"inject": [
|
|
51
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
52
53
|
],
|
|
53
54
|
"external": []
|
|
54
55
|
}
|
package/src/client/index.js
CHANGED
|
@@ -148,6 +148,267 @@ window.__ModuleLoader__.load({
|
|
|
148
148
|
'}',
|
|
149
149
|
].join('\n')
|
|
150
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 most recent memory_map result in this session. That is not a
|
|
346
|
+
* convenience: a browser half cannot ask the host for anything, so the session record is
|
|
347
|
+
* the only channel, and a tool result carries its structured value on `meta` (ui-chat's
|
|
348
|
+
* conversation-nodes/tool.ts does `meta: match.event.data.meta`).
|
|
349
|
+
*
|
|
350
|
+
* READING IT: a conversation view gets data through a registered standard hook, so the
|
|
351
|
+
* plugin provides `memoryMap` via ctx.uiSession.provide and this component receives
|
|
352
|
+
* `useMemoryMap` as a prop, the way ui-tool's rows receive their history hook. The hook
|
|
353
|
+
* resolves by scanning the Trajectory target's event nodes for a settled memory_map tool
|
|
354
|
+
* call; the Trajectory target is the shipped assembly of this session's nodes, and its
|
|
355
|
+
* snapshot contract is eventNodes: ConversationNode[] with { kind: 'tool', root }.
|
|
356
|
+
*/
|
|
357
|
+
function findMemorySnapshot(snapshot) {
|
|
358
|
+
if (!snapshot || !Array.isArray(snapshot.eventNodes)) return null
|
|
359
|
+
// Newest first: the tab describes the session as it stands now, not as it first was.
|
|
360
|
+
for (let i = snapshot.eventNodes.length - 1; i >= 0; i -= 1) {
|
|
361
|
+
const node = snapshot.eventNodes[i]
|
|
362
|
+
const root = node && node.kind === 'tool' ? node.root : null
|
|
363
|
+
if (!root || root.isError) continue
|
|
364
|
+
const name = root.call && root.call.name
|
|
365
|
+
if (name !== 'memory_map') continue
|
|
366
|
+
if (root.meta && typeof root.meta === 'object') return root.meta
|
|
367
|
+
}
|
|
368
|
+
return null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function MemoryTabView(props) {
|
|
372
|
+
const useMemoryMap = props ? props.useMemoryMap : null
|
|
373
|
+
const meta = typeof useMemoryMap === 'function' ? useMemoryMap() : null
|
|
374
|
+
const page = { padding: '32px 28px 0 28px', display: 'flex', flexDirection: 'column', gap: '0' }
|
|
375
|
+
if (!meta) {
|
|
376
|
+
// Nothing to draw yet, and saying so beats drawing an empty chart that reads as zero.
|
|
377
|
+
const empty = { display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '520px' }
|
|
378
|
+
return box(page, [box(empty, [
|
|
379
|
+
react.createElement('div', { key: 'h', style: { fontFamily: GEIST, fontSize: '15px', fontWeight: 500, color: INK } }, 'No memory snapshot in this session yet'),
|
|
380
|
+
react.createElement('div', { key: 'p', style: { fontFamily: GEIST, fontSize: '13px', lineHeight: 1.6, color: MUTED } }, 'This tab shows what memory put in front of this conversation, what the conversation wrote back, and the state of the store. It fills in once memory_map runs in this session.'),
|
|
381
|
+
])]);
|
|
382
|
+
}
|
|
383
|
+
const store = meta.store || null
|
|
384
|
+
const session = meta.session || null
|
|
385
|
+
const parts = []
|
|
386
|
+
if (session) {
|
|
387
|
+
const who = session.workspace ? 'workspace ' + session.workspace : 'global layer'
|
|
388
|
+
parts.push(react.createElement('div', { key: 'id', style: { fontFamily: GEIST, fontSize: '13px', color: '#3A3A38', paddingBottom: '26px' } }, 'This session · ' + who));
|
|
389
|
+
if (session.received) parts.push(box({ key: 'r', display: 'flex', flexDirection: 'column', gap: '12px', maxWidth: '460px' }, [react.createElement(ReceivedPanel, { key: 'p', received: session.received })]));
|
|
390
|
+
if (session.written) parts.push(box({ key: 'w', marginTop: '34px', paddingTop: '26px', borderTop: '1px solid ' + LINE, display: 'flex', flexDirection: 'column' }, [react.createElement(WrittenPanel, { key: 'p', written: session.written })]));
|
|
391
|
+
}
|
|
392
|
+
if (store) {
|
|
393
|
+
const attention = (store.queue && store.queue.attention) || []
|
|
394
|
+
const chips = attention.slice(0, 3).map(function (row) { return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true }) });
|
|
395
|
+
if (store.queue && store.queue.waiting) chips.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }));
|
|
396
|
+
parts.push(box({ key: 's', marginTop: '34px', paddingTop: '26px', borderTop: '1px solid ' + LINE, display: 'flex', flexDirection: 'column', gap: '15px' }, [
|
|
397
|
+
sectionLabel('SNAPSHOT', fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes'),
|
|
398
|
+
react.createElement(LayerBar, { key: 'bar', layers: store.layers, height: '30px' }),
|
|
399
|
+
react.createElement(Legend, { key: 'leg', layers: store.layers }),
|
|
400
|
+
box({ key: 'f', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }, [
|
|
401
|
+
box({ key: 'st', display: 'flex', gap: '18px' }, [
|
|
402
|
+
react.createElement(Stat, { key: 'a', value: fmt(store.freshness.fresh), label: 'fresh' }),
|
|
403
|
+
store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
|
|
404
|
+
store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
|
|
405
|
+
]),
|
|
406
|
+
box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips),
|
|
407
|
+
]),
|
|
408
|
+
]));
|
|
409
|
+
}
|
|
410
|
+
return box(page, parts);
|
|
411
|
+
}
|
|
151
412
|
/** Fields rendered on this page, in order. */
|
|
152
413
|
const FIELDS = [
|
|
153
414
|
{ key: 'memoryRoot', label: 'Memory root', hint: 'Local folder or drive that holds the store.', placeholder: 'D:\\Memory' },
|
|
@@ -351,6 +612,38 @@ window.__ModuleLoader__.load({
|
|
|
351
612
|
|
|
352
613
|
ctx.effect(() => {
|
|
353
614
|
const disposers = []
|
|
615
|
+
// The inline face of memory_map, registered by the tool's WIRE NAME, which is how the
|
|
616
|
+
// host finds it. The result carries the data, so the view needs no other channel.
|
|
617
|
+
disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
|
618
|
+
name: 'tool.call.toolview',
|
|
619
|
+
key: 'memory_map',
|
|
620
|
+
}, MemoryMapCard)))
|
|
621
|
+
// The Memory tab. Data arrives through a registered standard hook, because a
|
|
622
|
+
// conversation view is a slot and slots are fed by hooks, not by direct reads.
|
|
623
|
+
// The Trajectory target is the shipped assembly of this session's nodes, so the
|
|
624
|
+
// source re-derives the memory snapshot from it on every projection.
|
|
625
|
+
ctx.effect(() => ctx.uiSession.provide({
|
|
626
|
+
hooks: ['memoryMap'],
|
|
627
|
+
resolve: (binding) => {
|
|
628
|
+
const target = ctx.uiConversation.binding(binding).target('trajectory')
|
|
629
|
+
return {
|
|
630
|
+
hooks: {
|
|
631
|
+
memoryMap: {
|
|
632
|
+
getSnapshot: () => findMemorySnapshot(target.getSnapshot()),
|
|
633
|
+
subscribe: (listener) => target.subscribe(listener),
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
}), 'dsh-memory: memory map hook')
|
|
639
|
+
disposers.push(ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
640
|
+
name: 'conversation.view',
|
|
641
|
+
id: 'memory',
|
|
642
|
+
// ui-chat holds 0 and ui-trajectory 10, so Memory sits after both without
|
|
643
|
+
// reordering anyone else's tab.
|
|
644
|
+
order: 20,
|
|
645
|
+
label: 'Memory',
|
|
646
|
+
}, MemoryTabView)))
|
|
354
647
|
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
355
648
|
name: 'settings.section',
|
|
356
649
|
id: 'memory',
|
|
@@ -362,7 +655,7 @@ window.__ModuleLoader__.load({
|
|
|
362
655
|
}
|
|
363
656
|
|
|
364
657
|
exports.name = 'dsh-memory'
|
|
365
|
-
exports.inject = ['slots', 'configForms', 'remote.session']
|
|
658
|
+
exports.inject = ['slots', 'configForms', 'remote.session', 'uiSession', 'uiConversation']
|
|
366
659
|
exports.apply = apply
|
|
367
660
|
return module.exports
|
|
368
661
|
},
|
package/src/host.js
CHANGED
|
@@ -690,5 +690,8 @@ export function wireHost(ctx, store, cfg) {
|
|
|
690
690
|
})
|
|
691
691
|
}
|
|
692
692
|
|
|
693
|
-
|
|
693
|
+
// injectionMaxChars travels with the host so memory_map can describe the card that was
|
|
694
|
+
// ACTUALLY built: the cap is configuration, and a snapshot built with a different cap would
|
|
695
|
+
// report a size the session never saw.
|
|
696
|
+
return { scheduler, syncNow: () => runSync(true), consolidate: consolidateNow, refresh: refreshNow, status: statusNow, recover: recoverNow, rerankSearch, workspaceOf, historyOf, injectionMaxChars: cfg.injectionMaxChars }
|
|
694
697
|
}
|
package/src/index.js
CHANGED
package/src/map.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The UI snapshot behind memory_map: one structured view of the store, and of what THIS
|
|
2
|
+
// session has received from memory and written back to it.
|
|
3
|
+
//
|
|
4
|
+
// Three rules shape this module.
|
|
5
|
+
//
|
|
6
|
+
// Every number is DERIVED, never restated. Freshness comes from freshnessVerdict, the queue
|
|
7
|
+
// from the queue reader, and the RECEIVED panel from the injection's own rendered text. A
|
|
8
|
+
// second set of rules here would drift from the first, and the UI would then lie about the
|
|
9
|
+
// store it exists to describe.
|
|
10
|
+
//
|
|
11
|
+
// The snapshot is LOSSLESS JSON - no undefined, no Date, no Map. It crosses the tool boundary
|
|
12
|
+
// and a browser parses it, so one undefined rejects the whole result.
|
|
13
|
+
//
|
|
14
|
+
// Store and session are separate scopes because they answer different questions and have
|
|
15
|
+
// different lifetimes. The store is what EXISTS; the session is what THIS conversation saw
|
|
16
|
+
// and wrote. A tab can show either without pretending the other is live.
|
|
17
|
+
|
|
18
|
+
import { buildLedger } from './ledger.js'
|
|
19
|
+
import { LAYERS } from './entry.js'
|
|
20
|
+
import { freshnessVerdict } from './freshness.js'
|
|
21
|
+
import { buildInjectionText } from './inject.js'
|
|
22
|
+
import { problems, summarize } from './queue.js'
|
|
23
|
+
|
|
24
|
+
/** How many scopes and written entries the snapshot carries before it summarises instead. */
|
|
25
|
+
const SCOPE_LIMIT = 6
|
|
26
|
+
const WRITTEN_LIMIT = 8
|
|
27
|
+
|
|
28
|
+
/** Doubt, worst first: a group reports the most doubtful thing inside it, not the best. */
|
|
29
|
+
const SEVERITY = { fresh: 0, empty: 1, aging: 2, unverified: 3, stale: 4 }
|
|
30
|
+
|
|
31
|
+
function worstVerdict(verdicts) {
|
|
32
|
+
let worst = 'fresh'
|
|
33
|
+
for (const verdict of verdicts) {
|
|
34
|
+
if ((SEVERITY[verdict] ?? 0) > (SEVERITY[worst] ?? 0)) worst = verdict
|
|
35
|
+
}
|
|
36
|
+
return worst
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The workspace key a ledger file belongs to, or 'global' for the shared layer. */
|
|
40
|
+
export function scopeKeyOf(file) {
|
|
41
|
+
const normalised = String(file ?? '').replace(/\\/g, '/')
|
|
42
|
+
const match = normalised.match(/workspaces\/([^/]+)\//)
|
|
43
|
+
return match ? match[1] : 'global'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tally(values) {
|
|
47
|
+
const out = {}
|
|
48
|
+
for (const value of values) out[value] = (out[value] ?? 0) + 1
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whole-store composition: entries per layer, how fresh they are, which workspaces hold
|
|
54
|
+
* them, and what the ingest queue is doing.
|
|
55
|
+
* @param store - the memory store.
|
|
56
|
+
* @param options.ledger - a prebuilt ledger, so a caller needing two scopes reads the disk once.
|
|
57
|
+
*/
|
|
58
|
+
export function buildStoreSnapshot(store, { ledger, now = Date.now(), scopeLimit = SCOPE_LIMIT } = {}) {
|
|
59
|
+
const entries = ledger ?? buildLedger(store.root)
|
|
60
|
+
// The verdict vocabulary belongs to the freshness module. An unrecognised verdict counts as
|
|
61
|
+
// fresh, so a new one upstream shows up as a total that does not add up - visible - rather
|
|
62
|
+
// than as a crash or a silent zero.
|
|
63
|
+
const freshness = { fresh: 0, stale: 0, expired: 0, skip: 0 }
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const verdict = freshnessVerdict(entry, now).verdict
|
|
66
|
+
freshness[Object.hasOwn(freshness, verdict) ? verdict : 'fresh'] += 1
|
|
67
|
+
}
|
|
68
|
+
const layerCounts = tally(entries.map((e) => e.layer))
|
|
69
|
+
const scopeCounts = tally(entries.map((e) => scopeKeyOf(e.file)))
|
|
70
|
+
const scopes = Object.keys(scopeCounts)
|
|
71
|
+
.map((key) => ({ key, count: scopeCounts[key] }))
|
|
72
|
+
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
|
73
|
+
const states = summarize(store.root)
|
|
74
|
+
// Group problem rows by state and reason: 76 orphaned candidates are ONE fact about the
|
|
75
|
+
// store, not 76 lines of UI.
|
|
76
|
+
const grouped = new Map()
|
|
77
|
+
for (const problem of problems(store.root)) {
|
|
78
|
+
const reason = problem.reason ?? 'unclassified'
|
|
79
|
+
const key = problem.state + '|' + reason
|
|
80
|
+
const seen = grouped.get(key)
|
|
81
|
+
if (seen) seen.count += 1
|
|
82
|
+
else grouped.set(key, { state: problem.state, reason, count: 1 })
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
root: store.root,
|
|
86
|
+
total: entries.length,
|
|
87
|
+
layers: LAYERS.map((name) => ({ name, count: layerCounts[name] ?? 0 })),
|
|
88
|
+
freshness,
|
|
89
|
+
scopes: scopes.slice(0, scopeLimit),
|
|
90
|
+
scopeTotal: scopes.length,
|
|
91
|
+
queue: {
|
|
92
|
+
states,
|
|
93
|
+
waiting: (states.pending ?? 0) + (states.summarized ?? 0),
|
|
94
|
+
recoverable: states.orphaned ?? 0,
|
|
95
|
+
attention: [...grouped.values()].sort((a, b) => b.count - a.count),
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The marker vocabulary the injection actually emits. Matching only these keeps a bracketed
|
|
101
|
+
* word inside an entry's prose from being read as a freshness verdict. */
|
|
102
|
+
const MARKER = /\[(STALE|AGING|UNVERIFIED|NOT STARTED)\b[^\]]*\]/g
|
|
103
|
+
|
|
104
|
+
function verdictOfMarker(marker) {
|
|
105
|
+
if (marker.startsWith('[STALE')) return 'stale'
|
|
106
|
+
if (marker.startsWith('[AGING')) return 'aging'
|
|
107
|
+
if (marker.startsWith('[UNVERIFIED')) return 'unverified'
|
|
108
|
+
if (marker.startsWith('[NOT STARTED')) return 'empty'
|
|
109
|
+
return 'fresh'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read the sections back out of the injection's OWN rendered text.
|
|
114
|
+
*
|
|
115
|
+
* Parsing text is usually the wrong instinct, and here it is the right one: the question the
|
|
116
|
+
* panel answers is 'what was actually put in front of this session', and the only artefact
|
|
117
|
+
* that can answer it without the possibility of disagreement is the text itself. Recomputing
|
|
118
|
+
* the sections from the same inputs would be a second implementation of one rule.
|
|
119
|
+
*/
|
|
120
|
+
export function parseInjectionSections(text) {
|
|
121
|
+
const sections = []
|
|
122
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
123
|
+
const heading = raw.match(/^###\s+(.+?)\s*$/)
|
|
124
|
+
if (heading) {
|
|
125
|
+
const found = heading[1].match(MARKER) ?? []
|
|
126
|
+
// The heading is written for the agent ('Working memory - yours'); the panel labels the
|
|
127
|
+
// same thing more briefly, so the owner suffix is dropped at the boundary.
|
|
128
|
+
const name = heading[1].replace(MARKER, '').replace(/\s+\u2014\s+yours\s*$/, '').trim()
|
|
129
|
+
sections.push({ name, markers: [...found] })
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (sections.length === 0) continue
|
|
133
|
+
const found = raw.match(MARKER)
|
|
134
|
+
if (found) sections[sections.length - 1].markers.push(...found)
|
|
135
|
+
}
|
|
136
|
+
return sections.map((section) => {
|
|
137
|
+
const verdict = worstVerdict(section.markers.map(verdictOfMarker))
|
|
138
|
+
const marker = section.markers.find((m) => verdictOfMarker(m) === verdict) ?? ''
|
|
139
|
+
return { name: section.name, verdict, marker }
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What memory put in front of this session, sized and broken down as it was sent. */
|
|
144
|
+
export function buildReceived(store, { memoryScope = 'global', key, goal, maxChars } = {}) {
|
|
145
|
+
const text = buildInjectionText(store, { scope: memoryScope, key, goal, maxChars })
|
|
146
|
+
return { chars: text.length, sections: parseInjectionSections(text) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What THIS session wrote back. Attribution is exact rather than inferred from timestamps:
|
|
151
|
+
* every write already records source 'agent:<sessionId>', so two sessions writing in the same
|
|
152
|
+
* minute are still told apart.
|
|
153
|
+
*/
|
|
154
|
+
export function buildWritten(store, { ledger, sessionId, limit = WRITTEN_LIMIT } = {}) {
|
|
155
|
+
if (!sessionId) return { total: 0, byLayer: {}, entries: [] }
|
|
156
|
+
const marker = 'agent:' + sessionId
|
|
157
|
+
const written = (ledger ?? buildLedger(store.root)).filter((entry) => entry.source === marker)
|
|
158
|
+
const ordered = [...written].sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')))
|
|
159
|
+
return {
|
|
160
|
+
total: written.length,
|
|
161
|
+
byLayer: tally(written.map((e) => e.layer)),
|
|
162
|
+
entries: ordered.slice(0, limit).map((e) => ({ id: e.id, layer: e.layer, heading: e.heading ?? '' })),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One snapshot, either scope or both.
|
|
168
|
+
* @param options.scope - 'store' (what exists), 'session' (what this conversation saw and wrote), or 'all'.
|
|
169
|
+
* @param options.memoryScope - 'global' or 'workspace': which memory layer this session belongs to.
|
|
170
|
+
*/
|
|
171
|
+
export function buildMap(store, { scope = 'all', sessionId, memoryScope = 'global', key, goal, now = Date.now(), maxChars } = {}) {
|
|
172
|
+
const ledger = buildLedger(store.root)
|
|
173
|
+
const snapshot = { scope, generatedAt: new Date(now).toISOString() }
|
|
174
|
+
if (scope === 'store' || scope === 'all') snapshot.store = buildStoreSnapshot(store, { ledger, now })
|
|
175
|
+
if (scope === 'session' || scope === 'all') {
|
|
176
|
+
snapshot.session = {
|
|
177
|
+
sessionId: sessionId ?? null,
|
|
178
|
+
workspace: memoryScope === 'workspace' ? (key ?? null) : null,
|
|
179
|
+
received: buildReceived(store, { memoryScope, key, goal, maxChars }),
|
|
180
|
+
written: buildWritten(store, { ledger, sessionId }),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return snapshot
|
|
184
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -11,6 +11,7 @@ import { isSubagentSession } from './session.js'
|
|
|
11
11
|
import { appendActivity, clearActivity, readWorking, workingSummary, writeWorkingSection } from './working.js'
|
|
12
12
|
import { associate, listWorkspaces } from './workspace.js'
|
|
13
13
|
import { forget, forgetUndo } from './forget.js'
|
|
14
|
+
import { buildMap } from './map.js'
|
|
14
15
|
|
|
15
16
|
function renderHits(hits) {
|
|
16
17
|
if (!hits || hits.length === 0) return [{ type: 'text', text: '(no memory matches)' }]
|
|
@@ -29,6 +30,34 @@ function canonicalPath(pathArg, exec) {
|
|
|
29
30
|
return realpathSync(abs)
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
|
|
34
|
+
/** The text face of a map snapshot: what a caller without a UI still needs to know. */
|
|
35
|
+
function renderMap(value) {
|
|
36
|
+
const parts = []
|
|
37
|
+
const store = value.store
|
|
38
|
+
if (store) {
|
|
39
|
+
const layers = store.layers.filter((l) => l.count > 0).map((l) => l.name + ' ' + l.count).join(', ')
|
|
40
|
+
parts.push(store.total + ' entries at ' + store.root)
|
|
41
|
+
parts.push('layers: ' + layers)
|
|
42
|
+
const f = store.freshness
|
|
43
|
+
parts.push('freshness: ' + f.fresh + ' fresh' + (f.stale ? ', ' + f.stale + ' STALE' : '') + (f.skip ? ', ' + f.skip + ' superseded' : ''))
|
|
44
|
+
const q = store.queue
|
|
45
|
+
const attention = q.attention.length
|
|
46
|
+
? ', needs attention: ' + q.attention.slice(0, 3).map((a) => a.count + ' ' + a.state + ' (' + a.reason + ')').join(', ')
|
|
47
|
+
: ''
|
|
48
|
+
parts.push('queue: ' + q.waiting + ' waiting' + (q.recoverable ? ', ' + q.recoverable + ' recoverable' : '') + attention)
|
|
49
|
+
}
|
|
50
|
+
const session = value.session
|
|
51
|
+
if (session) {
|
|
52
|
+
const received = session.received
|
|
53
|
+
const sections = received.sections.map((s) => s.name + (s.marker ? ' ' + s.marker : '')).join(', ')
|
|
54
|
+
parts.push('this session received ' + received.chars + ' chars: ' + (sections || '(nothing injected)'))
|
|
55
|
+
const written = session.written
|
|
56
|
+
const byLayer = Object.keys(written.byLayer).map((k) => k + ' ' + written.byLayer[k]).join(', ')
|
|
57
|
+
parts.push('and wrote ' + written.total + ' entr' + (written.total === 1 ? 'y' : 'ies') + (byLayer ? ' (' + byLayer + ')' : ''))
|
|
58
|
+
}
|
|
59
|
+
return parts.join('; ') + '.'
|
|
60
|
+
}
|
|
32
61
|
export function registerTools(ctx, store, extras = {}) {
|
|
33
62
|
/**
|
|
34
63
|
* Refuse a WRITE from a subagent session.
|
|
@@ -50,6 +79,7 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
50
79
|
const rerankSearch = extras.rerankSearch
|
|
51
80
|
const statusNow = extras.status
|
|
52
81
|
const recoverNow = extras.recover
|
|
82
|
+
const injectionMaxChars = extras.injectionMaxChars
|
|
53
83
|
|
|
54
84
|
ctx.tools.register(defineTool({
|
|
55
85
|
name: 'memory_search',
|
|
@@ -275,6 +305,45 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
275
305
|
},
|
|
276
306
|
}))
|
|
277
307
|
|
|
308
|
+
ctx.tools.register(defineTool({
|
|
309
|
+
name: 'memory_map',
|
|
310
|
+
description: 'Show what memory holds, and what THIS session has done with it. scope "store" reports the whole store - entries per layer, freshness, workspaces, and the ingest queue. scope "session" reports what memory injected into this conversation and what this conversation wrote back. "all" returns both. Read-only, and the data behind the Memory view.',
|
|
311
|
+
parameters: {
|
|
312
|
+
scope: { type: 'string', description: 'Which view: "store" (what exists), "session" (what this conversation received and wrote), or "all" (default).' },
|
|
313
|
+
},
|
|
314
|
+
output: {
|
|
315
|
+
schema: {
|
|
316
|
+
type: 'object', additionalProperties: false,
|
|
317
|
+
properties: {
|
|
318
|
+
scope: { type: 'string', required: true },
|
|
319
|
+
generatedAt: { type: 'string', required: true },
|
|
320
|
+
store: { type: 'object', additionalProperties: true },
|
|
321
|
+
session: { type: 'object', additionalProperties: true },
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
render: (_args, value) => [{ type: 'text', text: renderMap(value) }],
|
|
325
|
+
},
|
|
326
|
+
async execute(args, exec) {
|
|
327
|
+
const scope = args.scope === 'store' || args.scope === 'session' ? args.scope : 'all'
|
|
328
|
+
// The session id is what attributes writes: every tool records source 'agent:<sessionId>'.
|
|
329
|
+
const sessionId = exec?.agent?.session?.id
|
|
330
|
+
let key
|
|
331
|
+
if (workspaceOf && exec?.agent) {
|
|
332
|
+
try { key = await workspaceOf(exec.agent) } catch { key = undefined }
|
|
333
|
+
}
|
|
334
|
+
// The goal and the char cap are read exactly as the injection reads them, so the
|
|
335
|
+
// RECEIVED panel describes the card that was actually built.
|
|
336
|
+
const goal = ctx.get('goals')?.get(exec?.agent)?.objective
|
|
337
|
+
return buildMap(store, {
|
|
338
|
+
scope,
|
|
339
|
+
sessionId,
|
|
340
|
+
memoryScope: key && key !== 'global' ? 'workspace' : 'global',
|
|
341
|
+
key: key && key !== 'global' ? key : undefined,
|
|
342
|
+
goal,
|
|
343
|
+
maxChars: injectionMaxChars,
|
|
344
|
+
})
|
|
345
|
+
},
|
|
346
|
+
}))
|
|
278
347
|
ctx.tools.register(defineTool({
|
|
279
348
|
name: 'memory_state',
|
|
280
349
|
description: 'Record or refresh one section of the CURRENT repository state overview (what is running, the layout, what is pending, health). Replaces that section: state describes now, so a newer statement supersedes the old one. Each section carries a Verified timestamp and stale sections are reported as such.',
|