@try-works/dsh-memory 0.1.17 → 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 +2 -1
- package/package.json +1 -1
- package/src/client/index.js +37 -16
- package/src/host.js +56 -2
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
|
-
- [
|
|
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
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 } })],
|