@try-works/dsh-memory 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
@@ -12,7 +12,9 @@
12
12
  // into undifferentiated prose. The page mirrors a shipped settings section: the same
13
13
  // column width, card chrome, type scale and control metrics.
14
14
  window.__ModuleLoader__.load({
15
- id: 'dsh-memory',
15
+ // Must be the package name: the web client loader looks up this exact id.
16
+ // The Cordis entry id (`dsh-memory`) is a different string used only for settings.
17
+ id: '@try-works/dsh-memory',
16
18
  factory: (require) => {
17
19
  var module = { exports: {} }
18
20
  var exports = module.exports
@@ -146,6 +148,196 @@ window.__ModuleLoader__.load({
146
148
  '}',
147
149
  ].join('\n')
148
150
 
151
+
152
+ // ---------------------------------------------------------------------------------
153
+ // Memory map: the inline face of the memory_map tool.
154
+ //
155
+ // It renders the tool result's structured `meta`, which is the value the tool returned.
156
+ // That is not a shortcut, it is the only honest source: a browser half cannot ask the
157
+ // host for anything (dsh.client has no data channel, remotes are an allowlisted
158
+ // application assembly, and the plugin route serves only client JS), so the tool result
159
+ // IS the channel. Recomputing the numbers here would invent a second source that could
160
+ // disagree with the one the agent was shown, and the disagreement would be invisible.
161
+ // ---------------------------------------------------------------------------------
162
+ const INK = '#1A1A18'
163
+ const MUTED = '#6E6E6B'
164
+ const FAINT = '#8A8A87'
165
+ const LINE = '#EFEFEC'
166
+ const RUST = '#8A3F1E'
167
+ const COPPER = '#C77E4A'
168
+ const QUIET = '#6B6B68'
169
+ const GEIST = 'Geist, system-ui, -apple-system, sans-serif'
170
+ const MONO = 'Geist Mono, ui-monospace, SFMono-Regular, monospace'
171
+ // Deepest tone for the largest layer, so the ramp itself encodes proportion and seven
172
+ // layers need seven TONES rather than seven hues.
173
+ const TONES = ['#2A3644', '#3A4757', '#4C5A6B', '#64717F', '#808C99', '#9DA8B3', '#BCC4CC']
174
+
175
+ /** Thousands separators, so a four-digit count is readable at a glance. */
176
+ function fmt(value) {
177
+ return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('en-US') : String(value)
178
+ }
179
+
180
+ /** How a verdict is written and coloured: the WORD carries the meaning, colour agrees. */
181
+ function verdictStyle(verdict) {
182
+ if (verdict === 'stale') return { color: RUST, weight: 500 }
183
+ if (verdict === 'aging') return { color: COPPER, weight: 500 }
184
+ if (verdict === 'unverified' || verdict === 'empty') return { color: QUIET, weight: 500 }
185
+ return { color: QUIET, weight: 400 }
186
+ }
187
+
188
+ function box(style, children) { return react.createElement('div', { style: style }, children) }
189
+
190
+ /** The stacked layer bar. Each width is a count as a fraction of the total. */
191
+ function LayerBar(props) {
192
+ const total = props.layers.reduce(function (sum, l) { return sum + l.count }, 0)
193
+ if (!total) return null
194
+ const segs = props.layers.map(function (layer, i) {
195
+ return react.createElement('div', {
196
+ key: layer.name,
197
+ style: { width: (layer.count / total * 100).toFixed(3) + '%', flexShrink: 0, background: TONES[i % TONES.length] },
198
+ })
199
+ })
200
+ return box({ display: 'flex', height: props.height || '26px', borderRadius: '7px', overflow: 'hidden' }, segs)
201
+ }
202
+
203
+ /** The legend: fixed dot and name slots, so every count shares one vertical lane. */
204
+ function Legend(props) {
205
+ const items = props.layers.map(function (layer, i) {
206
+ return box({ key: layer.name, display: 'flex', alignItems: 'center', gap: '7px' }, [
207
+ react.createElement('div', { key: 'd', style: { width: '6px', height: '6px', flexShrink: 0, borderRadius: '2px', background: TONES[i % TONES.length] } }),
208
+ react.createElement('div', { key: 'n', style: { fontFamily: GEIST, fontSize: '11px', color: '#3A3A38', width: '62px', flexShrink: 0 } }, layer.name),
209
+ react.createElement('div', { key: 'c', style: { fontFamily: MONO, fontSize: '10px', color: QUIET } }, fmt(layer.count)),
210
+ ])
211
+ })
212
+ const half = Math.ceil(items.length / 2)
213
+ return box({ display: 'flex', flexDirection: 'column', gap: '7px' }, [
214
+ box({ key: 'a', display: 'flex', gap: '22px' }, items.slice(0, half)),
215
+ box({ key: 'b', display: 'flex', gap: '22px' }, items.slice(half)),
216
+ ])
217
+ }
218
+
219
+ function Stat(props) {
220
+ return box({ display: 'flex', alignItems: 'baseline', gap: '6px' }, [
221
+ react.createElement('div', { key: 'n', style: { fontFamily: MONO, fontSize: props.size || '15px', color: props.color || INK } }, props.value),
222
+ react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', color: MUTED } }, props.label),
223
+ ])
224
+ }
225
+
226
+ function Chip(props) {
227
+ return react.createElement('div', {
228
+ style: { padding: '3px 8px', borderRadius: '5px', fontFamily: MONO, fontSize: '10px', background: props.hot ? '#FAEDE6' : '#F4F4F2', color: props.hot ? RUST : '#3A3A38' },
229
+ }, props.text)
230
+ }
231
+
232
+ /** Section label, with an optional right-hand note. */
233
+ function sectionLabel(text, note) {
234
+ return box({ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }, [
235
+ react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', fontWeight: 500, letterSpacing: '0.08em', color: MUTED } }, text),
236
+ note ? react.createElement('div', { key: 'r', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT } }, note) : null,
237
+ ])
238
+ }
239
+
240
+ /** The three-bar mark: a miniature of the layer bar, so the card is identifiable at a glance. */
241
+ function Glyph() {
242
+ const bar = function (key, width, tone) {
243
+ return react.createElement('div', { key: key, style: { height: '2px', width: width, borderRadius: '1px', background: tone } })
244
+ }
245
+ return box({ width: '14px', height: '14px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '2px', justifyContent: 'center' }, [
246
+ bar('a', '14px', TONES[0]), bar('b', '10px', TONES[3]), bar('c', '6px', TONES[5]),
247
+ ])
248
+ }
249
+
250
+ /** The RECEIVED panel: what memory put in front of this session, with its freshness. */
251
+ function ReceivedPanel(props) {
252
+ const sections = (props.received && props.received.sections) || []
253
+ if (!sections.length) return null
254
+ const rows = sections.map(function (section) {
255
+ const style = verdictStyle(section.verdict)
256
+ return box({ key: section.name, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }, [
257
+ react.createElement('div', { key: 'n', style: { fontFamily: GEIST, fontSize: '12px', color: '#3A3A38' } }, section.name),
258
+ react.createElement('div', { key: 'v', style: { fontFamily: MONO, fontSize: '11px', color: style.color, fontWeight: style.weight } }, section.marker ? section.marker.replace(/[\[\]]/g, '').trim() : 'fresh'),
259
+ ])
260
+ })
261
+ return box({ display: 'flex', flexDirection: 'column', gap: '10px' }, [
262
+ sectionLabel('RECEIVED', fmt(props.received.chars) + ' chars'),
263
+ box({ key: 'rows', display: 'flex', flexDirection: 'column', gap: '7px' }, rows),
264
+ ])
265
+ }
266
+
267
+ /** The WRITTEN panel: what THIS session recorded, by layer and by entry. */
268
+ function WrittenPanel(props) {
269
+ const written = props.written
270
+ if (!written || !written.total) return null
271
+ const chips = Object.keys(written.byLayer).map(function (layer) {
272
+ return react.createElement(Chip, { key: layer, text: layer + ' ' + written.byLayer[layer] })
273
+ })
274
+ const entries = written.entries.map(function (entry) {
275
+ return box({ key: entry.id, display: 'flex', alignItems: 'baseline' }, [
276
+ react.createElement('div', { key: 'i', style: { fontFamily: MONO, fontSize: '11px', color: QUIET, width: '186px', flexShrink: 0 } }, entry.id),
277
+ react.createElement('div', { key: 'l', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT, width: '72px', flexShrink: 0 } }, entry.layer),
278
+ react.createElement('div', { key: 'h', style: { fontFamily: GEIST, fontSize: '12px', color: '#3A3A38' } }, entry.heading),
279
+ ])
280
+ })
281
+ const more = written.total > written.entries.length
282
+ return box({ display: 'flex', flexDirection: 'column', gap: '12px' }, [
283
+ sectionLabel('WRITTEN', fmt(written.total) + ' this session'),
284
+ box({ key: 'c', display: 'flex', gap: '5px' }, chips),
285
+ box({ key: 'e', display: 'flex', flexDirection: 'column', gap: '7px' }, entries),
286
+ more ? react.createElement('div', { key: 'm', style: { fontFamily: GEIST, fontSize: '11px', color: FAINT } }, '+ ' + fmt(written.total - written.entries.length) + ' more') : null,
287
+ ])
288
+ }
289
+
290
+ /**
291
+ * The memory_map tool row.
292
+ *
293
+ * Every value comes from the result's own `meta`. Nothing is fetched and nothing is
294
+ * inferred from the rendered text: when the snapshot is absent the card says so, rather
295
+ * than drawing a shape that merely looks like data.
296
+ */
297
+ function MemoryMapCard(props) {
298
+ const block = props ? props.block : null
299
+ const meta = block && typeof block.meta === 'object' && block.meta !== null ? block.meta : null
300
+ const store = meta && meta.store ? meta.store : null
301
+ const session = meta && meta.session ? meta.session : null
302
+ const summary = store ? fmt(store.total) + ' entries · ' + fmt(store.scopeTotal) + ' scopes' : ''
303
+ const headStyle = { height: '46px', flexShrink: 0, display: 'flex', alignItems: 'center', gap: '10px', padding: '0 16px', borderBottom: '1px solid ' + LINE }
304
+ const head = box(headStyle, [
305
+ react.createElement(Glyph, { key: 'g' }),
306
+ react.createElement('div', { key: 't', style: { fontFamily: GEIST, fontSize: '13px', fontWeight: 500, color: INK } }, 'Memory map'),
307
+ react.createElement('div', { key: 's', style: { flex: 1 } }),
308
+ react.createElement('div', { key: 'c', style: { fontFamily: GEIST, fontSize: '12px', color: MUTED } }, summary),
309
+ ])
310
+ const body = []
311
+ if (store) {
312
+ body.push(box({ key: 'l', display: 'flex', flexDirection: 'column', gap: '12px' }, [
313
+ react.createElement(LayerBar, { key: 'bar', layers: store.layers }),
314
+ react.createElement(Legend, { key: 'leg', layers: store.layers }),
315
+ ]))
316
+ const attention = (store.queue && store.queue.attention) || []
317
+ const chips = attention.slice(0, 3).map(function (row) {
318
+ return react.createElement(Chip, { key: row.state + row.reason, text: fmt(row.count) + ' ' + row.state, hot: true })
319
+ })
320
+ if (store.queue && store.queue.waiting) chips.push(react.createElement(Chip, { key: 'waiting', text: fmt(store.queue.waiting) + ' waiting' }))
321
+ const stats = [
322
+ react.createElement(Stat, { key: 'a', value: fmt(store.freshness.fresh), label: 'fresh' }),
323
+ store.freshness.stale ? react.createElement(Stat, { key: 'b', value: fmt(store.freshness.stale), label: 'stale', color: RUST }) : null,
324
+ store.freshness.skip ? react.createElement(Stat, { key: 'c', value: fmt(store.freshness.skip), label: 'superseded' }) : null,
325
+ ]
326
+ const footStyle = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid ' + LINE, paddingTop: '13px', gap: '16px' }
327
+ body.push(box({ key: 'f', display: 'flex', flexDirection: 'column' }, [
328
+ box(Object.assign({ key: 'row' }, footStyle), [
329
+ box({ key: 's', display: 'flex', gap: '18px' }, stats),
330
+ box({ key: 'q', display: 'flex', gap: '5px', flexWrap: 'wrap' }, chips),
331
+ ]),
332
+ ]))
333
+ }
334
+ if (session && session.received) body.push(react.createElement(ReceivedPanel, { key: 'r', received: session.received }))
335
+ if (session && session.written) body.push(react.createElement(WrittenPanel, { key: 'w', written: session.written }))
336
+ if (!body.length) body.push(react.createElement('div', { key: 'none', style: { fontFamily: GEIST, fontSize: '12px', color: MUTED } }, 'No snapshot was recorded for this call.'))
337
+ const cardStyle = { maxWidth: '760px', border: '1px solid #E7E7E4', borderRadius: '12px', background: '#FFFFFF', display: 'flex', flexDirection: 'column' }
338
+ const bodyStyle = { padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px' }
339
+ return box(cardStyle, [head, box(Object.assign({ key: 'b' }, bodyStyle), body)]);
340
+ }
149
341
  /** Fields rendered on this page, in order. */
150
342
  const FIELDS = [
151
343
  { key: 'memoryRoot', label: 'Memory root', hint: 'Local folder or drive that holds the store.', placeholder: 'D:\\Memory' },
@@ -349,6 +541,12 @@ window.__ModuleLoader__.load({
349
541
 
350
542
  ctx.effect(() => {
351
543
  const disposers = []
544
+ // The inline face of memory_map, registered by the tool's WIRE NAME, which is how the
545
+ // host finds it. The result carries the data, so the view needs no other channel.
546
+ disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
547
+ name: 'tool.call.toolview',
548
+ key: 'memory_map',
549
+ }, MemoryMapCard)))
352
550
  disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
353
551
  name: 'settings.section',
354
552
  id: 'memory',
package/src/host.js CHANGED
@@ -690,5 +690,8 @@ export function wireHost(ctx, store, cfg) {
690
690
  })
691
691
  }
692
692
 
693
- return { scheduler, syncNow: () => runSync(true), consolidate: consolidateNow, refresh: refreshNow, status: statusNow, recover: recoverNow, rerankSearch, workspaceOf, historyOf }
693
+ // injectionMaxChars travels with the host so memory_map can describe the card that was
694
+ // ACTUALLY built: the cap is configuration, and a snapshot built with a different cap would
695
+ // report a size the session never saw.
696
+ return { scheduler, syncNow: () => runSync(true), consolidate: consolidateNow, refresh: refreshNow, status: statusNow, recover: recoverNow, rerankSearch, workspaceOf, historyOf, injectionMaxChars: cfg.injectionMaxChars }
694
697
  }
package/src/index.js CHANGED
@@ -40,6 +40,7 @@ export function hostExtras(host) {
40
40
  rerankSearch: host.rerankSearch,
41
41
  workspaceOf: host.workspaceOf,
42
42
  historyOf: host.historyOf,
43
+ injectionMaxChars: host.injectionMaxChars,
43
44
  }
44
45
  }
45
46
  export const name = 'dsh-memory'
package/src/map.js ADDED
@@ -0,0 +1,184 @@
1
+ // The UI snapshot behind memory_map: one structured view of the store, and of what THIS
2
+ // session has received from memory and written back to it.
3
+ //
4
+ // Three rules shape this module.
5
+ //
6
+ // Every number is DERIVED, never restated. Freshness comes from freshnessVerdict, the queue
7
+ // from the queue reader, and the RECEIVED panel from the injection's own rendered text. A
8
+ // second set of rules here would drift from the first, and the UI would then lie about the
9
+ // store it exists to describe.
10
+ //
11
+ // The snapshot is LOSSLESS JSON - no undefined, no Date, no Map. It crosses the tool boundary
12
+ // and a browser parses it, so one undefined rejects the whole result.
13
+ //
14
+ // Store and session are separate scopes because they answer different questions and have
15
+ // different lifetimes. The store is what EXISTS; the session is what THIS conversation saw
16
+ // and wrote. A tab can show either without pretending the other is live.
17
+
18
+ import { buildLedger } from './ledger.js'
19
+ import { LAYERS } from './entry.js'
20
+ import { freshnessVerdict } from './freshness.js'
21
+ import { buildInjectionText } from './inject.js'
22
+ import { problems, summarize } from './queue.js'
23
+
24
+ /** How many scopes and written entries the snapshot carries before it summarises instead. */
25
+ const SCOPE_LIMIT = 6
26
+ const WRITTEN_LIMIT = 8
27
+
28
+ /** Doubt, worst first: a group reports the most doubtful thing inside it, not the best. */
29
+ const SEVERITY = { fresh: 0, empty: 1, aging: 2, unverified: 3, stale: 4 }
30
+
31
+ function worstVerdict(verdicts) {
32
+ let worst = 'fresh'
33
+ for (const verdict of verdicts) {
34
+ if ((SEVERITY[verdict] ?? 0) > (SEVERITY[worst] ?? 0)) worst = verdict
35
+ }
36
+ return worst
37
+ }
38
+
39
+ /** The workspace key a ledger file belongs to, or 'global' for the shared layer. */
40
+ export function scopeKeyOf(file) {
41
+ const normalised = String(file ?? '').replace(/\\/g, '/')
42
+ const match = normalised.match(/workspaces\/([^/]+)\//)
43
+ return match ? match[1] : 'global'
44
+ }
45
+
46
+ function tally(values) {
47
+ const out = {}
48
+ for (const value of values) out[value] = (out[value] ?? 0) + 1
49
+ return out
50
+ }
51
+
52
+ /**
53
+ * Whole-store composition: entries per layer, how fresh they are, which workspaces hold
54
+ * them, and what the ingest queue is doing.
55
+ * @param store - the memory store.
56
+ * @param options.ledger - a prebuilt ledger, so a caller needing two scopes reads the disk once.
57
+ */
58
+ export function buildStoreSnapshot(store, { ledger, now = Date.now(), scopeLimit = SCOPE_LIMIT } = {}) {
59
+ const entries = ledger ?? buildLedger(store.root)
60
+ // The verdict vocabulary belongs to the freshness module. An unrecognised verdict counts as
61
+ // fresh, so a new one upstream shows up as a total that does not add up - visible - rather
62
+ // than as a crash or a silent zero.
63
+ const freshness = { fresh: 0, stale: 0, expired: 0, skip: 0 }
64
+ for (const entry of entries) {
65
+ const verdict = freshnessVerdict(entry, now).verdict
66
+ freshness[Object.hasOwn(freshness, verdict) ? verdict : 'fresh'] += 1
67
+ }
68
+ const layerCounts = tally(entries.map((e) => e.layer))
69
+ const scopeCounts = tally(entries.map((e) => scopeKeyOf(e.file)))
70
+ const scopes = Object.keys(scopeCounts)
71
+ .map((key) => ({ key, count: scopeCounts[key] }))
72
+ .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
73
+ const states = summarize(store.root)
74
+ // Group problem rows by state and reason: 76 orphaned candidates are ONE fact about the
75
+ // store, not 76 lines of UI.
76
+ const grouped = new Map()
77
+ for (const problem of problems(store.root)) {
78
+ const reason = problem.reason ?? 'unclassified'
79
+ const key = problem.state + '|' + reason
80
+ const seen = grouped.get(key)
81
+ if (seen) seen.count += 1
82
+ else grouped.set(key, { state: problem.state, reason, count: 1 })
83
+ }
84
+ return {
85
+ root: store.root,
86
+ total: entries.length,
87
+ layers: LAYERS.map((name) => ({ name, count: layerCounts[name] ?? 0 })),
88
+ freshness,
89
+ scopes: scopes.slice(0, scopeLimit),
90
+ scopeTotal: scopes.length,
91
+ queue: {
92
+ states,
93
+ waiting: (states.pending ?? 0) + (states.summarized ?? 0),
94
+ recoverable: states.orphaned ?? 0,
95
+ attention: [...grouped.values()].sort((a, b) => b.count - a.count),
96
+ },
97
+ }
98
+ }
99
+
100
+ /** The marker vocabulary the injection actually emits. Matching only these keeps a bracketed
101
+ * word inside an entry's prose from being read as a freshness verdict. */
102
+ const MARKER = /\[(STALE|AGING|UNVERIFIED|NOT STARTED)\b[^\]]*\]/g
103
+
104
+ function verdictOfMarker(marker) {
105
+ if (marker.startsWith('[STALE')) return 'stale'
106
+ if (marker.startsWith('[AGING')) return 'aging'
107
+ if (marker.startsWith('[UNVERIFIED')) return 'unverified'
108
+ if (marker.startsWith('[NOT STARTED')) return 'empty'
109
+ return 'fresh'
110
+ }
111
+
112
+ /**
113
+ * Read the sections back out of the injection's OWN rendered text.
114
+ *
115
+ * Parsing text is usually the wrong instinct, and here it is the right one: the question the
116
+ * panel answers is 'what was actually put in front of this session', and the only artefact
117
+ * that can answer it without the possibility of disagreement is the text itself. Recomputing
118
+ * the sections from the same inputs would be a second implementation of one rule.
119
+ */
120
+ export function parseInjectionSections(text) {
121
+ const sections = []
122
+ for (const raw of String(text ?? '').split('\n')) {
123
+ const heading = raw.match(/^###\s+(.+?)\s*$/)
124
+ if (heading) {
125
+ const found = heading[1].match(MARKER) ?? []
126
+ // The heading is written for the agent ('Working memory - yours'); the panel labels the
127
+ // same thing more briefly, so the owner suffix is dropped at the boundary.
128
+ const name = heading[1].replace(MARKER, '').replace(/\s+\u2014\s+yours\s*$/, '').trim()
129
+ sections.push({ name, markers: [...found] })
130
+ continue
131
+ }
132
+ if (sections.length === 0) continue
133
+ const found = raw.match(MARKER)
134
+ if (found) sections[sections.length - 1].markers.push(...found)
135
+ }
136
+ return sections.map((section) => {
137
+ const verdict = worstVerdict(section.markers.map(verdictOfMarker))
138
+ const marker = section.markers.find((m) => verdictOfMarker(m) === verdict) ?? ''
139
+ return { name: section.name, verdict, marker }
140
+ })
141
+ }
142
+
143
+ /** What memory put in front of this session, sized and broken down as it was sent. */
144
+ export function buildReceived(store, { memoryScope = 'global', key, goal, maxChars } = {}) {
145
+ const text = buildInjectionText(store, { scope: memoryScope, key, goal, maxChars })
146
+ return { chars: text.length, sections: parseInjectionSections(text) }
147
+ }
148
+
149
+ /**
150
+ * What THIS session wrote back. Attribution is exact rather than inferred from timestamps:
151
+ * every write already records source 'agent:<sessionId>', so two sessions writing in the same
152
+ * minute are still told apart.
153
+ */
154
+ export function buildWritten(store, { ledger, sessionId, limit = WRITTEN_LIMIT } = {}) {
155
+ if (!sessionId) return { total: 0, byLayer: {}, entries: [] }
156
+ const marker = 'agent:' + sessionId
157
+ const written = (ledger ?? buildLedger(store.root)).filter((entry) => entry.source === marker)
158
+ const ordered = [...written].sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')))
159
+ return {
160
+ total: written.length,
161
+ byLayer: tally(written.map((e) => e.layer)),
162
+ entries: ordered.slice(0, limit).map((e) => ({ id: e.id, layer: e.layer, heading: e.heading ?? '' })),
163
+ }
164
+ }
165
+
166
+ /**
167
+ * One snapshot, either scope or both.
168
+ * @param options.scope - 'store' (what exists), 'session' (what this conversation saw and wrote), or 'all'.
169
+ * @param options.memoryScope - 'global' or 'workspace': which memory layer this session belongs to.
170
+ */
171
+ export function buildMap(store, { scope = 'all', sessionId, memoryScope = 'global', key, goal, now = Date.now(), maxChars } = {}) {
172
+ const ledger = buildLedger(store.root)
173
+ const snapshot = { scope, generatedAt: new Date(now).toISOString() }
174
+ if (scope === 'store' || scope === 'all') snapshot.store = buildStoreSnapshot(store, { ledger, now })
175
+ if (scope === 'session' || scope === 'all') {
176
+ snapshot.session = {
177
+ sessionId: sessionId ?? null,
178
+ workspace: memoryScope === 'workspace' ? (key ?? null) : null,
179
+ received: buildReceived(store, { memoryScope, key, goal, maxChars }),
180
+ written: buildWritten(store, { ledger, sessionId }),
181
+ }
182
+ }
183
+ return snapshot
184
+ }
package/src/tools.js CHANGED
@@ -11,6 +11,7 @@ import { isSubagentSession } from './session.js'
11
11
  import { appendActivity, clearActivity, readWorking, workingSummary, writeWorkingSection } from './working.js'
12
12
  import { associate, listWorkspaces } from './workspace.js'
13
13
  import { forget, forgetUndo } from './forget.js'
14
+ import { buildMap } from './map.js'
14
15
 
15
16
  function renderHits(hits) {
16
17
  if (!hits || hits.length === 0) return [{ type: 'text', text: '(no memory matches)' }]
@@ -29,6 +30,34 @@ function canonicalPath(pathArg, exec) {
29
30
  return realpathSync(abs)
30
31
  }
31
32
 
33
+
34
+ /** The text face of a map snapshot: what a caller without a UI still needs to know. */
35
+ function renderMap(value) {
36
+ const parts = []
37
+ const store = value.store
38
+ if (store) {
39
+ const layers = store.layers.filter((l) => l.count > 0).map((l) => l.name + ' ' + l.count).join(', ')
40
+ parts.push(store.total + ' entries at ' + store.root)
41
+ parts.push('layers: ' + layers)
42
+ const f = store.freshness
43
+ parts.push('freshness: ' + f.fresh + ' fresh' + (f.stale ? ', ' + f.stale + ' STALE' : '') + (f.skip ? ', ' + f.skip + ' superseded' : ''))
44
+ const q = store.queue
45
+ const attention = q.attention.length
46
+ ? ', needs attention: ' + q.attention.slice(0, 3).map((a) => a.count + ' ' + a.state + ' (' + a.reason + ')').join(', ')
47
+ : ''
48
+ parts.push('queue: ' + q.waiting + ' waiting' + (q.recoverable ? ', ' + q.recoverable + ' recoverable' : '') + attention)
49
+ }
50
+ const session = value.session
51
+ if (session) {
52
+ const received = session.received
53
+ const sections = received.sections.map((s) => s.name + (s.marker ? ' ' + s.marker : '')).join(', ')
54
+ parts.push('this session received ' + received.chars + ' chars: ' + (sections || '(nothing injected)'))
55
+ const written = session.written
56
+ const byLayer = Object.keys(written.byLayer).map((k) => k + ' ' + written.byLayer[k]).join(', ')
57
+ parts.push('and wrote ' + written.total + ' entr' + (written.total === 1 ? 'y' : 'ies') + (byLayer ? ' (' + byLayer + ')' : ''))
58
+ }
59
+ return parts.join('; ') + '.'
60
+ }
32
61
  export function registerTools(ctx, store, extras = {}) {
33
62
  /**
34
63
  * Refuse a WRITE from a subagent session.
@@ -50,6 +79,7 @@ export function registerTools(ctx, store, extras = {}) {
50
79
  const rerankSearch = extras.rerankSearch
51
80
  const statusNow = extras.status
52
81
  const recoverNow = extras.recover
82
+ const injectionMaxChars = extras.injectionMaxChars
53
83
 
54
84
  ctx.tools.register(defineTool({
55
85
  name: 'memory_search',
@@ -275,6 +305,45 @@ export function registerTools(ctx, store, extras = {}) {
275
305
  },
276
306
  }))
277
307
 
308
+ ctx.tools.register(defineTool({
309
+ name: 'memory_map',
310
+ description: 'Show what memory holds, and what THIS session has done with it. scope "store" reports the whole store - entries per layer, freshness, workspaces, and the ingest queue. scope "session" reports what memory injected into this conversation and what this conversation wrote back. "all" returns both. Read-only, and the data behind the Memory view.',
311
+ parameters: {
312
+ scope: { type: 'string', description: 'Which view: "store" (what exists), "session" (what this conversation received and wrote), or "all" (default).' },
313
+ },
314
+ output: {
315
+ schema: {
316
+ type: 'object', additionalProperties: false,
317
+ properties: {
318
+ scope: { type: 'string', required: true },
319
+ generatedAt: { type: 'string', required: true },
320
+ store: { type: 'object', additionalProperties: true },
321
+ session: { type: 'object', additionalProperties: true },
322
+ },
323
+ },
324
+ render: (_args, value) => [{ type: 'text', text: renderMap(value) }],
325
+ },
326
+ async execute(args, exec) {
327
+ const scope = args.scope === 'store' || args.scope === 'session' ? args.scope : 'all'
328
+ // The session id is what attributes writes: every tool records source 'agent:<sessionId>'.
329
+ const sessionId = exec?.agent?.session?.id
330
+ let key
331
+ if (workspaceOf && exec?.agent) {
332
+ try { key = await workspaceOf(exec.agent) } catch { key = undefined }
333
+ }
334
+ // The goal and the char cap are read exactly as the injection reads them, so the
335
+ // RECEIVED panel describes the card that was actually built.
336
+ const goal = ctx.get('goals')?.get(exec?.agent)?.objective
337
+ return buildMap(store, {
338
+ scope,
339
+ sessionId,
340
+ memoryScope: key && key !== 'global' ? 'workspace' : 'global',
341
+ key: key && key !== 'global' ? key : undefined,
342
+ goal,
343
+ maxChars: injectionMaxChars,
344
+ })
345
+ },
346
+ }))
278
347
  ctx.tools.register(defineTool({
279
348
  name: 'memory_state',
280
349
  description: 'Record or refresh one section of the CURRENT repository state overview (what is running, the layout, what is pending, health). Replaces that section: state describes now, so a newer statement supersedes the old one. Each section carries a Verified timestamp and stale sections are reported as such.',