@try-works/dsh-memory 0.1.6 → 0.1.8

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/client/index.js +255 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -342,59 +342,213 @@ 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 in this session. That is not a
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` (ui-chat's
348
- * conversation-nodes/tool.ts does `meta: match.event.data.meta`).
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: 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 }.
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
- function findMemorySnapshot(snapshot) {
358
- if (!snapshot || !Array.isArray(snapshot.eventNodes)) return null
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
+ // Distinct from NONE on purpose. PTC mode dispatches a tool from inside a run_code program and
362
+ // records tool/ptc-dispatch, which carries the rendered text but NO structured value, so there
363
+ // is genuinely nothing to draw - and telling the reader 'memory_map has not run here' would be
364
+ // false.
365
+ const MEMORY_STATE_NESTED = { kind: 'nested', meta: null, seq: null, time: null }
366
+
367
+ /**
368
+ * Find the newest memory_map result in the window and say what it carried.
369
+ *
370
+ * A tool/result event names its call only by id, so the call events are indexed first.
371
+ * A result whose tool/call pair has been paged out or pruned is still recognised by the
372
+ * shape of its own value: nothing but memory_map produces { scope, generatedAt }.
373
+ */
374
+ function readMemoryState(window) {
375
+ const entries = window && Array.isArray(window.entries) ? window.entries : null
376
+ if (!entries) return MEMORY_STATE_NONE
377
+ const calls = Object.create(null)
378
+ let nested = false
379
+ for (let i = 0; i < entries.length; i += 1) {
380
+ const entry = entries[i]
381
+ const event = entry && entry.type === 'event' ? entry.event : null
382
+ if (event && event.type === 'tool/call' && event.data && event.data.name === 'memory_map') calls[event.data.callId] = true
383
+ // A nested dispatch is a real call the agent made; it simply has no structured result.
384
+ if (event && event.type === 'tool/ptc-dispatch' && event.data && event.data.name === 'memory_map') nested = true
385
+ }
359
386
  // 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
387
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
388
+ const entry = entries[i]
389
+ const event = entry && entry.type === 'event' ? entry.event : null
390
+ if (!event || event.type !== 'tool/result') continue
391
+ const data = event.data || {}
392
+ const message = data.message || null
393
+ const callId = message && message.source ? message.source.callId : undefined
394
+ const meta = data.meta
395
+ const shaped = meta !== null && typeof meta === 'object'
396
+ && typeof meta.generatedAt === 'string' && typeof meta.scope === 'string'
397
+ if (calls[callId] !== true && !shaped) continue
398
+ // A failed call is not a snapshot: it is the absence of one, and it is said so.
399
+ if (message && message.isError === true) continue
400
+ if (meta === null || typeof meta !== 'object') return { kind: 'empty', meta: null, seq: event.seq, time: event.time }
401
+ return { kind: 'ready', meta: meta, seq: event.seq, time: event.time }
367
402
  }
368
- return null
403
+ return nested ? MEMORY_STATE_NESTED : MEMORY_STATE_NONE
369
404
  }
370
405
 
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
- ])]);
406
+ /**
407
+ * The tab's observable source over one session's event window.
408
+ *
409
+ * It re-resolves the binding on every read, so a session whose binding materializes
410
+ * after the tab has already rendered still fills in; while no window exists it listens
411
+ * to the session list, which publishes when a generation is retained or opened, so the
412
+ * tab wakes up instead of staying dark until an unrelated re-render. Values are cached by
413
+ * window identity, which keeps getSnapshot referentially stable between publications.
414
+ */
415
+ function createMemorySource(ctx, sessionId) {
416
+ const listeners = []
417
+ let subscribedTo = null
418
+ let unsubscribe = null
419
+ let cachedWindow = null
420
+ let cachedState = MEMORY_STATE_NONE
421
+ const wake = () => {
422
+ cachedWindow = null
423
+ for (let i = 0; i < listeners.length; i += 1) listeners[i]()
424
+ }
425
+ const eventSourceOf = () => {
426
+ const sessions = ctx.get('sessions')
427
+ if (!sessions || typeof sessions.binding !== 'function') return null
428
+ const binding = sessions.binding(sessionId)
429
+ return binding && binding.eventSource ? binding.eventSource : null
430
+ }
431
+ const attach = (source) => {
432
+ if (unsubscribe) { unsubscribe(); unsubscribe = null }
433
+ subscribedTo = source
434
+ if (source) { unsubscribe = source.subscribe(wake); return }
435
+ const sessions = ctx.get('sessions')
436
+ const list = sessions && sessions.list
437
+ if (list && typeof list.subscribe === 'function' && listeners.length) unsubscribe = list.subscribe(wake)
438
+ }
439
+ const sync = () => {
440
+ const source = eventSourceOf()
441
+ if (listeners.length) { if (source !== subscribedTo) attach(source) }
442
+ else if (subscribedTo) attach(null)
443
+ return source
444
+ }
445
+ return {
446
+ getSnapshot: () => {
447
+ const source = sync()
448
+ if (!source) return MEMORY_STATE_UNAVAILABLE
449
+ const window = source.getSnapshot()
450
+ if (window !== cachedWindow) { cachedWindow = window; cachedState = readMemoryState(window) }
451
+ return cachedState
452
+ },
453
+ subscribe: (listener) => {
454
+ listeners.push(listener)
455
+ sync()
456
+ return () => {
457
+ const index = listeners.indexOf(listener)
458
+ if (index >= 0) listeners.splice(index, 1)
459
+ if (!listeners.length) attach(null)
460
+ }
461
+ },
462
+ }
463
+ }
464
+
465
+ /** Selector for the injected memory hook: the source caches by window, so identity holds. */
466
+ function memoryStateOf(value) { return value === undefined ? MEMORY_STATE_NONE : value }
467
+
468
+ /** The sheet the panels sit on: their ink colours are fixed, so the surface must be too. */
469
+ function sheet(key, children) {
470
+ return box({ key: key, border: '1px solid #E7E7E4', borderRadius: '12px', background: '#FFFFFF', padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' }, children)
471
+ }
472
+
473
+ /** The snapshot's OWN stamp, never the clock: a snapshot taken an hour ago must read so. */
474
+ function whenText(iso) {
475
+ const at = new Date(iso)
476
+ return Number.isNaN(at.getTime()) ? String(iso) : at.toLocaleString()
477
+ }
478
+
479
+ /**
480
+ * What the tab says when there is nothing to draw.
481
+ *
482
+ * The distinctions matter: 'this session never ran memory_map' and 'the call ran but its
483
+ * result carries no snapshot' are different facts, and neither of them is zero entries.
484
+ */
485
+ function emptyMemoryCopy(state) {
486
+ if (state.kind === 'unavailable') {
487
+ return {
488
+ title: 'The session record is not attached yet',
489
+ lines: [
490
+ 'This tab reads memory from the conversation it belongs to - the event window of the open session - and that window is not available here.',
491
+ 'Settings, Memory still reads the live store through the host, so the store itself stays visible either way.',
492
+ ],
493
+ }
382
494
  }
495
+ if (state.kind === 'empty') {
496
+ return {
497
+ title: 'The last memory_map result carried no snapshot',
498
+ lines: [
499
+ '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.',
500
+ 'Running memory_map again puts a fresh snapshot here.',
501
+ ],
502
+ }
503
+ }
504
+ if (state.kind === 'nested') {
505
+ return {
506
+ title: 'memory_map ran here, but not as a readable result',
507
+ lines: [
508
+ '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.',
509
+ 'Calling memory_map directly - scope "session" or "all" - records a result the panels can read.',
510
+ ],
511
+ }
512
+ }
513
+ return {
514
+ title: 'No memory_map result in this session yet',
515
+ lines: [
516
+ '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.',
517
+ 'Run memory_map once - scope "session" or "all" - and the panels appear here.',
518
+ ],
519
+ }
520
+ }
521
+
522
+ /** The honest empty state: prose that says what is missing, never a shape that reads as data. */
523
+ function MemoryEmpty(props) {
524
+ const copy = emptyMemoryCopy(props.state)
525
+ const lines = copy.lines.map(function (line, index) {
526
+ return react.createElement('div', { key: String(index), style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: MUTED } }, line)
527
+ })
528
+ return sheet('empty', [
529
+ react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '13px', fontWeight: 500, color: INK } }, copy.title),
530
+ box({ key: 'b', display: 'flex', flexDirection: 'column', gap: '8px' }, lines),
531
+ ])
532
+ }
533
+
534
+ /** The panels, drawn from the snapshot memory_map actually returned and nothing else. */
535
+ function MemoryPanels(props) {
536
+ const meta = props.meta
383
537
  const store = meta.store || null
384
538
  const session = meta.session || null
385
539
  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
- }
540
+ const chips = [react.createElement(Chip, { key: 'scope', text: 'scope ' + String(meta.scope) })]
541
+ if (meta.generatedAt) chips.push(react.createElement(Chip, { key: 'at', text: whenText(meta.generatedAt) }))
542
+ if (session) chips.push(react.createElement(Chip, { key: 'where', text: session.workspace ? 'workspace ' + session.workspace : 'global layer' }))
543
+ parts.push(box({ key: 'chips', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips))
392
544
  if (store) {
393
545
  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'),
546
+ const queue = attention.slice(0, 3).map(function (row) {
547
+ return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true })
548
+ })
549
+ if (store.queue && store.queue.waiting) queue.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }))
550
+ parts.push(sheet('store', [
551
+ sectionLabel('STORE', fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes'),
398
552
  react.createElement(LayerBar, { key: 'bar', layers: store.layers, height: '30px' }),
399
553
  react.createElement(Legend, { key: 'leg', layers: store.layers }),
400
554
  box({ key: 'f', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }, [
@@ -403,12 +557,62 @@ window.__ModuleLoader__.load({
403
557
  store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
404
558
  store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
405
559
  ]),
406
- box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips),
560
+ box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, queue),
407
561
  ]),
408
- ]));
562
+ ]))
563
+ }
564
+ // A panel with nothing in it is omitted, or said in one line of prose: an empty card is
565
+ // a shape that reads as data, which is exactly what this tab must never draw.
566
+ const received = session ? session.received : null
567
+ if (received && received.sections && received.sections.length) {
568
+ parts.push(sheet('received', [react.createElement(ReceivedPanel, { key: 'r', received: received })]))
569
+ }
570
+ const written = session ? session.written : null
571
+ if (written && written.total) {
572
+ parts.push(sheet('written', [react.createElement(WrittenPanel, { key: 'w', written: written })]))
573
+ } else if (written) {
574
+ parts.push(react.createElement('div', {
575
+ key: 'none',
576
+ style: { fontFamily: GEIST, fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' },
577
+ }, 'This session has not recorded an entry yet.'))
409
578
  }
410
- return box(page, parts);
579
+ parts.push(react.createElement('div', {
580
+ key: 'src',
581
+ style: { fontFamily: GEIST, fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', lineHeight: 1.6 },
582
+ }, '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.'))
583
+ return box({ display: 'flex', flexDirection: 'column', gap: '18px' }, parts)
584
+ }
585
+
586
+ /**
587
+ * The Memory tab.
588
+ *
589
+ * The hook arrives as part of this entry's injected standard kit: the inject face
590
+ * publishes hooks.memory, which the renderer binds to a useMemory prop, exactly as
591
+ * ui-trajectory's view receives useDuration. It is present for the whole life of a mount
592
+ * or absent for the whole of it, so this branch never changes hook order. The selector is
593
+ * required: the bound hook is a selector hook and calls it without a guard.
594
+ */
595
+ function MemoryTabView(props) {
596
+ const useMemory = props && typeof props.useMemory === 'function' ? props.useMemory : null
597
+ const state = useMemory ? useMemory(memoryStateOf) : MEMORY_STATE_NONE
598
+ const page = { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' }
599
+ const inner = { maxWidth: '760px', margin: '0 auto', padding: '26px 28px 44px', display: 'flex', flexDirection: 'column', gap: '18px' }
600
+ const head = box({ display: 'flex', alignItems: 'center', gap: '10px' }, [
601
+ react.createElement(Glyph, { key: 'g' }),
602
+ // Theme token, not the fixed ink palette: this line sits on the shell background,
603
+ // which the palette knows nothing about.
604
+ react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '15px', fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } }, 'Memory'),
605
+ ])
606
+ const intro = react.createElement('div', {
607
+ key: 'i',
608
+ style: { fontFamily: GEIST, fontSize: '12px', lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary)' },
609
+ }, 'What memory put in front of this conversation, what the conversation wrote back, and the state of the store.')
610
+ const body = state.kind === 'ready'
611
+ ? react.createElement(MemoryPanels, { key: 'p', meta: state.meta, seq: state.seq })
612
+ : react.createElement(MemoryEmpty, { key: 'e', state: state })
613
+ return box(page, [box(inner, [head, intro, body])])
411
614
  }
615
+
412
616
  /** Fields rendered on this page, in order. */
413
617
  const FIELDS = [
414
618
  { key: 'memoryRoot', label: 'Memory root', hint: 'Local folder or drive that holds the store.', placeholder: 'D:\\Memory' },
@@ -628,25 +832,11 @@ window.__ModuleLoader__.load({
628
832
  order: 80,
629
833
  label: 'Memory',
630
834
  }, () => react.createElement(MemorySettings))))
631
- // The Memory tab. Data arrives through a registered standard hook, because a
632
- // conversation view is a slot and slots are fed by hooks, not by direct reads.
633
- // The Trajectory target is the shipped assembly of this session's nodes, so the
634
- // source re-derives the memory snapshot from it on every projection.
835
+ // The Memory tab. Its data is injected by THIS entry rather than registered globally
836
+ // through uiSession.provide: the source is per-session and read from the session's own
837
+ // event window, and an entry-owned inject face is cached per (entry x session), so the
838
+ // hook the renderer binds (useMemory) is created once per conversation.
635
839
  try {
636
- ctx.effect(() => ctx.uiSession.provide({
637
- hooks: ['memoryMap'],
638
- resolve: (binding) => {
639
- const target = ctx.uiConversation.binding(binding).target('trajectory')
640
- return {
641
- hooks: {
642
- memoryMap: {
643
- getSnapshot: () => findMemorySnapshot(target.getSnapshot()),
644
- subscribe: (listener) => target.subscribe(listener),
645
- },
646
- },
647
- }
648
- },
649
- }), 'dsh-memory: memory map hook')
650
840
  disposers.push(ctx.slots.inject('conversation.view', () => ctx.slots.register({
651
841
  name: 'conversation.view',
652
842
  id: 'memory',
@@ -654,6 +844,7 @@ window.__ModuleLoader__.load({
654
844
  // reordering anyone else's tab.
655
845
  order: 20,
656
846
  label: 'Memory',
847
+ inject: (sessionId) => ({ hooks: { memory: createMemorySource(ctx, sessionId) } }),
657
848
  }, MemoryTabView)))
658
849
  } catch (error) {
659
850
  // Loud, not silent. A tab that fails to register is invisible by definition, so this
@@ -665,7 +856,9 @@ window.__ModuleLoader__.load({
665
856
  }
666
857
 
667
858
  exports.name = 'dsh-memory'
668
- exports.inject = ['slots', 'configForms', 'remote.session', 'uiSession', 'uiConversation']
859
+ // 'sessions' is deliberately NOT here: the tab reaches it with ctx.get() so that a
860
+ // missing service can only blank the tab, never hold back the settings page too.
861
+ exports.inject = ['slots', 'configForms', 'remote.session']
669
862
  exports.apply = apply
670
863
  return module.exports
671
864
  },