@try-works/dsh-memory 0.1.5 → 0.1.7
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 +1 -1
- package/src/client/index.js +256 -70
package/package.json
CHANGED
package/src/client/index.js
CHANGED
|
@@ -342,59 +342,196 @@ window.__ModuleLoader__.load({
|
|
|
342
342
|
/**
|
|
343
343
|
* The Memory tab: this session's memory activity, in one place.
|
|
344
344
|
*
|
|
345
|
-
* Its data is the most recent memory_map result
|
|
345
|
+
* Its data is the most recent memory_map result of this conversation. That is not a
|
|
346
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`
|
|
348
|
-
* conversation-nodes/tool.ts
|
|
347
|
+
* the only channel, and a tool result carries its structured value on `meta` - the very
|
|
348
|
+
* object ui-chat's conversation-nodes/tool.ts forwards as `meta: match.event.data.meta`.
|
|
349
349
|
*
|
|
350
|
-
* READING IT:
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
350
|
+
* READING IT: the session's contiguous event window is published as the documented
|
|
351
|
+
* public member SessionBinding.eventSource (ObservableSnapshot<SessionEventWindow>, see
|
|
352
|
+
* api/session-controller/src/client/contract/events.ts), reached through
|
|
353
|
+
* ctx.sessions.binding(sessionId). That is the same source the Chat and Trajectory tabs
|
|
354
|
+
* are assembled from, so the tab adds no new channel and cannot disagree with them.
|
|
355
|
+
* Sessions is reached with ctx.get() because it is a reachable service, not a required
|
|
356
|
+
* one: making it an inject dependency would hold the whole plugin - Settings included -
|
|
357
|
+
* pending behind a service the settings page does not need.
|
|
356
358
|
*/
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
const MEMORY_STATE_NONE = { kind: 'none', meta: null, seq: null, time: null }
|
|
360
|
+
const MEMORY_STATE_UNAVAILABLE = { kind: 'unavailable', meta: null, seq: null, time: null }
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Find the newest memory_map result in the window and say what it carried.
|
|
364
|
+
*
|
|
365
|
+
* A tool/result event names its call only by id, so the call events are indexed first.
|
|
366
|
+
* A result whose tool/call pair has been paged out or pruned is still recognised by the
|
|
367
|
+
* shape of its own value: nothing but memory_map produces { scope, generatedAt }.
|
|
368
|
+
*/
|
|
369
|
+
function readMemoryState(window) {
|
|
370
|
+
const entries = window && Array.isArray(window.entries) ? window.entries : null
|
|
371
|
+
if (!entries) return MEMORY_STATE_NONE
|
|
372
|
+
const calls = Object.create(null)
|
|
373
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
374
|
+
const entry = entries[i]
|
|
375
|
+
const event = entry && entry.type === 'event' ? entry.event : null
|
|
376
|
+
if (event && event.type === 'tool/call' && event.data && event.data.name === 'memory_map') calls[event.data.callId] = true
|
|
377
|
+
}
|
|
359
378
|
// Newest first: the tab describes the session as it stands now, not as it first was.
|
|
360
|
-
for (let i =
|
|
361
|
-
const
|
|
362
|
-
const
|
|
363
|
-
if (!
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
379
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
380
|
+
const entry = entries[i]
|
|
381
|
+
const event = entry && entry.type === 'event' ? entry.event : null
|
|
382
|
+
if (!event || event.type !== 'tool/result') continue
|
|
383
|
+
const data = event.data || {}
|
|
384
|
+
const message = data.message || null
|
|
385
|
+
const callId = message && message.source ? message.source.callId : undefined
|
|
386
|
+
const meta = data.meta
|
|
387
|
+
const shaped = meta !== null && typeof meta === 'object'
|
|
388
|
+
&& typeof meta.generatedAt === 'string' && typeof meta.scope === 'string'
|
|
389
|
+
if (calls[callId] !== true && !shaped) continue
|
|
390
|
+
// A failed call is not a snapshot: it is the absence of one, and it is said so.
|
|
391
|
+
if (message && message.isError === true) continue
|
|
392
|
+
if (meta === null || typeof meta !== 'object') return { kind: 'empty', meta: null, seq: event.seq, time: event.time }
|
|
393
|
+
return { kind: 'ready', meta: meta, seq: event.seq, time: event.time }
|
|
367
394
|
}
|
|
368
|
-
return
|
|
395
|
+
return MEMORY_STATE_NONE
|
|
369
396
|
}
|
|
370
397
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
398
|
+
/**
|
|
399
|
+
* The tab's observable source over one session's event window.
|
|
400
|
+
*
|
|
401
|
+
* It re-resolves the binding on every read, so a session whose binding materializes
|
|
402
|
+
* after the tab has already rendered still fills in; while no window exists it listens
|
|
403
|
+
* to the session list, which publishes when a generation is retained or opened, so the
|
|
404
|
+
* tab wakes up instead of staying dark until an unrelated re-render. Values are cached by
|
|
405
|
+
* window identity, which keeps getSnapshot referentially stable between publications.
|
|
406
|
+
*/
|
|
407
|
+
function createMemorySource(ctx, sessionId) {
|
|
408
|
+
const listeners = []
|
|
409
|
+
let subscribedTo = null
|
|
410
|
+
let unsubscribe = null
|
|
411
|
+
let cachedWindow = null
|
|
412
|
+
let cachedState = MEMORY_STATE_NONE
|
|
413
|
+
const wake = () => {
|
|
414
|
+
cachedWindow = null
|
|
415
|
+
for (let i = 0; i < listeners.length; i += 1) listeners[i]()
|
|
416
|
+
}
|
|
417
|
+
const eventSourceOf = () => {
|
|
418
|
+
const sessions = ctx.get('sessions')
|
|
419
|
+
if (!sessions || typeof sessions.binding !== 'function') return null
|
|
420
|
+
const binding = sessions.binding(sessionId)
|
|
421
|
+
return binding && binding.eventSource ? binding.eventSource : null
|
|
422
|
+
}
|
|
423
|
+
const attach = (source) => {
|
|
424
|
+
if (unsubscribe) { unsubscribe(); unsubscribe = null }
|
|
425
|
+
subscribedTo = source
|
|
426
|
+
if (source) { unsubscribe = source.subscribe(wake); return }
|
|
427
|
+
const sessions = ctx.get('sessions')
|
|
428
|
+
const list = sessions && sessions.list
|
|
429
|
+
if (list && typeof list.subscribe === 'function' && listeners.length) unsubscribe = list.subscribe(wake)
|
|
430
|
+
}
|
|
431
|
+
const sync = () => {
|
|
432
|
+
const source = eventSourceOf()
|
|
433
|
+
if (listeners.length) { if (source !== subscribedTo) attach(source) }
|
|
434
|
+
else if (subscribedTo) attach(null)
|
|
435
|
+
return source
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
getSnapshot: () => {
|
|
439
|
+
const source = sync()
|
|
440
|
+
if (!source) return MEMORY_STATE_UNAVAILABLE
|
|
441
|
+
const window = source.getSnapshot()
|
|
442
|
+
if (window !== cachedWindow) { cachedWindow = window; cachedState = readMemoryState(window) }
|
|
443
|
+
return cachedState
|
|
444
|
+
},
|
|
445
|
+
subscribe: (listener) => {
|
|
446
|
+
listeners.push(listener)
|
|
447
|
+
sync()
|
|
448
|
+
return () => {
|
|
449
|
+
const index = listeners.indexOf(listener)
|
|
450
|
+
if (index >= 0) listeners.splice(index, 1)
|
|
451
|
+
if (!listeners.length) attach(null)
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Selector for the injected memory hook: the source caches by window, so identity holds. */
|
|
458
|
+
function memoryStateOf(value) { return value === undefined ? MEMORY_STATE_NONE : value }
|
|
459
|
+
|
|
460
|
+
/** The sheet the panels sit on: their ink colours are fixed, so the surface must be too. */
|
|
461
|
+
function sheet(key, children) {
|
|
462
|
+
return box({ key: key, border: '1px solid #E7E7E4', borderRadius: '12px', background: '#FFFFFF', padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' }, children)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The snapshot's OWN stamp, never the clock: a snapshot taken an hour ago must read so. */
|
|
466
|
+
function whenText(iso) {
|
|
467
|
+
const at = new Date(iso)
|
|
468
|
+
return Number.isNaN(at.getTime()) ? String(iso) : at.toLocaleString()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* What the tab says when there is nothing to draw.
|
|
473
|
+
*
|
|
474
|
+
* The distinctions matter: 'this session never ran memory_map' and 'the call ran but its
|
|
475
|
+
* result carries no snapshot' are different facts, and neither of them is zero entries.
|
|
476
|
+
*/
|
|
477
|
+
function emptyMemoryCopy(state) {
|
|
478
|
+
if (state.kind === 'unavailable') {
|
|
479
|
+
return {
|
|
480
|
+
title: 'The session record is not attached yet',
|
|
481
|
+
lines: [
|
|
482
|
+
'This tab reads memory from the conversation it belongs to - the event window of the open session - and that window is not available here.',
|
|
483
|
+
'Settings, Memory still reads the live store through the host, so the store itself stays visible either way.',
|
|
484
|
+
],
|
|
485
|
+
}
|
|
382
486
|
}
|
|
487
|
+
if (state.kind === 'empty') {
|
|
488
|
+
return {
|
|
489
|
+
title: 'The last memory_map result carried no snapshot',
|
|
490
|
+
lines: [
|
|
491
|
+
'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.',
|
|
492
|
+
'Running memory_map again puts a fresh snapshot here.',
|
|
493
|
+
],
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
title: 'No memory_map result in this session yet',
|
|
498
|
+
lines: [
|
|
499
|
+
'This tab draws the most recent memory_map result of this conversation: the same snapshot the agent was shown, read back from the session record rather than fetched again.',
|
|
500
|
+
'Run memory_map once - scope "session" or "all" - and the panels appear here.',
|
|
501
|
+
],
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The honest empty state: prose that says what is missing, never a shape that reads as data. */
|
|
506
|
+
function MemoryEmpty(props) {
|
|
507
|
+
const copy = emptyMemoryCopy(props.state)
|
|
508
|
+
const lines = copy.lines.map(function (line, index) {
|
|
509
|
+
return react.createElement('div', { key: String(index), style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: MUTED } }, line)
|
|
510
|
+
})
|
|
511
|
+
return sheet('empty', [
|
|
512
|
+
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '13px', fontWeight: 500, color: INK } }, copy.title),
|
|
513
|
+
box({ key: 'b', display: 'flex', flexDirection: 'column', gap: '8px' }, lines),
|
|
514
|
+
])
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The panels, drawn from the snapshot memory_map actually returned and nothing else. */
|
|
518
|
+
function MemoryPanels(props) {
|
|
519
|
+
const meta = props.meta
|
|
383
520
|
const store = meta.store || null
|
|
384
521
|
const session = meta.session || null
|
|
385
522
|
const parts = []
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
}
|
|
523
|
+
const chips = [react.createElement(Chip, { key: 'scope', text: 'scope ' + String(meta.scope) })]
|
|
524
|
+
if (meta.generatedAt) chips.push(react.createElement(Chip, { key: 'at', text: whenText(meta.generatedAt) }))
|
|
525
|
+
if (session) chips.push(react.createElement(Chip, { key: 'where', text: session.workspace ? 'workspace ' + session.workspace : 'global layer' }))
|
|
526
|
+
parts.push(box({ key: 'chips', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips))
|
|
392
527
|
if (store) {
|
|
393
528
|
const attention = (store.queue && store.queue.attention) || []
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
529
|
+
const queue = attention.slice(0, 3).map(function (row) {
|
|
530
|
+
return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true })
|
|
531
|
+
})
|
|
532
|
+
if (store.queue && store.queue.waiting) queue.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }))
|
|
533
|
+
parts.push(sheet('store', [
|
|
534
|
+
sectionLabel('STORE', fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes'),
|
|
398
535
|
react.createElement(LayerBar, { key: 'bar', layers: store.layers, height: '30px' }),
|
|
399
536
|
react.createElement(Legend, { key: 'leg', layers: store.layers }),
|
|
400
537
|
box({ key: 'f', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }, [
|
|
@@ -403,12 +540,62 @@ window.__ModuleLoader__.load({
|
|
|
403
540
|
store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
|
|
404
541
|
store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
|
|
405
542
|
]),
|
|
406
|
-
box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' },
|
|
543
|
+
box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, queue),
|
|
407
544
|
]),
|
|
408
|
-
]))
|
|
545
|
+
]))
|
|
546
|
+
}
|
|
547
|
+
// A panel with nothing in it is omitted, or said in one line of prose: an empty card is
|
|
548
|
+
// a shape that reads as data, which is exactly what this tab must never draw.
|
|
549
|
+
const received = session ? session.received : null
|
|
550
|
+
if (received && received.sections && received.sections.length) {
|
|
551
|
+
parts.push(sheet('received', [react.createElement(ReceivedPanel, { key: 'r', received: received })]))
|
|
409
552
|
}
|
|
410
|
-
|
|
553
|
+
const written = session ? session.written : null
|
|
554
|
+
if (written && written.total) {
|
|
555
|
+
parts.push(sheet('written', [react.createElement(WrittenPanel, { key: 'w', written: written })]))
|
|
556
|
+
} else if (written) {
|
|
557
|
+
parts.push(react.createElement('div', {
|
|
558
|
+
key: 'none',
|
|
559
|
+
style: { fontFamily: GEIST, fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
560
|
+
}, 'This session has not recorded an entry yet.'))
|
|
561
|
+
}
|
|
562
|
+
parts.push(react.createElement('div', {
|
|
563
|
+
key: 'src',
|
|
564
|
+
style: { fontFamily: GEIST, fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.6 },
|
|
565
|
+
}, 'Read from this session\u2019s most recent memory_map result (event #' + fmt(props.seq) + '), not re-read from the store. Run memory_map again to refresh it.'))
|
|
566
|
+
return box({ display: 'flex', flexDirection: 'column', gap: '18px' }, parts)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The Memory tab.
|
|
571
|
+
*
|
|
572
|
+
* The hook arrives as part of this entry's injected standard kit: the inject face
|
|
573
|
+
* publishes hooks.memory, which the renderer binds to a useMemory prop, exactly as
|
|
574
|
+
* ui-trajectory's view receives useDuration. It is present for the whole life of a mount
|
|
575
|
+
* or absent for the whole of it, so this branch never changes hook order. The selector is
|
|
576
|
+
* required: the bound hook is a selector hook and calls it without a guard.
|
|
577
|
+
*/
|
|
578
|
+
function MemoryTabView(props) {
|
|
579
|
+
const useMemory = props && typeof props.useMemory === 'function' ? props.useMemory : null
|
|
580
|
+
const state = useMemory ? useMemory(memoryStateOf) : MEMORY_STATE_NONE
|
|
581
|
+
const page = { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }
|
|
582
|
+
const inner = { maxWidth: '760px', margin: '0 auto', padding: '26px 28px 44px', display: 'flex', flexDirection: 'column', gap: '18px' }
|
|
583
|
+
const head = box({ display: 'flex', alignItems: 'center', gap: '10px' }, [
|
|
584
|
+
react.createElement(Glyph, { key: 'g' }),
|
|
585
|
+
// Theme token, not the fixed ink palette: this line sits on the shell background,
|
|
586
|
+
// which the palette knows nothing about.
|
|
587
|
+
react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '15px', fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, 'Memory'),
|
|
588
|
+
])
|
|
589
|
+
const intro = react.createElement('div', {
|
|
590
|
+
key: 'i',
|
|
591
|
+
style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary)' },
|
|
592
|
+
}, 'What memory put in front of this conversation, what the conversation wrote back, and the state of the store.')
|
|
593
|
+
const body = state.kind === 'ready'
|
|
594
|
+
? react.createElement(MemoryPanels, { key: 'p', meta: state.meta, seq: state.seq })
|
|
595
|
+
: react.createElement(MemoryEmpty, { key: 'e', state: state })
|
|
596
|
+
return box(page, [box(inner, [head, intro, body])])
|
|
411
597
|
}
|
|
598
|
+
|
|
412
599
|
/** Fields rendered on this page, in order. */
|
|
413
600
|
const FIELDS = [
|
|
414
601
|
{ key: 'memoryRoot', label: 'Memory root', hint: 'Local folder or drive that holds the store.', placeholder: 'D:\\Memory' },
|
|
@@ -618,44 +805,43 @@ window.__ModuleLoader__.load({
|
|
|
618
805
|
name: 'tool.call.toolview',
|
|
619
806
|
key: 'memory_map',
|
|
620
807
|
}, MemoryMapCard)))
|
|
621
|
-
// The
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
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)))
|
|
808
|
+
// The settings page registers FIRST, and deliberately. It is the established surface;
|
|
809
|
+
// the tab below is newer and depends on two services the page does not need. Ordering
|
|
810
|
+
// the proven surface before the unproven one means a failure in the tab cannot take the
|
|
811
|
+
// settings page down with it - both halves share one file and one apply().
|
|
647
812
|
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
648
813
|
name: 'settings.section',
|
|
649
814
|
id: 'memory',
|
|
650
815
|
order: 80,
|
|
651
816
|
label: 'Memory',
|
|
652
817
|
}, () => react.createElement(MemorySettings))))
|
|
818
|
+
// The Memory tab. Its data is injected by THIS entry rather than registered globally
|
|
819
|
+
// through uiSession.provide: the source is per-session and read from the session's own
|
|
820
|
+
// event window, and an entry-owned inject face is cached per (entry x session), so the
|
|
821
|
+
// hook the renderer binds (useMemory) is created once per conversation.
|
|
822
|
+
try {
|
|
823
|
+
disposers.push(ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
824
|
+
name: 'conversation.view',
|
|
825
|
+
id: 'memory',
|
|
826
|
+
// ui-chat holds 0 and ui-trajectory 10, so Memory sits after both without
|
|
827
|
+
// reordering anyone else's tab.
|
|
828
|
+
order: 20,
|
|
829
|
+
label: 'Memory',
|
|
830
|
+
inject: (sessionId) => ({ hooks: { memory: createMemorySource(ctx, sessionId) } }),
|
|
831
|
+
}, MemoryTabView)))
|
|
832
|
+
} catch (error) {
|
|
833
|
+
// Loud, not silent. A tab that fails to register is invisible by definition, so this
|
|
834
|
+
// warning is the only trace the failure can leave.
|
|
835
|
+
ctx.logger.warn('dsh-memory: Memory tab unavailable: ' + (error && error.message ? error.message : String(error)))
|
|
836
|
+
}
|
|
653
837
|
return () => { for (const dispose of disposers) dispose() }
|
|
654
838
|
}, 'dsh-memory: settings section')
|
|
655
839
|
}
|
|
656
840
|
|
|
657
841
|
exports.name = 'dsh-memory'
|
|
658
|
-
|
|
842
|
+
// 'sessions' is deliberately NOT here: the tab reaches it with ctx.get() so that a
|
|
843
|
+
// missing service can only blank the tab, never hold back the settings page too.
|
|
844
|
+
exports.inject = ['slots', 'configForms', 'remote.session']
|
|
659
845
|
exports.apply = apply
|
|
660
846
|
return module.exports
|
|
661
847
|
},
|