@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 +31 -11
- package/package.json +1 -1
- package/src/client/index.js +37 -16
- package/src/host.js +56 -2
- package/src/tasks.js +7 -2
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:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
through the
|
|
95
|
-
|
|
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
|
-
- [
|
|
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.
|
|
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
package/src/client/index.js
CHANGED
|
@@ -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
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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
|
|
375
|
+
* Find the newest memory snapshot in the window and say what it carried.
|
|
369
376
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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: '
|
|
535
|
+
title: 'Nothing published for this session yet',
|
|
515
536
|
lines: [
|
|
516
|
-
'This tab draws the
|
|
517
|
-
'
|
|
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
|
-
}, '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|