@try-works/dsh-memory 0.1.16 → 0.1.18

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
@@ -84,15 +84,34 @@ your automated tasks and is in no git repository. The host DELETES a task when i
84
84
  stops, so a task can vanish with nothing to observe; the backup runs on the sync cadence and a
85
85
  restore re-arms what is missing at the first session after boot.
86
86
 
87
- Verified on disk: 386 tests pass. `backupTasks` pointed at `E:\dsh-memory` wrote the layer for
88
- real — a second pass reported `written=0`, so it converges. `planRestore` against that layer and
89
- the live `schedule.json` returned `add=0 present=2`, which is the correct answer rather than an
90
- empty one: both tasks are in the live store, so a restore finds nothing to do.
91
-
92
- NOT verified until a restart: that the plugin itself runs either pass. The cadence
93
- (`maybeBackupTasks`) and the first-session restore (`maybeRestoreTasks`) have only been exercised
94
- through the wiring tests, where the schedule service is a stub and the store is temporary. Writing
95
- those files by calling the module is not the same claim as the plugin writing them.
87
+ Verified on disk: 396 tests pass, and `@try-works/dsh-memory@0.1.16` is published and installed
88
+ byte-identical.
89
+
90
+ - `backupTasks` against `E:\dsh-memory`: two cron tasks captured, a second pass `written=0`.
91
+ - The layer is at `global/tasks/`. It first shipped at `<root>/tasks`; every test used a
92
+ self-consistent temp root and could not see the difference (`learning-0213`).
93
+ - **The emptied-store drill, on a COPY of the real `schedule.json`:** emptied, backed up, restored
94
+ through the schedule plugin's own `createCronScheduleRecord`. Both tasks came back with their
95
+ expressions, zones and prompts (3597 and 2678 chars). The SECOND boot reported `add=0 present=2` —
96
+ which is the property that matters, since `create` mints a fresh id every time and id-based
97
+ de-duplication would duplicate every task on every boot.
98
+ - The record -> request -> record round trip holds against the plugin's own builder; only the id
99
+ differs.
100
+ - No task file leaks into memory search, checked by building the index over a store holding the layer.
101
+ - `SYSTEM.md` names the layer, its path, `memory_tasks`, and all eight states.
102
+
103
+ **NOT verified, and the log proves it.** `E:\dsh-memory\logs\memory.log` holds 3976 entries and
104
+ ZERO `tasks-backup` or `tasks-restore` ones, while the running host is 3h24m old and predates every
105
+ install from 0.1.10 on. The plugin is alive — it is injecting, capturing and consolidating — it has
106
+ simply never executed this code. The cadence and the boot restore have only ever run inside the
107
+ wiring tests, where the schedule service is a stub and the store is temporary. **Calling the module
108
+ directly is not the same claim as the plugin calling it.**
109
+
110
+ Seven defects were found by asking what the REAL path does, and not one by an existing test: the
111
+ empty-string config fallback, the wrong directory, a fixture-coincidence assertion, `orphaned` with
112
+ no producer, `skipped` never written while unknown-kind tasks were dropped from the backup, an
113
+ exits table read by nothing and wrong twice, and a manual restore that returned silently while
114
+ reporting success.
96
115
 
97
116
  Known limitation, by design: the host deletes a task when its owning session stops, so a restored
98
117
  task lives as long as the session that adopted it. The boot restore is what re-arms them.
@@ -126,7 +145,8 @@ the flag on did not change what the user sees.
126
145
 
127
146
  ### Blocked on a restart
128
147
 
129
- - [ ] **Restart, then check the tasks layer.** 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
130
150
  that plugin's service, so it deserves a real observation rather than a passing suite. After
131
151
  the restart, ask for `memory_tasks` and expect a report with `live: 2`, `backedUp: 2` and
132
152
  `pending.present: 2`. Then check `logs/memory.log` for a `tasks-backup` entry and a
@@ -183,7 +203,7 @@ Kept because the pattern matters more than the list: every one of these was foun
183
203
  | A test edit silently discarded | my own script re-read and overwrote the file before writing |
184
204
  ## Published
185
205
 
186
- **`@try-works/dsh-memory@0.1.10`** on npm (0.1.0 was the first release; the Memory tab and the
206
+ **`@try-works/dsh-memory@0.1.16`** on npm (0.1.0 was the first release; the Memory tab and the
187
207
  tasks layer each added several). The unscoped name `dsh-memory` was already taken by an
188
208
  unrelated DeepSeek Harness memory plugin, so the package publishes under the existing `@try-works`
189
209
  scope.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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,8 @@ 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
+ if (event.type === 'turn/start') void resolveKey(session).then(() => publishMemoryMap(session))
763
+ else if (event.type === 'turn/end') { maybeCapture(session); publishMemoryMap(session) }
714
764
  })
715
765
 
716
766
  // Disposal is the LAST chance to capture a session, so it bypasses the throttle.
@@ -751,6 +801,10 @@ export function wireHost(ctx, store, cfg) {
751
801
  // Injection writes nothing, so without this it is invisible: the one hook whose effect is
752
802
  // entirely in the conversation could break and leave no trace anywhere on disk.
753
803
  record(store.root, 'inject', { detail: { scope, key: key ?? '', subagent, step, chars: text.length } })
804
+ // Published here as well as on turn boundaries, because this is the only place the AGENT (and
805
+ // so the session's goal) is in reach - and the received panel is supposed to describe what was
806
+ // actually injected, goal included.
807
+ publishMemoryMap(agent.session, { agent })
754
808
  return {
755
809
  ...decision,
756
810
  messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: { kind: PLUGIN, form: 'memory-context', version: 1 } })],
package/src/tasks.js CHANGED
@@ -361,9 +361,14 @@ function scheduleOf(record) {
361
361
  if (kind === 'cron') return 'cron ' + String(record.expression ?? '') + zone;
362
362
  if (kind === 'weekly') return 'weekly ' + JSON.stringify(record.weekly ?? {}) + zone;
363
363
  if (kind === 'daily') return 'daily ' + JSON.stringify(record.daily ?? {}) + zone;
364
- if (kind === 'every') return 'every ' + String(record.every_seconds ?? '') + 's';
364
+ // The same camelCase/snake_case confusion as `after` above, in the same function. Writing a test
365
+ // to pin ONE of them surfaced the other.
366
+ if (kind === 'every') return 'every ' + String(record.everySeconds ?? '') + 's';
365
367
  if (kind === 'at') return 'at ' + String(record.scheduledAt ?? '');
366
- if (kind === 'after') return 'after ' + String(record.after_seconds ?? '') + 's';
368
+ // `afterSeconds`, not `after_seconds` - the record uses camelCase, the CREATE REQUEST uses
369
+ // snake_case. The live index printed "after s" with the interval missing, which no test saw
370
+ // because no test rendered an `after` record.
371
+ if (kind === 'after') return 'after ' + String(record.afterSeconds ?? '') + 's';
367
372
  return kind;
368
373
  }
369
374