@try-works/dsh-memory 0.1.3 → 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 +1 -1
- package/src/client/index.js +196 -0
- package/src/host.js +4 -1
- package/src/index.js +1 -0
- package/src/map.js +184 -0
- package/src/tools.js +69 -0
package/package.json
CHANGED
package/src/client/index.js
CHANGED
|
@@ -148,6 +148,196 @@ window.__ModuleLoader__.load({
|
|
|
148
148
|
'}',
|
|
149
149
|
].join('\n')
|
|
150
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
|
+
}
|
|
151
341
|
/** Fields rendered on this page, in order. */
|
|
152
342
|
const FIELDS = [
|
|
153
343
|
{ key: 'memoryRoot', label: 'Memory root', hint: 'Local folder or drive that holds the store.', placeholder: 'D:\\Memory' },
|
|
@@ -351,6 +541,12 @@ window.__ModuleLoader__.load({
|
|
|
351
541
|
|
|
352
542
|
ctx.effect(() => {
|
|
353
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)))
|
|
354
550
|
disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
355
551
|
name: 'settings.section',
|
|
356
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
|
-
|
|
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
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.',
|