@try-works/dsh-memory 0.1.17 → 0.1.19

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/STATUS.md CHANGED
@@ -145,7 +145,8 @@ the flag on did not change what the user sees.
145
145
 
146
146
  ### Blocked on a restart
147
147
 
148
- - [ ] **Restart, then check the tasks layer — THE BLOCKER.** It writes to ANOTHER plugin's storage through
148
+ - [x] **Restart, then check the tasks layer — DONE 2026-10-09.** The restart confirmed it. See the
149
+ tasks-layer section above for the two log entries and the counts. It writes to ANOTHER plugin's storage through
149
150
  that plugin's service, so it deserves a real observation rather than a passing suite. After
150
151
  the restart, ask for `memory_tasks` and expect a report with `live: 2`, `backedUp: 2` and
151
152
  `pending.present: 2`. Then check `logs/memory.log` for a `tasks-backup` entry and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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,10 +342,17 @@ 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 of this conversation. 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` - the very
348
- * object ui-chat's conversation-nodes/tool.ts forwards as `meta: match.event.data.meta`.
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`.
349
356
  *
350
357
  * READING IT: the session's contiguous event window is published as the documented
351
358
  * public member SessionBinding.eventSource (ObservableSnapshot<SessionEventWindow>, see
@@ -365,11 +372,13 @@ window.__ModuleLoader__.load({
365
372
  const MEMORY_STATE_NESTED = { kind: 'nested', meta: null, seq: null, time: null }
366
373
 
367
374
  /**
368
- * Find the newest memory_map result in the window and say what it carried.
375
+ * Find the newest memory snapshot in the window and say what it carried.
369
376
  *
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 }.
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 }.
373
382
  */
374
383
  function readMemoryState(window) {
375
384
  const entries = window && Array.isArray(window.entries) ? window.entries : null
@@ -387,7 +396,17 @@ window.__ModuleLoader__.load({
387
396
  for (let i = entries.length - 1; i >= 0; i -= 1) {
388
397
  const entry = entries[i]
389
398
  const event = entry && entry.type === 'event' ? entry.event : null
390
- if (!event || event.type !== 'tool/result') continue
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
391
410
  const data = event.data || {}
392
411
  const message = data.message || null
393
412
  const callId = message && message.source ? message.source.callId : undefined
@@ -479,8 +498,10 @@ window.__ModuleLoader__.load({
479
498
  /**
480
499
  * What the tab says when there is nothing to draw.
481
500
  *
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.
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.
484
505
  */
485
506
  function emptyMemoryCopy(state) {
486
507
  if (state.kind === 'unavailable') {
@@ -497,7 +518,7 @@ window.__ModuleLoader__.load({
497
518
  title: 'The last memory_map result carried no snapshot',
498
519
  lines: [
499
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.',
500
- 'Running memory_map again puts a fresh snapshot here.',
521
+ 'The host publishes a fresh snapshot on its own cadence, so this clears itself.',
501
522
  ],
502
523
  }
503
524
  }
@@ -511,10 +532,10 @@ window.__ModuleLoader__.load({
511
532
  }
512
533
  }
513
534
  return {
514
- title: 'No memory_map result in this session yet',
535
+ title: 'Nothing published for this session yet',
515
536
  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.',
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.',
518
539
  ],
519
540
  }
520
541
  }
@@ -579,7 +600,7 @@ window.__ModuleLoader__.load({
579
600
  parts.push(react.createElement('div', {
580
601
  key: 'src',
581
602
  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.'))
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.'))
583
604
  return box({ display: 'flex', flexDirection: 'column', gap: '18px' }, parts)
584
605
  }
585
606
 
package/src/host.js CHANGED
@@ -16,6 +16,7 @@ import { due, problems, readQueue, reapStalled, REASONS, STATES, summarize as su
16
16
  import { planRecovery, recoverOne } from './recover.js'
17
17
  import { logRun, noteActivity, readMetrics, record } from './telemetry.js'
18
18
  import { GitRunner, SyncScheduler, classifyGitResult, commitMessage } from './git.js'
19
+ import { buildMap } from './map.js'
19
20
  import { backupTasks, defaultSchedulePath, planRestore, readScheduleStore } from './tasks.js'
20
21
  import { autoAssociateSession } from './session.js'
21
22
  import { readAssociations } from './workspace.js'
@@ -23,6 +24,7 @@ import { readAssociations } from './workspace.js'
23
24
  const PLUGIN = 'dsh-memory'
24
25
 
25
26
  /** How often the idle ticker checks whether a cadence is due (the cadences self-throttle). */
27
+ const MAP_PUBLISH_MS = 60 * 1000
26
28
  const TASKS_BACKUP_MS = 30 * 60 * 1000
27
29
  const IDLE_TICK_MS = 60_000
28
30
 
@@ -70,6 +72,8 @@ export function wireHost(ctx, store, cfg) {
70
72
  let lastRefreshAttempt = 0
71
73
  let lastTasksBackupAttempt = 0
72
74
  let restoreAttempted = false
75
+ const lastMapPublish = new Map()
76
+ const lastMapSignature = new Map()
73
77
  let lastHistoryAttempt = 0
74
78
  let syncInFlight = false
75
79
 
@@ -695,6 +699,52 @@ export function wireHost(ctx, store, cfg) {
695
699
  }
696
700
  }
697
701
 
702
+ /**
703
+ * Publish the memory map into the session log, so a UI can draw it without anyone calling a tool.
704
+ *
705
+ * WHY AN EVENT AND NOT A TOOL CALL. The Memory tab used to read the newest memory_map TOOL RESULT
706
+ * out of the session, which meant it stayed empty until an agent happened to call the tool - the
707
+ * reader had to be shown the data before the tab could show it back. That is backwards for a view
708
+ * whose whole job is to report state nobody asked about. Session.append is the harness's own
709
+ * answer: SessionEventMap is documented as "the merge-extensible, append-only source of truth",
710
+ * plugins add their own event types (session/title does exactly this), and a log-only event never
711
+ * reaches the model surface or the derived history. So the map is published BY the host, INTO the
712
+ * log, and the client reads it from the same event window it already subscribes to.
713
+ *
714
+ * TWO GUARDS, both about not making the log grow without limit:
715
+ * - a time throttle, because building the map walks the ledger;
716
+ * - a signature comparison that ignores generatedAt, so an unchanged store appends nothing.
717
+ * The signature is the point. Without it every turn would add an identical snapshot to a log that
718
+ * is the durable record of the conversation.
719
+ */
720
+ function publishMemoryMap(session, { agent, force = false } = {}) {
721
+ try {
722
+ if (!session || typeof session.append !== 'function' || !session.id) return
723
+ if (isSubagentSession(session)) return
724
+ const now = Date.now()
725
+ if (!force && now - (lastMapPublish.get(session.id) ?? 0) < MAP_PUBLISH_MS) return
726
+ lastMapPublish.set(session.id, now)
727
+ const key = keyBySession.get(session.id) ?? 'global'
728
+ const scope = key === 'global' ? 'global' : 'workspace'
729
+ // goals.get() takes an AGENT, not a session (goal/src/index.ts:277), so the goal is only
730
+ // available on the pre-step path. Elsewhere the map is published without it, which is honest:
731
+ // it describes what it can see.
732
+ const goal = agent ? ctx.get('goals')?.get(agent)?.objective : undefined
733
+ const map = buildMap(store, { scope: 'all', sessionId: session.id, memoryScope: scope, key, goal, now, maxChars: cfg.injectionMaxChars })
734
+ // JSON round-trip is not decoration: append() REJECTS data containing undefined, and the
735
+ // round-trip is what guarantees we hand it lossless JSON.
736
+ const payload = JSON.parse(JSON.stringify(map))
737
+ const signature = JSON.stringify({ ...payload, generatedAt: '' })
738
+ if (!force && lastMapSignature.get(session.id) === signature) return
739
+ lastMapSignature.set(session.id, signature)
740
+ session.append('memory/map', payload)
741
+ logRun(store.root, 'map-publish', { ok: true, scope, key: key ?? '', bytes: signature.length })
742
+ } catch (error) {
743
+ // Never let a view break the conversation it is describing.
744
+ logRun(store.root, 'map-publish', { ok: false, error: String((error && error.message) || error).slice(0, 160) })
745
+ }
746
+ }
747
+
698
748
  function maybeSync() {
699
749
  if (syncInFlight || !git) return
700
750
  if (!scheduler.shouldCommit() && !scheduler.shouldSync()) return
@@ -709,8 +759,17 @@ export function wireHost(ctx, store, cfg) {
709
759
 
710
760
  // session lifecycle: associate workspace + capture on turn end
711
761
  ctx.on('session/event', (session, event) => {
712
- if (event.type === 'turn/start') void resolveKey(session)
713
- else if (event.type === 'turn/end') maybeCapture(session)
762
+ // DEFERRED, and it has to be. This handler runs INSIDE an append - the harness sets a reentrancy
763
+ // flag for the duration of the session/event emission - and Session.append refuses to reenter:
764
+ // "session append cannot reenter while another append is being published". Measured in the live
765
+ // log, exactly half the publishes failed with that message while the pre-step ones succeeded.
766
+ // A macrotask lands after the append has settled, so the guard is satisfied.
767
+ if (event.type === 'turn/start') {
768
+ void resolveKey(session).then(() => setTimeout(() => publishMemoryMap(session), 0))
769
+ } else if (event.type === 'turn/end') {
770
+ maybeCapture(session)
771
+ setTimeout(() => publishMemoryMap(session), 0)
772
+ }
714
773
  })
715
774
 
716
775
  // Disposal is the LAST chance to capture a session, so it bypasses the throttle.
@@ -751,6 +810,10 @@ export function wireHost(ctx, store, cfg) {
751
810
  // Injection writes nothing, so without this it is invisible: the one hook whose effect is
752
811
  // entirely in the conversation could break and leave no trace anywhere on disk.
753
812
  record(store.root, 'inject', { detail: { scope, key: key ?? '', subagent, step, chars: text.length } })
813
+ // Published here as well as on turn boundaries, because this is the only place the AGENT (and
814
+ // so the session's goal) is in reach - and the received panel is supposed to describe what was
815
+ // actually injected, goal included.
816
+ publishMemoryMap(agent.session, { agent })
754
817
  return {
755
818
  ...decision,
756
819
  messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: { kind: PLUGIN, form: 'memory-context', version: 1 } })],