@ddtcorex/dsh-maestro-memory 1.0.1

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +448 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +756 -0
  6. package/lib/index.d.ts +10 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +627 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/memory/batch.d.ts +44 -0
  11. package/lib/memory/batch.d.ts.map +1 -0
  12. package/lib/memory/batch.js +75 -0
  13. package/lib/memory/batch.js.map +1 -0
  14. package/lib/memory/feedback.d.ts +18 -0
  15. package/lib/memory/feedback.d.ts.map +1 -0
  16. package/lib/memory/feedback.js +29 -0
  17. package/lib/memory/feedback.js.map +1 -0
  18. package/lib/memory/store.d.ts +154 -0
  19. package/lib/memory/store.d.ts.map +1 -0
  20. package/lib/memory/store.js +606 -0
  21. package/lib/memory/store.js.map +1 -0
  22. package/lib/migration/cli.d.ts +18 -0
  23. package/lib/migration/cli.d.ts.map +1 -0
  24. package/lib/migration/cli.js +143 -0
  25. package/lib/migration/cli.js.map +1 -0
  26. package/lib/migration/fixture.d.ts +45 -0
  27. package/lib/migration/fixture.d.ts.map +1 -0
  28. package/lib/migration/fixture.js +177 -0
  29. package/lib/migration/fixture.js.map +1 -0
  30. package/lib/migration/service.d.ts +75 -0
  31. package/lib/migration/service.d.ts.map +1 -0
  32. package/lib/migration/service.js +713 -0
  33. package/lib/migration/service.js.map +1 -0
  34. package/lib/prompt/snapshot.d.ts +26 -0
  35. package/lib/prompt/snapshot.d.ts.map +1 -0
  36. package/lib/prompt/snapshot.js +74 -0
  37. package/lib/prompt/snapshot.js.map +1 -0
  38. package/lib/review/queue.d.ts +72 -0
  39. package/lib/review/queue.d.ts.map +1 -0
  40. package/lib/review/queue.js +306 -0
  41. package/lib/review/queue.js.map +1 -0
  42. package/lib/skills-browser.d.ts +67 -0
  43. package/lib/skills-browser.d.ts.map +1 -0
  44. package/lib/skills-browser.js +185 -0
  45. package/lib/skills-browser.js.map +1 -0
  46. package/lib/storage/atomic-store.d.ts +211 -0
  47. package/lib/storage/atomic-store.d.ts.map +1 -0
  48. package/lib/storage/atomic-store.js +582 -0
  49. package/lib/storage/atomic-store.js.map +1 -0
  50. package/lib/storage/layout.d.ts +81 -0
  51. package/lib/storage/layout.d.ts.map +1 -0
  52. package/lib/storage/layout.js +162 -0
  53. package/lib/storage/layout.js.map +1 -0
  54. package/lib/storage/legacy-format.d.ts +104 -0
  55. package/lib/storage/legacy-format.d.ts.map +1 -0
  56. package/lib/storage/legacy-format.js +262 -0
  57. package/lib/storage/legacy-format.js.map +1 -0
  58. package/lib/sync/config.d.ts +22 -0
  59. package/lib/sync/config.d.ts.map +1 -0
  60. package/lib/sync/config.js +57 -0
  61. package/lib/sync/config.js.map +1 -0
  62. package/lib/sync/git.d.ts +97 -0
  63. package/lib/sync/git.d.ts.map +1 -0
  64. package/lib/sync/git.js +282 -0
  65. package/lib/sync/git.js.map +1 -0
  66. package/lib/sync/index.d.ts +6 -0
  67. package/lib/sync/index.d.ts.map +1 -0
  68. package/lib/sync/index.js +6 -0
  69. package/lib/sync/index.js.map +1 -0
  70. package/lib/sync/layout.d.ts +15 -0
  71. package/lib/sync/layout.d.ts.map +1 -0
  72. package/lib/sync/layout.js +33 -0
  73. package/lib/sync/layout.js.map +1 -0
  74. package/lib/sync/merge.d.ts +42 -0
  75. package/lib/sync/merge.d.ts.map +1 -0
  76. package/lib/sync/merge.js +159 -0
  77. package/lib/sync/merge.js.map +1 -0
  78. package/lib/sync/service.d.ts +70 -0
  79. package/lib/sync/service.d.ts.map +1 -0
  80. package/lib/sync/service.js +508 -0
  81. package/lib/sync/service.js.map +1 -0
  82. package/lib/todo/store.d.ts +142 -0
  83. package/lib/todo/store.d.ts.map +1 -0
  84. package/lib/todo/store.js +452 -0
  85. package/lib/todo/store.js.map +1 -0
  86. package/lib/types/client/index.d.ts +7 -0
  87. package/lib/types/client/index.d.ts.map +1 -0
  88. package/package.json +63 -0
  89. package/src/client/index.tsx +994 -0
  90. package/src/host/index.ts +591 -0
  91. package/src/host/memory/batch.ts +107 -0
  92. package/src/host/memory/feedback.ts +36 -0
  93. package/src/host/memory/store.ts +650 -0
  94. package/src/host/migration/cli.ts +139 -0
  95. package/src/host/migration/fixture.ts +218 -0
  96. package/src/host/migration/service.ts +786 -0
  97. package/src/host/prompt/snapshot.ts +95 -0
  98. package/src/host/review/queue.ts +325 -0
  99. package/src/host/skills-browser.ts +191 -0
  100. package/src/host/storage/atomic-store.ts +605 -0
  101. package/src/host/storage/layout.ts +191 -0
  102. package/src/host/storage/legacy-format.ts +308 -0
  103. package/src/host/sync/config.ts +67 -0
  104. package/src/host/sync/git.ts +280 -0
  105. package/src/host/sync/index.ts +5 -0
  106. package/src/host/sync/layout.ts +39 -0
  107. package/src/host/sync/merge.ts +188 -0
  108. package/src/host/sync/service.ts +494 -0
  109. package/src/host/todo/store.ts +480 -0
@@ -0,0 +1,994 @@
1
+ /**
2
+ * dsh-maestro-memory — client entry (M3-PR-A: Todos subtab + dtodo RPC)
3
+ * Single conversation.view slot (id: maestro-memory, order 40) with internal tabs.
4
+ */
5
+
6
+ import * as React from 'react'
7
+
8
+ export const inject = ['slots', 'sessions', 'connection'] as const
9
+
10
+ const RPC_CHANNEL = '/dsh-maestro-memory'
11
+
12
+ // Theme-aware style values. DSH exposes these as CSS custom properties that
13
+ // flip for light/dark (see Theme.listTokens). The previous hard-coded light
14
+ // hex values made the view unreadable on the dark theme: an inherited white
15
+ // label on white `#fff`/`#eee` buttons (white-on-white), plus bright `#ccc`/
16
+ // `#ddd` borders and dark `#333`/`#666` text that disappeared against the
17
+ // dark base. Using the --dsw-alias-* tokens keeps it readable in both themes.
18
+ const STYLE = {
19
+ text: 'var(--dsw-alias-label-primary)',
20
+ textSec: 'var(--dsw-alias-label-secondary)',
21
+ borderL1: '1px solid var(--dsw-alias-border-l1)',
22
+ borderL2: '1px solid var(--dsw-alias-border-l2)',
23
+ surface: 'var(--dsw-alias-bg-layer-1)',
24
+ // Active tab/target highlight — a filled overlay that reads as "selected" in
25
+ // both themes (lighter over dark, grayish over light). Avoids bg-layer-2,
26
+ // which collapses to white on the light theme.
27
+ active: 'var(--dsw-alias-interactive-bg-active)',
28
+ success: 'var(--dsw-alias-state-success-primary)',
29
+ error: 'var(--dsw-alias-state-error-primary)',
30
+ // Brand accent keeps white text (it's readable in both themes; the DSH
31
+ // --dsw-alias-brand-primary token resolves to white here, which would make
32
+ // white text invisible, so we don't use the token for a solid button fill).
33
+ brand: '#06c',
34
+ // Neutral secondary chip (Archive / Cancel / Undo): theme surface + primary label.
35
+ neutral: 'var(--dsw-alias-bg-layer-2)',
36
+ onAccent: '#fff', // white text on colored accents — readable in both themes
37
+ }
38
+
39
+ // Scoped design tokens for the view so it reads like a first-class DSH panel.
40
+ // Hover/focus/transition states are impossible with inline styles alone, so we
41
+ // attach a small <style> scoped to the .dshmem container. All colors come from
42
+ // the --dsw-alias-* theme tokens and flip with the DSH light/dark theme.
43
+ // Typography is deliberately NOT declared here: the container inherits the DSH
44
+ // text styles from the host tree (label-primary color, 16px/28px body text),
45
+ // and every child resolves its font through normal CSS inheritance — no more
46
+ // per-element `font: 13px system-ui` stamps that fight the host theme.
47
+ const MEM_CSS = `
48
+ .dshmem { color: var(--dsw-alias-label-primary); font-size: 13px; }
49
+ /* Form controls carry a UA-stylesheet font (13.33px Arial) and do not
50
+ inherit by default: opt them back into the host chain explicitly. */
51
+ .dshmem button { font: inherit; cursor: pointer; transition: color .12s ease, background .12s ease, border-color .12s ease; }
52
+ .dshmem button:hover { color: var(--dsw-alias-label-primary); }
53
+ .dshmem button:active { transform: translateY(.5px); }
54
+ .dshmem button:focus-visible { outline: 2px solid var(--dsw-alias-interactive-bg-active); outline-offset: 1px; }
55
+ .dshmem input, .dshmem textarea, .dshmem select { font: inherit; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 5px 8px; }
56
+ .dshmem input:focus, .dshmem textarea:focus, .dshmem select:focus { outline: none; border-color: var(--dsw-alias-border-l2); }
57
+ .dshmem textarea { resize: vertical; }
58
+ .dshmem .card { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px; }
59
+ .dshmem .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
60
+ .dshmem .muted { color: var(--dsw-alias-label-secondary); }
61
+
62
+ /* Top-level view navigation — an underline tab bar (mirrors the DSH
63
+ * Chat/Trajectory/Memory header tabs above it), NOT boxed chips, so it reads
64
+ * as navigation and stays distinct from the memory-track filters below. */
65
+ .dshmem .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l1); margin-bottom: 14px; }
66
+ .dshmem .tab { appearance: none; background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; padding: 7px 12px; border-radius: 0; font-weight: 500; color: var(--dsw-alias-label-secondary); }
67
+ .dshmem .tab:hover { color: var(--dsw-alias-label-primary); }
68
+ .dshmem .tab-active { color: var(--dsw-alias-label-primary); font-weight: 700; border-bottom-color: #06c; }
69
+
70
+ /* Memory-track / date filters + actions — one consistent control height so
71
+ * nothing reflows or misaligns when a field appears/disappears. */
72
+ .dshmem .tracklabel { text-transform: uppercase; letter-spacing: .04em; color: var(--dsw-alias-label-secondary); }
73
+ .dshmem .control { height: 28px; display: inline-flex; align-items: center; gap: 6px; line-height: 1; }
74
+ .dshmem .pill { border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); padding: 0 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); }
75
+ .dshmem .pill:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
76
+ .dshmem .pill-active { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); font-weight: 600; border-color: var(--dsw-alias-border-l2); }
77
+ .dshmem .ghost { border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); padding: 0 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); }
78
+ .dshmem .ghost:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
79
+ .dshmem .cwd { box-sizing: border-box; height: 28px; flex: 1; min-width: 180px; padding: 0 10px; }
80
+ .dshmem .cwd:disabled { opacity: .5; cursor: not-allowed; }
81
+ .dshmem .cwd:disabled::placeholder { color: var(--dsw-alias-label-secondary); }
82
+ .dshmem .tabbadge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 5px; margin-left: 6px; border-radius: 999px; background: var(--dsw-alias-state-error-primary, #d9534f); color: #fff; font-size: 11px; font-weight: 700; line-height: 1; vertical-align: middle; }
83
+ `
84
+
85
+ // Global CSS for the top-level header tab badge (injected into <head> by the
86
+ // boot-time effect). Reuses DSH theme tokens so it flips with light/dark; a
87
+ // soft blink draws attention to pending reviews without being obnoxious.
88
+ const HEADER_BADGE_CSS = `
89
+ .dshmem-header-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 5px; margin-left: 6px; border-radius: 999px; background: var(--dsw-alias-state-error-primary, #d9534f); color: #fff; font-size: 11px; font-weight: 700; line-height: 1; vertical-align: middle; animation: dshmem-blink 1.2s ease-in-out infinite; }
90
+ @keyframes dshmem-blink { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
91
+ `
92
+
93
+ // Best-effort current-session cwd, derived from the injected `sessions` feed
94
+ // (never `ctx.conversation` — that face is not injectable here, and touching a
95
+ // non-injected service throws `cannot get property ... without inject`).
96
+ // `ctx.sessions.list.getSnapshot()` returns `{ current, byId }`, and each row
97
+ // carries the session's canonical `cwd`. Undefined on a fresh page / no session.
98
+ function sessionCwd(ctx: any): string {
99
+ try {
100
+ const snap = ctx?.sessions?.list?.getSnapshot?.()
101
+ const id: string | undefined = snap?.current
102
+ if (!id) return ''
103
+ return (snap?.byId?.[id]?.cwd as string) ?? ''
104
+ } catch {
105
+ return ''
106
+ }
107
+ }
108
+
109
+ function useRpc(ctx: any) {
110
+ return React.useCallback(
111
+ (endpoint: string, payload: any) => {
112
+ const conn = (ctx as any).connection ?? (ctx as any).get?.('connection')
113
+ if (!conn?.rpc?.call) return Promise.reject(new Error('RPC not available'))
114
+ return conn.rpc.call(RPC_CHANNEL, endpoint, payload).then((result: any) => {
115
+ if (result?.ok === true) return result.value
116
+ const message = typeof result?.error?.message === 'string' ? result.error.message : 'RPC request failed'
117
+ throw new Error(message)
118
+ })
119
+ },
120
+ [ctx],
121
+ )
122
+ }
123
+
124
+ function ReviewQueueView({ ctx, onPendingChange }: { ctx: any; onPendingChange?: (n: number) => void }): React.ReactElement {
125
+ const rpc = useRpc(ctx)
126
+ const [entries, setEntries] = React.useState<any[]>([])
127
+ const [loading, setLoading] = React.useState(true)
128
+ const [edits, setEdits] = React.useState<Record<number, string>>({})
129
+ const [msg, setMsg] = React.useState<string>('')
130
+
131
+ const load = React.useCallback(async () => {
132
+ setLoading(true)
133
+ try {
134
+ const res: any = await rpc('queue.list', {})
135
+ const list = Array.isArray(res?.entries) ? res.entries : []
136
+ setEntries(list)
137
+ onPendingChange?.(list.length)
138
+ } catch (e: any) {
139
+ setMsg(`load failed: ${e?.message ?? String(e)}`)
140
+ } finally {
141
+ setLoading(false)
142
+ }
143
+ }, [rpc, onPendingChange])
144
+
145
+ React.useEffect(() => {
146
+ load()
147
+ }, [load])
148
+
149
+ const decide = React.useCallback(
150
+ async (action: 'approve' | 'reject' | 'archive', index: number) => {
151
+ setMsg('')
152
+ const payload: any = { action, indices: [index] }
153
+ if (action === 'approve' && edits[index] !== undefined && edits[index].trim() !== '') {
154
+ payload.edits = { [String(index)]: edits[index] }
155
+ }
156
+ try {
157
+ const res: any = await rpc('queue.decide', payload)
158
+ if (res?.ok) {
159
+ setMsg(res.lines ? res.lines.join('; ') : `${action} ok`)
160
+ await load()
161
+ } else {
162
+ setMsg(`failed: ${res?.error ?? 'unknown'}`)
163
+ }
164
+ } catch (e: any) {
165
+ setMsg(`error: ${e?.message ?? String(e)}`)
166
+ }
167
+ },
168
+ [rpc, edits, load],
169
+ )
170
+
171
+ if (loading) return React.createElement('div', null, 'Loading queue…')
172
+
173
+ if (entries.length === 0) {
174
+ return React.createElement(
175
+ 'div',
176
+ null,
177
+ React.createElement('div', { style: { opacity: 0.7, marginBottom: 8 } }, 'No pending suggestions'),
178
+ msg ? React.createElement('div', { style: { color: STYLE.textSec } }, msg) : null,
179
+ React.createElement('button', { onClick: load, style: { marginTop: 8 } }, 'Refresh'),
180
+ )
181
+ }
182
+
183
+ return React.createElement(
184
+ 'div',
185
+ null,
186
+ React.createElement('div', { style: { marginBottom: 8, opacity: 0.7 } }, `${entries.length} pending`),
187
+ ...entries.map((e: any, idx: number) => {
188
+ const number = idx + 1
189
+ return React.createElement(
190
+ 'div',
191
+ {
192
+ key: number,
193
+ style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 8 },
194
+ },
195
+ React.createElement('div', { style: { fontWeight: 600 } }, `#${number} [${e.target}]`),
196
+ React.createElement('div', { style: { margin: '4px 0', whiteSpace: 'pre-wrap' } }, e.content),
197
+ e.reason ? React.createElement('div', { style: { opacity: 0.7 } }, `Reason: ${e.reason}`) : null,
198
+ React.createElement('textarea', {
199
+ value: edits[number] ?? '',
200
+ placeholder: 'Edit content before approve (optional)',
201
+ onChange: (ev: any) => setEdits((prev) => ({ ...prev, [number]: ev.target.value })),
202
+ style: { width: '100%', minHeight: 40, marginTop: 4 },
203
+ }),
204
+ React.createElement(
205
+ 'div',
206
+ { style: { display: 'flex', gap: 8, marginTop: 8 } },
207
+ React.createElement(
208
+ 'button',
209
+ {
210
+ onClick: () => decide('approve', number),
211
+ 'data-testid': `approve-${number}`,
212
+ style: { background: STYLE.success, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
213
+ },
214
+ 'Approve',
215
+ ),
216
+ React.createElement(
217
+ 'button',
218
+ {
219
+ onClick: () => decide('reject', number),
220
+ 'data-testid': `reject-${number}`,
221
+ style: { background: STYLE.error, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
222
+ },
223
+ 'Reject',
224
+ ),
225
+ React.createElement(
226
+ 'button',
227
+ {
228
+ onClick: () => decide('archive', number),
229
+ 'data-testid': `archive-${number}`,
230
+ style: { background: STYLE.neutral, color: STYLE.text, border: STYLE.borderL1, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
231
+ },
232
+ 'Archive',
233
+ ),
234
+ ),
235
+ )
236
+ }),
237
+ msg ? React.createElement('div', { style: { marginTop: 8, color: STYLE.text } }, msg) : null,
238
+ React.createElement('button', { onClick: load, style: { marginTop: 8 } }, 'Refresh'),
239
+ )
240
+ }
241
+
242
+ function TodosView({ ctx }: { ctx: any }): React.ReactElement {
243
+ const rpc = useRpc(ctx)
244
+ const [items, setItems] = React.useState<any[]>([])
245
+ const [loading, setLoading] = React.useState(true)
246
+ const [msg, setMsg] = React.useState<string>('')
247
+ const [target, setTarget] = React.useState<string>('all')
248
+ const [showAll, setShowAll] = React.useState(false)
249
+ const [showPast, setShowPast] = React.useState(false)
250
+ const [showExpired, setShowExpired] = React.useState(false)
251
+ const [draftContent, setDraftContent] = React.useState('')
252
+ const [draftTarget, setDraftTarget] = React.useState<string>('work')
253
+ const [draftDue, setDraftDue] = React.useState('')
254
+ const [draftQuadrant, setDraftQuadrant] = React.useState<string>('')
255
+ const [draftCat, setDraftCat] = React.useState('')
256
+ const [editId, setEditId] = React.useState<string | null>(null)
257
+ const [editContent, setEditContent] = React.useState('')
258
+ const [editDue, setEditDue] = React.useState('')
259
+ const [editQuadrant, setEditQuadrant] = React.useState('')
260
+ const [editStatus, setEditStatus] = React.useState('')
261
+ const [editCat, setEditCat] = React.useState('')
262
+
263
+ const load = React.useCallback(async () => {
264
+ setLoading(true)
265
+ setMsg('')
266
+ try {
267
+ const rpcPayload: any = {
268
+ target: target !== 'all' ? target : undefined,
269
+ opts: {
270
+ all: showAll,
271
+ past: showPast,
272
+ expired: showExpired,
273
+ },
274
+ }
275
+ // For single target we need to pass correctly; todo.list expects target or targets
276
+ const res: any = await rpc('todo.list', rpcPayload)
277
+ const list = Array.isArray(res?.items) ? res.items : []
278
+ setItems(list)
279
+ if (res?.hint) setMsg(res.hint)
280
+ else if (res?.text) {
281
+ // fallback: text contains items but we already have items
282
+ }
283
+ } catch (e: any) {
284
+ setMsg(`load failed: ${e?.message ?? String(e)}`)
285
+ } finally {
286
+ setLoading(false)
287
+ }
288
+ }, [rpc, target, showAll, showPast, showExpired])
289
+
290
+ React.useEffect(() => {
291
+ load()
292
+ }, [load])
293
+
294
+ const addTodo = React.useCallback(async () => {
295
+ const content = draftContent.trim()
296
+ if (!content) {
297
+ setMsg('content required')
298
+ return
299
+ }
300
+ setMsg('')
301
+ try {
302
+ const res: any = await rpc('todo.mutate', {
303
+ action: 'add',
304
+ target: draftTarget,
305
+ content,
306
+ due: draftDue || undefined,
307
+ quadrant: draftQuadrant || undefined,
308
+ cat: draftCat || undefined,
309
+ })
310
+ if (res?.ok) {
311
+ setDraftContent('')
312
+ setDraftDue('')
313
+ setDraftQuadrant('')
314
+ setDraftCat('')
315
+ setMsg(`added (id: ${res.id})`)
316
+ await load()
317
+ } else {
318
+ setMsg(`add failed: ${res?.message ?? res?.error ?? 'unknown'}`)
319
+ }
320
+ } catch (e: any) {
321
+ setMsg(`error: ${e?.message ?? String(e)}`)
322
+ }
323
+ }, [rpc, draftContent, draftTarget, draftDue, draftQuadrant, draftCat, load])
324
+
325
+ const doneTodo = React.useCallback(
326
+ async (item: any) => {
327
+ try {
328
+ const res: any = await rpc('todo.mutate', { action: 'done', target: item.target, id: item.id })
329
+ if (res?.ok) {
330
+ setMsg(res.message ?? 'done')
331
+ await load()
332
+ } else setMsg(`done failed: ${res?.message ?? res?.error}`)
333
+ } catch (e: any) {
334
+ setMsg(`error: ${e?.message ?? String(e)}`)
335
+ }
336
+ },
337
+ [rpc, load],
338
+ )
339
+
340
+ const undoTodo = React.useCallback(
341
+ async (item: any) => {
342
+ try {
343
+ // Re-open a done todo: set status back to pending (only if currently done).
344
+ const res: any = await rpc('todo.mutate', { action: 'update', target: item.target, id: item.id, status: 'pending' })
345
+ if (res?.ok) {
346
+ setMsg(res.message ?? 'undone')
347
+ await load()
348
+ } else setMsg(`undo failed: ${res?.message ?? res?.error}`)
349
+ } catch (e: any) {
350
+ setMsg(`error: ${e?.message ?? String(e)}`)
351
+ }
352
+ },
353
+ [rpc, load],
354
+ )
355
+
356
+ const removeTodo = React.useCallback(
357
+ async (item: any) => {
358
+ try {
359
+ const res: any = await rpc('todo.mutate', { action: 'remove', target: item.target, id: item.id })
360
+ if (res?.ok) {
361
+ setMsg(res.message ?? 'removed')
362
+ await load()
363
+ } else setMsg(`remove failed: ${res?.message ?? res?.error}`)
364
+ } catch (e: any) {
365
+ setMsg(`error: ${e?.message ?? String(e)}`)
366
+ }
367
+ },
368
+ [rpc, load],
369
+ )
370
+
371
+ const startEdit = (item: any) => {
372
+ setEditId(item.id)
373
+ setEditContent(item.text ?? '')
374
+ setEditDue(item.due ?? '')
375
+ setEditQuadrant(item.quadrant ?? '')
376
+ setEditStatus(item.status ?? 'pending')
377
+ setEditCat(item.cat ?? '')
378
+ }
379
+
380
+ const saveEdit = React.useCallback(
381
+ async (item: any) => {
382
+ try {
383
+ const patch: any = {}
384
+ if (editContent !== item.text) patch.content = editContent
385
+ if (editQuadrant !== (item.quadrant ?? '')) patch.quadrant = editQuadrant || null
386
+ if (editDue !== (item.due ?? '')) patch.due = editDue || null
387
+ if (editStatus !== item.status) patch.status = editStatus
388
+ if (editCat !== (item.cat ?? '')) patch.cat = editCat || null
389
+ const res: any = await rpc('todo.mutate', {
390
+ action: 'update',
391
+ target: item.target,
392
+ id: item.id,
393
+ ...patch,
394
+ })
395
+ if (res?.ok) {
396
+ setEditId(null)
397
+ setMsg(res.message ?? 'updated')
398
+ await load()
399
+ } else setMsg(`update failed: ${res?.message ?? res?.error}`)
400
+ } catch (e: any) {
401
+ setMsg(`error: ${e?.message ?? String(e)}`)
402
+ }
403
+ },
404
+ [rpc, editContent, editDue, editQuadrant, editStatus, editCat, load],
405
+ )
406
+
407
+ return React.createElement(
408
+ 'div',
409
+ null,
410
+ React.createElement(
411
+ 'div',
412
+ { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 } },
413
+ (['all', 'life', 'work', 'project', 'daily'] as const).map((k) =>
414
+ React.createElement(
415
+ 'button',
416
+ {
417
+ key: k,
418
+ onClick: () => setTarget(k),
419
+ 'data-testid': `todo-target-${k}`,
420
+ style: {
421
+ fontWeight: target === k ? 700 : 400,
422
+ padding: '4px 8px',
423
+ borderRadius: 6,
424
+ border: target === k ? STYLE.borderL2 : STYLE.borderL1,
425
+ background: target === k ? STYLE.active : STYLE.surface,
426
+ color: STYLE.text,
427
+ cursor: 'pointer',
428
+ },
429
+ },
430
+ k,
431
+ ),
432
+ ),
433
+ ),
434
+ React.createElement(
435
+ 'div',
436
+ { style: { display: 'flex', gap: 12, marginBottom: 8, alignItems: 'center', flexWrap: 'wrap' } },
437
+ React.createElement(
438
+ 'label',
439
+ { style: { display: 'flex', gap: 4, alignItems: 'center' } },
440
+ React.createElement('input', {
441
+ type: 'checkbox',
442
+ checked: showAll,
443
+ onChange: (e: any) => setShowAll(e.target.checked),
444
+ 'data-testid': 'todo-all',
445
+ }),
446
+ 'all (no limit)',
447
+ ),
448
+ React.createElement(
449
+ 'label',
450
+ { style: { display: 'flex', gap: 4, alignItems: 'center' } },
451
+ React.createElement('input', {
452
+ type: 'checkbox',
453
+ checked: showPast,
454
+ onChange: (e: any) => setShowPast(e.target.checked),
455
+ 'data-testid': 'todo-past',
456
+ }),
457
+ 'past',
458
+ ),
459
+ React.createElement(
460
+ 'label',
461
+ { style: { display: 'flex', gap: 4, alignItems: 'center' } },
462
+ React.createElement('input', {
463
+ type: 'checkbox',
464
+ checked: showExpired,
465
+ onChange: (e: any) => setShowExpired(e.target.checked),
466
+ 'data-testid': 'todo-expired',
467
+ }),
468
+ 'expired',
469
+ ),
470
+ React.createElement(
471
+ 'button',
472
+ { onClick: load, style: { padding: '4px 8px' }, 'data-testid': 'todo-refresh' },
473
+ 'Refresh',
474
+ ),
475
+ ),
476
+ React.createElement(
477
+ 'div',
478
+ { style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 12 } },
479
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 6 } }, 'Add todo'),
480
+ React.createElement('textarea', {
481
+ value: draftContent,
482
+ placeholder: 'Todo content',
483
+ onChange: (e: any) => setDraftContent(e.target.value),
484
+ style: { width: '100%', minHeight: 40, marginBottom: 6 },
485
+ 'data-testid': 'todo-add-content',
486
+ }),
487
+ React.createElement(
488
+ 'div',
489
+ { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 } },
490
+ React.createElement(
491
+ 'select',
492
+ {
493
+ value: draftTarget,
494
+ onChange: (e: any) => setDraftTarget(e.target.value),
495
+ style: { padding: '4px' },
496
+ 'data-testid': 'todo-add-target',
497
+ },
498
+ (['life', 'work', 'project', 'daily'] as const).map((t) => React.createElement('option', { key: t, value: t }, t)),
499
+ ),
500
+ React.createElement(
501
+ 'select',
502
+ {
503
+ value: draftQuadrant,
504
+ onChange: (e: any) => setDraftQuadrant(e.target.value),
505
+ style: { padding: '4px' },
506
+ 'data-testid': 'todo-add-quadrant',
507
+ },
508
+ React.createElement('option', { value: '' }, 'quadrant'),
509
+ React.createElement('option', { value: 'q1' }, 'q1'),
510
+ React.createElement('option', { value: 'q2' }, 'q2'),
511
+ React.createElement('option', { value: 'q3' }, 'q3'),
512
+ React.createElement('option', { value: 'q4' }, 'q4'),
513
+ ),
514
+ React.createElement('input', {
515
+ value: draftDue,
516
+ placeholder: 'due YYYY-MM-DD',
517
+ onChange: (e: any) => setDraftDue(e.target.value),
518
+ style: { padding: '4px', width: 120 },
519
+ 'data-testid': 'todo-add-due',
520
+ }),
521
+ React.createElement('input', {
522
+ value: draftCat,
523
+ placeholder: 'cat',
524
+ onChange: (e: any) => setDraftCat(e.target.value),
525
+ style: { padding: '4px', width: 80 },
526
+ 'data-testid': 'todo-add-cat',
527
+ }),
528
+ ),
529
+ React.createElement(
530
+ 'button',
531
+ {
532
+ onClick: addTodo,
533
+ style: { background: STYLE.success, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '6px 12px', cursor: 'pointer' },
534
+ 'data-testid': 'todo-add-btn',
535
+ },
536
+ 'Add',
537
+ ),
538
+ ),
539
+ msg ? React.createElement('div', { style: { color: STYLE.text, marginBottom: 8, whiteSpace: 'pre-wrap' } }, msg) : null,
540
+ loading
541
+ ? React.createElement('div', null, 'Loading todos…')
542
+ : items.length === 0
543
+ ? React.createElement('div', { style: { opacity: 0.7 } }, 'No todos (smart view: overdue / due today / current project / Q1-Q2, max 8). Try "all" or "past".')
544
+ : React.createElement(
545
+ 'div',
546
+ null,
547
+ React.createElement('div', { style: { opacity: 0.7, marginBottom: 6 } }, `${items.length} todos${!showAll && items.length === 8 ? ' (smart view limited to 8)' : ''}`),
548
+ ...items.map((it: any) =>
549
+ React.createElement(
550
+ 'div',
551
+ { key: it.id, style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 8 } },
552
+ React.createElement(
553
+ 'div',
554
+ { style: { opacity: 0.7 } },
555
+ `[${it.target}]`,
556
+ it.quadrant ? ` [${it.quadrant}]` : '',
557
+ it.due ? ` [due:${it.due}]` : '',
558
+ it.status !== 'pending' ? ` [${it.status}]` : '',
559
+ it.cat ? ` [cat:${it.cat}]` : '',
560
+ it.doneAt ? ` [done:${it.doneAt}]` : '',
561
+ it.past ? ` [past ${it.day}]` : '',
562
+ ` id:${it.id}`,
563
+ ),
564
+ editId === it.id
565
+ ? React.createElement(
566
+ 'div',
567
+ null,
568
+ React.createElement('textarea', {
569
+ value: editContent,
570
+ onChange: (e: any) => setEditContent(e.target.value),
571
+ style: { width: '100%', minHeight: 40, marginTop: 4 },
572
+ 'data-testid': `todo-edit-content-${it.id}`,
573
+ }),
574
+ React.createElement(
575
+ 'div',
576
+ { style: { display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' } },
577
+ React.createElement(
578
+ 'select',
579
+ {
580
+ value: editQuadrant,
581
+ onChange: (e: any) => setEditQuadrant(e.target.value),
582
+ 'data-testid': `todo-edit-quadrant-${it.id}`,
583
+ },
584
+ React.createElement('option', { value: '' }, 'no quadrant'),
585
+ React.createElement('option', { value: 'q1' }, 'q1'),
586
+ React.createElement('option', { value: 'q2' }, 'q2'),
587
+ React.createElement('option', { value: 'q3' }, 'q3'),
588
+ React.createElement('option', { value: 'q4' }, 'q4'),
589
+ ),
590
+ React.createElement('input', {
591
+ value: editDue,
592
+ placeholder: 'due YYYY-MM-DD',
593
+ onChange: (e: any) => setEditDue(e.target.value),
594
+ style: { width: 120 },
595
+ 'data-testid': `todo-edit-due-${it.id}`,
596
+ }),
597
+ React.createElement(
598
+ 'select',
599
+ {
600
+ value: editStatus,
601
+ onChange: (e: any) => setEditStatus(e.target.value),
602
+ 'data-testid': `todo-edit-status-${it.id}`,
603
+ },
604
+ (['pending', 'doing', 'done', 'blocked', 'cancelled'] as const).map((s) => React.createElement('option', { key: s, value: s }, s)),
605
+ ),
606
+ React.createElement('input', {
607
+ value: editCat,
608
+ placeholder: 'cat',
609
+ onChange: (e: any) => setEditCat(e.target.value),
610
+ style: { width: 80 },
611
+ 'data-testid': `todo-edit-cat-${it.id}`,
612
+ }),
613
+ ),
614
+ React.createElement(
615
+ 'div',
616
+ { style: { display: 'flex', gap: 8, marginTop: 8 } },
617
+ React.createElement(
618
+ 'button',
619
+ {
620
+ onClick: () => saveEdit(it),
621
+ style: { background: STYLE.success, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
622
+ 'data-testid': `todo-save-${it.id}`,
623
+ },
624
+ 'Save',
625
+ ),
626
+ React.createElement(
627
+ 'button',
628
+ {
629
+ onClick: () => setEditId(null),
630
+ style: { background: STYLE.neutral, color: STYLE.text, border: STYLE.borderL1, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
631
+ 'data-testid': `todo-cancel-${it.id}`,
632
+ },
633
+ 'Cancel',
634
+ ),
635
+ ),
636
+ )
637
+ : React.createElement(
638
+ 'div',
639
+ null,
640
+ React.createElement('div', { style: { margin: '4px 0', whiteSpace: 'pre-wrap' } }, it.text),
641
+ React.createElement(
642
+ 'div',
643
+ { style: { display: 'flex', gap: 6, marginTop: 8, flexWrap: 'wrap' } },
644
+ React.createElement(
645
+ 'button',
646
+ {
647
+ onClick: () => (it.status === 'done' ? undoTodo(it) : doneTodo(it)),
648
+ style: { background: it.status === 'done' ? STYLE.neutral : STYLE.success, color: it.status === 'done' ? STYLE.text : STYLE.onAccent, border: it.status === 'done' ? STYLE.borderL1 : 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
649
+ 'data-testid': `todo-done-${it.id}`,
650
+ },
651
+ it.status === 'done' ? 'Undo' : 'Done',
652
+ ),
653
+ React.createElement(
654
+ 'button',
655
+ {
656
+ onClick: () => startEdit(it),
657
+ style: { background: STYLE.brand, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
658
+ 'data-testid': `todo-edit-${it.id}`,
659
+ },
660
+ 'Edit',
661
+ ),
662
+ React.createElement(
663
+ 'button',
664
+ {
665
+ onClick: () => removeTodo(it),
666
+ style: { background: STYLE.error, color: STYLE.onAccent, border: 0, borderRadius: 4, padding: '4px 8px', cursor: 'pointer' },
667
+ 'data-testid': `todo-remove-${it.id}`,
668
+ },
669
+ 'Remove',
670
+ ),
671
+ ),
672
+ ),
673
+ ),
674
+ ),
675
+ ),
676
+ )
677
+ }
678
+
679
+ function SkillsView({ ctx }: { ctx: any }): React.ReactElement {
680
+ const rpc = useRpc(ctx)
681
+ const [entries, setEntries] = React.useState<any[]>([])
682
+ const [loading, setLoading] = React.useState(true)
683
+ const [msg, setMsg] = React.useState<string>('')
684
+
685
+ const load = React.useCallback(async () => {
686
+ setLoading(true)
687
+ setMsg('')
688
+ try {
689
+ const res: any = await rpc('skills.list', {})
690
+ if (res?.ok) {
691
+ setEntries(Array.isArray(res.entries) ? res.entries : [])
692
+ if (res.entries.length === 0) setMsg('No skills found (maestro-skills not installed or empty)')
693
+ } else {
694
+ setMsg(`load failed: ${res?.error ?? 'unknown'}`)
695
+ }
696
+ } catch (e: any) {
697
+ setMsg(`error: ${e?.message ?? String(e)}`)
698
+ } finally {
699
+ setLoading(false)
700
+ }
701
+ }, [rpc])
702
+
703
+ React.useEffect(() => {
704
+ load()
705
+ }, [load])
706
+
707
+ if (loading) return React.createElement('div', null, 'Loading skills…')
708
+
709
+ return React.createElement(
710
+ 'div',
711
+ null,
712
+ React.createElement(
713
+ 'div',
714
+ { style: { opacity: 0.7, marginBottom: 8 } },
715
+ `${entries.length} skills (read-only browser, metadata/origin only — maestro-skills discovery unchanged)`,
716
+ ),
717
+ entries.length === 0
718
+ ? React.createElement('div', { style: { opacity: 0.7 } }, msg || 'No skills')
719
+ : React.createElement(
720
+ 'div',
721
+ null,
722
+ ...entries.map((e: any) =>
723
+ React.createElement(
724
+ 'div',
725
+ { key: e.name, style: { border: STYLE.borderL1, borderRadius: 8, padding: 8, marginBottom: 8 } },
726
+ React.createElement('div', { style: { fontWeight: 600 } }, e.name),
727
+ React.createElement('div', { style: { opacity: 0.7 } }, `[${e.origin}] ${e.path}`),
728
+ React.createElement('div', { style: { margin: '4px 0', whiteSpace: 'pre-wrap' } }, e.description),
729
+ e.metadata && Object.keys(e.metadata).length > 0
730
+ ? React.createElement(
731
+ 'div',
732
+ { style: { opacity: 0.6, marginTop: 4 } },
733
+ `metadata: ${Object.entries(e.metadata)
734
+ .map(([k, v]) => `${k}=${String(v).slice(0, 60)}`)
735
+ .join(', ')}`,
736
+ )
737
+ : null,
738
+ ),
739
+ ),
740
+ ),
741
+ msg ? React.createElement('div', { style: { marginTop: 8, color: STYLE.text } }, msg) : null,
742
+ React.createElement('button', { onClick: load, style: { marginTop: 8, padding: '4px 8px' }, 'data-testid': 'skills-refresh' }, 'Refresh'),
743
+ React.createElement('div', { style: { marginTop: 8, opacity: 0.6 } }, 'Read-only — model suggestions cannot change skills. Future edits, if approved, will require explicit user action + path containment.'),
744
+ )
745
+ }
746
+
747
+ function MemoryListView({ ctx }: { ctx: any }): React.ReactElement {
748
+ const rpc = useRpc(ctx)
749
+ const [track, setTrack] = React.useState<string>('key')
750
+ const [cwd, setCwd] = React.useState<string>(() => sessionCwd(ctx))
751
+ const [entries, setEntries] = React.useState<string[]>([])
752
+ const [loading, setLoading] = React.useState(true)
753
+ const [msg, setMsg] = React.useState<string>('')
754
+
755
+ const needsCwd = track === 'key' || track === 'project'
756
+ const load = React.useCallback(async () => {
757
+ setLoading(true)
758
+ setMsg('')
759
+ try {
760
+ if (needsCwd && !cwd.trim()) {
761
+ setEntries([])
762
+ setMsg('cwd required for the key/project track')
763
+ setLoading(false)
764
+ return
765
+ }
766
+ const res: any = await rpc('memory.list', { target: track, cwd: needsCwd ? cwd.trim() : undefined })
767
+ setEntries(Array.isArray(res?.entries) ? res.entries : [])
768
+ } catch (e: any) {
769
+ setMsg(`load failed: ${e?.message ?? String(e)}`)
770
+ } finally {
771
+ setLoading(false)
772
+ }
773
+ }, [rpc, track, cwd, needsCwd])
774
+
775
+ React.useEffect(() => {
776
+ load()
777
+ }, [load])
778
+
779
+ return React.createElement(
780
+ 'div',
781
+ { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
782
+ React.createElement(
783
+ 'div',
784
+ { className: 'row', style: { alignItems: 'center', gap: 6 } },
785
+ React.createElement('span', { className: 'tracklabel' }, 'Track'),
786
+ (['memory', 'user', 'key', 'project', 'daily'] as const).map((k) =>
787
+ React.createElement(
788
+ 'button',
789
+ {
790
+ key: k,
791
+ onClick: () => setTrack(k),
792
+ 'aria-pressed': track === k,
793
+ className: track === k ? 'control pill pill-active' : 'control pill',
794
+ 'data-testid': `mem-track-${k}`,
795
+ },
796
+ k,
797
+ ),
798
+ ),
799
+ ),
800
+ React.createElement(
801
+ 'div',
802
+ { className: 'row', style: { alignItems: 'center' } },
803
+ React.createElement('input', {
804
+ value: cwd,
805
+ disabled: !needsCwd,
806
+ placeholder: needsCwd ? 'cwd (for key/project)' : 'cwd is not used for this track',
807
+ onChange: (e: any) => setCwd((e.target as HTMLInputElement).value),
808
+ 'data-testid': 'mem-cwd',
809
+ className: 'cwd',
810
+ }),
811
+ React.createElement('button', { onClick: load, className: 'control ghost', 'data-testid': 'mem-refresh' }, 'Refresh'),
812
+ ),
813
+ msg ? React.createElement('div', { className: 'muted' }, msg) : null,
814
+ loading
815
+ ? React.createElement('div', null, 'Loading memory…')
816
+ : entries.length === 0
817
+ ? React.createElement('div', { className: 'muted' }, '(no entries)')
818
+ : React.createElement(
819
+ 'div',
820
+ null,
821
+ React.createElement('div', { className: 'muted', style: { marginBottom: 6 } }, `${entries.length} entries`),
822
+ ...entries.map((e: string, idx: number) =>
823
+ React.createElement(
824
+ 'div',
825
+ { key: idx, className: 'card', style: { marginBottom: 8 } },
826
+ React.createElement('div', { style: { whiteSpace: 'pre-wrap' } }, e),
827
+ ),
828
+ ),
829
+ ),
830
+ )
831
+ }
832
+
833
+ function MemoryView({ ctx }: { ctx: any }): React.ReactElement {
834
+ const rpc = useRpc(ctx)
835
+ const [tab, setTab] = React.useState<'memory' | 'review' | 'todos' | 'skills'>('memory')
836
+ const [pending, setPending] = React.useState(0)
837
+
838
+ const refreshPending = React.useCallback(async () => {
839
+ try {
840
+ const res: any = await rpc('status', {})
841
+ setPending(typeof res?.queue === 'number' ? res.queue : 0)
842
+ } catch {
843
+ setPending(0)
844
+ }
845
+ }, [rpc])
846
+
847
+ React.useEffect(() => {
848
+ refreshPending()
849
+ }, [refreshPending, tab])
850
+
851
+ return React.createElement(
852
+ 'div',
853
+ { className: 'dshmem', style: { padding: 16, color: STYLE.text, display: 'flex', flexDirection: 'column' } },
854
+ React.createElement('style', null, MEM_CSS),
855
+ React.createElement(
856
+ 'div',
857
+ { className: 'tabs', role: 'tablist' },
858
+ (['memory', 'review', 'todos', 'skills'] as const).map((k) =>
859
+ React.createElement(
860
+ 'button',
861
+ {
862
+ key: k,
863
+ role: 'tab',
864
+ 'aria-selected': tab === k,
865
+ onClick: () => setTab(k),
866
+ className: tab === k ? 'tab tab-active' : 'tab',
867
+ 'data-testid': `tab-${k}`,
868
+ },
869
+ k,
870
+ k === 'review' && pending > 0
871
+ ? React.createElement('span', { className: 'tabbadge', 'data-testid': 'tab-badge-review' }, String(pending))
872
+ : null,
873
+ ),
874
+ ),
875
+ ),
876
+ tab === 'memory'
877
+ ? React.createElement(MemoryListView, { ctx })
878
+ : tab === 'review'
879
+ ? React.createElement(ReviewQueueView, { ctx, onPendingChange: setPending })
880
+ : tab === 'todos'
881
+ ? React.createElement(TodosView, { ctx })
882
+ : React.createElement(SkillsView, { ctx }),
883
+ )
884
+ }
885
+
886
+ export function apply(ctx: any): void {
887
+ ctx.effect(() => {
888
+ const dispose = ctx.slots.inject('conversation.view', () =>
889
+ ctx.slots.register(
890
+ {
891
+ name: 'conversation.view',
892
+ id: 'maestro-memory',
893
+ order: 40,
894
+ label: () => 'Memory',
895
+ },
896
+ () => React.createElement(MemoryView, { ctx }),
897
+ ),
898
+ )
899
+ return () => {
900
+ if (typeof dispose === 'function') dispose()
901
+ }
902
+ }, 'maestro-memory: view')
903
+
904
+ // Top-level header tab badge: pending-suggestion count, with a soft blink,
905
+ // injected into the DSH shell header tab labeled "Memory". DSH projects
906
+ // 'conversation.view' tabs as plain string labels (ViewTab.label), so there is
907
+ // no React slot to render into — the plugin reaches the shell DOM directly
908
+ // (same technique as the mobile nav plugin).
909
+ ctx.effect(() => {
910
+ const conn = (ctx as any).connection ?? (ctx as any).get?.('connection')
911
+ if (!conn?.rpc?.call) return () => {}
912
+ const BADGE_ATTR = 'data-dshmem-header-badge'
913
+ const VIEW_ATTR = 'data-dshmem-memory-tab'
914
+
915
+ const findMemoryTab = (): HTMLElement | null => {
916
+ const tabs = document.querySelectorAll('button[role="tab"]')
917
+ for (const tab of Array.from(tabs)) {
918
+ if (tab.getAttribute(VIEW_ATTR) === '1') return tab as HTMLElement
919
+ }
920
+ for (const tab of Array.from(tabs)) {
921
+ if (tab.closest('.dshmem')) continue
922
+ if ((tab.textContent ?? '').trim() === 'Memory') {
923
+ tab.setAttribute(VIEW_ATTR, '1')
924
+ return tab as HTMLElement
925
+ }
926
+ }
927
+ return null
928
+ }
929
+
930
+ const renderBadge = (count: number): void => {
931
+ const tab = findMemoryTab()
932
+ if (!tab) return
933
+ const existing = tab.querySelector(`[${BADGE_ATTR}]`)
934
+ if (count <= 0) {
935
+ if (existing) existing.remove()
936
+ return
937
+ }
938
+ if (existing && existing.textContent === String(count)) return
939
+ if (existing) existing.remove()
940
+ const badge = document.createElement('span')
941
+ badge.setAttribute(BADGE_ATTR, '')
942
+ badge.className = 'dshmem-header-badge'
943
+ badge.textContent = String(count)
944
+ tab.appendChild(badge)
945
+ }
946
+
947
+ let styleEl = document.getElementById('dshmem-header-style') as HTMLStyleElement | null
948
+ if (!styleEl) {
949
+ styleEl = document.createElement('style')
950
+ styleEl.id = 'dshmem-header-style'
951
+ styleEl.textContent = HEADER_BADGE_CSS
952
+ document.head.appendChild(styleEl)
953
+ }
954
+
955
+ let disposed = false
956
+ let lastCount = 0
957
+ const refresh = async (): Promise<void> => {
958
+ if (disposed) return
959
+ try {
960
+ const result: any = await conn.rpc.call(RPC_CHANNEL, 'status', {})
961
+ const value = result?.ok === true ? result.value : null
962
+ lastCount = typeof value?.queue === 'number' ? value.queue : 0
963
+ renderBadge(lastCount)
964
+ } catch {
965
+ lastCount = 0
966
+ renderBadge(0)
967
+ }
968
+ }
969
+
970
+ void refresh()
971
+ const timer = window.setInterval(() => {
972
+ void refresh()
973
+ }, 8000)
974
+
975
+ const observer = new MutationObserver(() => {
976
+ if (disposed) return
977
+ const tab = findMemoryTab()
978
+ if (!tab) return
979
+ const hasBadge = tab.querySelector(`[${BADGE_ATTR}]`)
980
+ if (lastCount > 0 && !hasBadge) renderBadge(lastCount)
981
+ else if (lastCount <= 0 && hasBadge) renderBadge(0)
982
+ })
983
+ observer.observe(document.body, { childList: true, subtree: true })
984
+
985
+ return () => {
986
+ disposed = true
987
+ window.clearInterval(timer)
988
+ observer.disconnect()
989
+ styleEl?.remove()
990
+ const tab = findMemoryTab()
991
+ if (tab) tab.querySelector(`[${BADGE_ATTR}]`)?.remove()
992
+ }
993
+ }, 'maestro-memory: header-badge')
994
+ }