mixdog 0.8.0 → 0.8.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.
@@ -87,7 +87,7 @@ import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddi
87
87
  import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
88
88
  import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
89
89
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
90
- import { claimAndMarkScheduledCycle, makeCycleRequestSignature, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
90
+ import { claimAndMarkScheduledCycle, makeCycleRequestSignature, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
91
91
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
92
92
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
93
93
  import { retrieveEntries } from './lib/memory-retrievers.mjs'
@@ -1110,24 +1110,34 @@ async function enqueueScheduledCycle3(intervalMs, _reason = 'scheduled') {
1110
1110
  }
1111
1111
  }
1112
1112
 
1113
- function scheduleScheduledCycle1(config, signature) {
1113
+ function scheduleScheduledCycle1(config, signature, attempt = 0) {
1114
+ const maxRetries = resolveCoalesceMaxRetries(config, 3)
1115
+ if (attempt > maxRetries) {
1116
+ __mixdogMemoryLog('[cycle1] scheduled queue retry cap reached\n')
1117
+ return
1118
+ }
1114
1119
  scheduleCoalescedCycleRetry(db, 'cycle1', async () => {
1115
1120
  if (_cycle1InFlight) {
1116
- scheduleScheduledCycle1(config, signature)
1121
+ scheduleScheduledCycle1(config, signature, attempt + 1)
1117
1122
  return
1118
1123
  }
1119
1124
  const result = await _awaitCycle1Run(config, {
1120
1125
  coalescedRetry: true,
1121
1126
  onCoalescedSuccess: recordCycle1Result,
1122
1127
  })
1123
- if (result?.skippedInFlight) scheduleScheduledCycle1(config, signature)
1128
+ if (result?.skippedInFlight) scheduleScheduledCycle1(config, signature, attempt + 1)
1124
1129
  }, config, signature)
1125
1130
  }
1126
1131
 
1127
- function scheduleScheduledCycle2(config, signature) {
1132
+ function scheduleScheduledCycle2(config, signature, attempt = 0) {
1133
+ const maxRetries = resolveCoalesceMaxRetries(config, 3)
1134
+ if (attempt > maxRetries) {
1135
+ __mixdogMemoryLog('[cycle2] scheduled queue retry cap reached\n')
1136
+ return
1137
+ }
1128
1138
  scheduleCoalescedCycleRetry(db, 'cycle2', async () => {
1129
1139
  if (_cycle2InFlight) {
1130
- scheduleScheduledCycle2(config, signature)
1140
+ scheduleScheduledCycle2(config, signature, attempt + 1)
1131
1141
  return
1132
1142
  }
1133
1143
  _cycle2InFlight = true
@@ -1137,7 +1147,7 @@ function scheduleScheduledCycle2(config, signature) {
1137
1147
  onCoalescedSuccess: _finalizeCycle2Run,
1138
1148
  }, DATA_DIR)
1139
1149
  if (result?.skippedInFlight) {
1140
- scheduleScheduledCycle2(config, signature)
1150
+ scheduleScheduledCycle2(config, signature, attempt + 1)
1141
1151
  } else if (result?.coalescedRetryNoop) {
1142
1152
  __mixdogMemoryLog('[cycle2] scheduled queue noop\n')
1143
1153
  } else if (result?.ok === false) {
@@ -1151,11 +1161,16 @@ function scheduleScheduledCycle2(config, signature) {
1151
1161
  }, config, signature)
1152
1162
  }
1153
1163
 
1154
- function scheduleScheduledCycle3(config, signature) {
1164
+ function scheduleScheduledCycle3(config, signature, attempt = 0) {
1155
1165
  const retryConfig = config?.cycle3 || config
1166
+ const maxRetries = resolveCoalesceMaxRetries(retryConfig, 3)
1167
+ if (attempt > maxRetries) {
1168
+ __mixdogMemoryLog('[cycle3] scheduled queue retry cap reached\n')
1169
+ return
1170
+ }
1156
1171
  scheduleCoalescedCycleRetry(db, 'cycle3', async () => {
1157
1172
  if (_cycle3InFlight) {
1158
- scheduleScheduledCycle3(config, signature)
1173
+ scheduleScheduledCycle3(config, signature, attempt + 1)
1159
1174
  return
1160
1175
  }
1161
1176
  _cycle3InFlight = true
@@ -1165,7 +1180,7 @@ function scheduleScheduledCycle3(config, signature) {
1165
1180
  onCoalescedSuccess: () => setCycleLastRun('cycle3', Date.now()),
1166
1181
  })
1167
1182
  if (result?.skippedInFlight) {
1168
- scheduleScheduledCycle3(config, signature)
1183
+ scheduleScheduledCycle3(config, signature, attempt + 1)
1169
1184
  } else if (result?.coalescedRetryNoop) {
1170
1185
  __mixdogMemoryLog('[cycle3] scheduled queue noop\n')
1171
1186
  }
@@ -1860,6 +1875,93 @@ function renderEntryLines(rows) {
1860
1875
  return lines.join('\n')
1861
1876
  }
1862
1877
 
1878
+ async function dumpSessionRootChunks(args = {}) {
1879
+ const sessionId = String(args.sessionId || args.session_id || '').trim()
1880
+ if (!sessionId) return { text: '(no current session)', rows: [], chunks: [], isError: true }
1881
+ const includeRaw = args.includeRaw !== false
1882
+ const limit = Math.max(1, Math.min(1000, Number(args.limit) || 1000))
1883
+ const rootRows = (await db.query(`
1884
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
1885
+ element, category, summary, status, score, last_seen_at, project_id
1886
+ FROM entries
1887
+ WHERE session_id = $1 AND is_root = 1
1888
+ ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
1889
+ LIMIT $2
1890
+ `, [sessionId, limit])).rows
1891
+ const roots = rootRows.map((r) => ({ ...r, members: [] }))
1892
+ const rootIds = roots.map((r) => Number(r.id)).filter((id) => Number.isFinite(id))
1893
+ const memberRows = rootIds.length > 0
1894
+ ? (await db.query(`
1895
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
1896
+ FROM entries
1897
+ WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
1898
+ ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
1899
+ `, [rootIds])).rows
1900
+ : []
1901
+ const byRoot = new Map(roots.map((r) => [Number(r.id), r]))
1902
+ for (const m of memberRows) {
1903
+ const root = byRoot.get(Number(m.chunk_root))
1904
+ if (root) root.members.push(m)
1905
+ }
1906
+ let rawRows = []
1907
+ if (includeRaw) {
1908
+ rawRows = (await db.query(`
1909
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
1910
+ FROM entries
1911
+ WHERE session_id = $1
1912
+ AND is_root = 0
1913
+ AND (chunk_root IS NULL OR chunk_root = id)
1914
+ ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
1915
+ LIMIT $2
1916
+ `, [sessionId, limit])).rows
1917
+ }
1918
+ const chunks = []
1919
+ for (const root of roots) {
1920
+ const memberText = root.members
1921
+ .map((m) => `${m.role === 'assistant' ? 'assistant' : m.role === 'user' ? 'user' : m.role}: ${cleanMemoryText(String(m.content ?? ''))}`)
1922
+ .filter(Boolean)
1923
+ .join('\n')
1924
+ const summary = [root.element, root.summary].map((v) => String(v || '').trim()).filter(Boolean).join(' — ')
1925
+ chunks.push({
1926
+ id: Number(root.id),
1927
+ kind: 'root',
1928
+ ts: Number(root.ts) || 0,
1929
+ sourceTurn: root.source_turn ?? null,
1930
+ category: root.category || null,
1931
+ summary,
1932
+ text: memberText || cleanMemoryText(String(root.content ?? '')),
1933
+ members: root.members,
1934
+ })
1935
+ }
1936
+ for (const raw of rawRows) {
1937
+ chunks.push({
1938
+ id: Number(raw.id),
1939
+ kind: 'raw',
1940
+ ts: Number(raw.ts) || 0,
1941
+ sourceTurn: raw.source_turn ?? null,
1942
+ category: null,
1943
+ summary: '',
1944
+ text: `${raw.role === 'assistant' ? 'assistant' : raw.role === 'user' ? 'user' : raw.role}: ${cleanMemoryText(String(raw.content ?? ''))}`,
1945
+ members: [],
1946
+ })
1947
+ }
1948
+ chunks.sort((a, b) => {
1949
+ const at = Number.isFinite(Number(a.sourceTurn)) ? Number(a.sourceTurn) : 2147483647
1950
+ const bt = Number.isFinite(Number(b.sourceTurn)) ? Number(b.sourceTurn) : 2147483647
1951
+ return (at - bt) || ((a.ts || 0) - (b.ts || 0)) || ((a.id || 0) - (b.id || 0))
1952
+ })
1953
+ const text = chunks.length
1954
+ ? chunks.map((chunk, idx) => {
1955
+ const label = chunk.kind === 'root'
1956
+ ? `# chunk ${idx + 1} root=${chunk.id}${chunk.category ? ` category=${chunk.category}` : ''}`
1957
+ : `# raw ${idx + 1} id=${chunk.id}`
1958
+ const summary = chunk.summary ? `summary: ${chunk.summary}\n` : ''
1959
+ return `${label}\n${summary}${chunk.text}`.trim()
1960
+ }).join('\n\n')
1961
+ : '(no results)'
1962
+ return { text, rows: [...roots, ...rawRows], chunks }
1963
+ }
1964
+
1863
1965
  async function entryStats() {
1864
1966
  return await db.transaction(async (tx) => {
1865
1967
  const total = (await tx.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
@@ -2224,6 +2326,10 @@ async function handleMemoryAction(args, signal) {
2224
2326
  return ingestSessionMessages(args)
2225
2327
  }
2226
2328
 
2329
+ if (action === 'dump_session_roots') {
2330
+ return dumpSessionRootChunks(args)
2331
+ }
2332
+
2227
2333
  if (action === 'manage') {
2228
2334
  const op = String(args.op ?? '').trim().toLowerCase()
2229
2335
  if (!['add', 'edit', 'delete'].includes(op)) {
@@ -277,7 +277,7 @@ export async function runCycle1(db, config = {}, options = {}, dataDir = null) {
277
277
  )
278
278
  if (_runCycle1InFlight.has(db)) {
279
279
  if (!coalescedRetry) await markCycleRequest(db, 'cycle1', 'in-flight', requestSignature)
280
- scheduleRetry()
280
+ if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
281
281
  logCycle1Throttled('in-flight', '[cycle1] skipped: already in flight for this db\n')
282
282
  return {
283
283
  processed: 0, chunks: 0, skipped: 0, sessions: 0,
@@ -301,7 +301,7 @@ export async function runCycle1(db, config = {}, options = {}, dataDir = null) {
301
301
  if (!gotLock) {
302
302
  client.release()
303
303
  if (!coalescedRetry) await markCycleRequest(db, 'cycle1', 'advisory-lock', requestSignature)
304
- scheduleRetry()
304
+ if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
305
305
  logCycle1Throttled('advisory-lock', '[cycle1] skipped: advisory lock held by another worker\n')
306
306
  return { processed: 0, chunks: 0, skipped: 0, sessions: 0, skippedInFlight: true, pendingRows: await countPendingRows(db) }
307
307
  }
@@ -390,23 +390,23 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
390
390
  const timeout = callerDeadlineMs > 0
391
391
  ? Math.min(baseTimeout, Math.max(5000, callerDeadlineMs - 1000))
392
392
  : baseTimeout
393
- // Select oldest-pending sessions first, then fetch oldest rows per selected
394
- // session. This keeps classifier windows session-isolated and prevents hot
395
- // sessions/new rows from starving older pending work.
393
+ // Select closest/recent sessions first, then fetch closest/recent rows per
394
+ // selected session. Memory fill is recency-first; session isolation below
395
+ // keeps unrelated episodes out of the same classifier prompt.
396
396
  const fetchResult = await db.query(
397
397
  `WITH selected_sessions AS (
398
- SELECT session_id, MIN(ts) AS oldest_ts, MIN(id) AS oldest_id
398
+ SELECT session_id, MAX(ts) AS latest_ts, MAX(id) AS latest_id
399
399
  FROM entries
400
400
  WHERE chunk_root IS NULL
401
401
  AND NULLIF(btrim(session_id), '') IS NOT NULL
402
402
  AND (reviewed_at IS NULL OR reviewed_at < $2)
403
403
  GROUP BY session_id
404
- ORDER BY oldest_ts ASC, oldest_id ASC
404
+ ORDER BY latest_ts DESC, latest_id DESC
405
405
  LIMIT $1
406
406
  ), ranked AS (
407
407
  SELECT e.id, e.ts, e.role, e.content, e.session_id, e.source_ref, e.project_id,
408
- s.oldest_ts, s.oldest_id,
409
- ROW_NUMBER() OVER (PARTITION BY e.session_id ORDER BY e.ts ASC, e.id ASC) AS rn
408
+ s.latest_ts, s.latest_id,
409
+ ROW_NUMBER() OVER (PARTITION BY e.session_id ORDER BY e.ts DESC, e.id DESC) AS rn
410
410
  FROM entries e
411
411
  JOIN selected_sessions s ON s.session_id = e.session_id
412
412
  WHERE e.chunk_root IS NULL
@@ -415,7 +415,7 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
415
415
  SELECT id, ts, role, content, session_id, source_ref, project_id
416
416
  FROM ranked
417
417
  WHERE rn <= $3
418
- ORDER BY oldest_ts ASC, oldest_id ASC, session_id, ts DESC, id DESC`,
418
+ ORDER BY latest_ts DESC, latest_id DESC, session_id, ts DESC, id DESC`,
419
419
  [sessionCap, Date.now() - CYCLE1_OMITTED_COOLDOWN_MS, batchSize],
420
420
  )
421
421
  throwIfAborted(signal)
@@ -901,7 +901,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
901
901
  }
902
902
  if (_runCycle2InFlight.has(db)) {
903
903
  if (!coalescedRetry) await markCycleRequest(db, 'cycle2', 'in-flight', requestSignature)
904
- scheduleRetry()
904
+ if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
905
905
  __mixdogMemoryLog('[cycle2] skipped: already in flight for this db\n')
906
906
  return { ok: true, ...partial, skippedInFlight: true }
907
907
  }
@@ -921,7 +921,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
921
921
  if (!gotLock) {
922
922
  client.release()
923
923
  if (!coalescedRetry) await markCycleRequest(db, 'cycle2', 'advisory-lock', requestSignature)
924
- scheduleRetry()
924
+ if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
925
925
  __mixdogMemoryLog('[cycle2] skipped: advisory lock held by another worker\n')
926
926
  return { ok: true, ...partial, skippedInFlight: true }
927
927
  }
@@ -272,7 +272,7 @@ export async function runCycle3(db, config, dataDir, options = {}) {
272
272
  }
273
273
  if (_runCycle3InFlight.has(db)) {
274
274
  if (!coalescedRetry) await markCycleRequest(db, 'cycle3', 'in-flight', requestSignature)
275
- scheduleRetry()
275
+ if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
276
276
  __mixdogMemoryLog('[cycle3] skipped: already in flight for this db\n')
277
277
  return { ...partial, skippedInFlight: true }
278
278
  }
@@ -291,7 +291,7 @@ export async function runCycle3(db, config, dataDir, options = {}) {
291
291
  if (!gotLock) {
292
292
  client.release()
293
293
  if (!coalescedRetry) await markCycleRequest(db, 'cycle3', 'advisory-lock', requestSignature)
294
- scheduleRetry()
294
+ if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
295
295
  __mixdogMemoryLog('[cycle3] skipped: advisory lock held by another worker\n')
296
296
  return { ...partial, skippedInFlight: true }
297
297
  }
@@ -4,6 +4,18 @@ function __mixdogMemoryLog(...args) {
4
4
  return __mixdogMemoryStderrWrite(...args);
5
5
  }
6
6
 
7
+ function installPoolErrorHandler(pool, label) {
8
+ if (!pool || typeof pool.on !== 'function') return pool
9
+ pool.on('error', (err, client) => {
10
+ const code = err?.code || err?.errno || ''
11
+ const msg = err?.message || String(err || 'unknown error')
12
+ const pid = client?.processID ? ` pid=${client.processID}` : ''
13
+ const suffix = code ? ` ${code}` : ''
14
+ __mixdogMemoryLog(`[pg-adapter] ${label} idle client error${suffix}${pid}: ${msg}\n`)
15
+ })
16
+ return pool
17
+ }
18
+
7
19
  // pg-adapter.mjs — PG connection manager for mixdog 0.4.0
8
20
  // Single owner: supervisor-pg.ensurePgInstance(dataDir) starts PG.
9
21
  // pg-adapter calls supervisor-pg — never pg-process directly.
@@ -235,6 +247,7 @@ export async function ensurePgInstance(dataDir, opts = {}) {
235
247
  host, port, user: PG_USER, database: 'postgres',
236
248
  password: '', max: 1, idleTimeoutMillis: 5_000,
237
249
  })
250
+ installPoolErrorHandler(adminPool, 'admin-pool')
238
251
  try {
239
252
  // CREATE DATABASE cannot run inside a transaction, so guard the
240
253
  // check-then-create with a session-level advisory lock held on a single
@@ -280,6 +293,7 @@ export async function ensurePgInstance(dataDir, opts = {}) {
280
293
  password: '', max: 5, idleTimeoutMillis: 30_000,
281
294
  connectionTimeoutMillis: 10_000,
282
295
  })
296
+ installPoolErrorHandler(pgPool, `pool:${schema}`)
283
297
 
284
298
  // 4. Bootstrap extensions + schemas once (idempotent).
285
299
  await bootstrapInstance(pgPool, resolve(dataDir))
@@ -721,7 +721,7 @@ export function isMemorySurface(label) {
721
721
 
722
722
  export const CATEGORY_ORDER = [
723
723
  'Read', 'Search', 'Web Research', 'Memory', 'Explore',
724
- 'Patch', 'Edit', 'Shell', 'Agent', 'Channel', 'Setup', 'Other',
724
+ 'Patch', 'Shell', 'Agent', 'Channel', 'Setup', 'Other',
725
725
  ];
726
726
 
727
727
  const TOOL_CATEGORY = new Map([
@@ -751,8 +751,6 @@ const TOOL_CATEGORY = new Map([
751
751
  ['memory', 'Memory'],
752
752
  ['explore', 'Explore'],
753
753
  ['apply_patch', 'Patch'],
754
- ['write', 'Edit'],
755
- ['edit', 'Edit'],
756
754
  ['bash', 'Shell'],
757
755
  ['shell', 'Shell'],
758
756
  ['shell_command', 'Shell'],
@@ -804,7 +802,6 @@ const CATEGORY_COPY = new Map([
804
802
  ['Memory', { active: 'Checking', done: 'Checked', noun: 'memory item' }],
805
803
  ['Explore', { active: 'Exploring', done: 'Explored', noun: 'item' }],
806
804
  ['Patch', { active: 'Editing', done: 'Edited', noun: 'item' }],
807
- ['Edit', { active: 'Editing', done: 'Edited', noun: 'item' }],
808
805
  ['Shell', { active: 'Running', done: 'Ran', noun: 'command' }],
809
806
  ['Agent', { active: 'Calling', done: 'Called', noun: 'agent' }],
810
807
  ['Channel', { active: 'Sending', done: 'Sent', noun: 'message' }],
package/src/tui/App.jsx CHANGED
@@ -2420,6 +2420,7 @@ export function App({ store, initialStatusLine = '' }) {
2420
2420
 
2421
2421
  const openContextPicker = () => {
2422
2422
  const tools = store.toolsStatus?.() || { activeCount: 0, count: 0, activeTools: [] };
2423
+ const mcp = store.mcpStatus?.() || { connectedCount: 0, configuredCount: 0, failedCount: 0 };
2423
2424
  const skills = store.skillsStatus?.() || { count: 0 };
2424
2425
  const plugins = store.pluginsStatus?.() || { count: 0 };
2425
2426
  const context = store.contextStatus?.() || {};
@@ -2553,6 +2554,7 @@ export function App({ store, initialStatusLine = '' }) {
2553
2554
  messages: {
2554
2555
  tokens: messages.estimatedTokens,
2555
2556
  count: messages.count,
2557
+ semantic: messages.semantic,
2556
2558
  },
2557
2559
  tools: {
2558
2560
  schemaTokens: request.toolSchemaTokens,
@@ -2564,6 +2566,7 @@ export function App({ store, initialStatusLine = '' }) {
2564
2566
  results: messages.toolResultCount,
2565
2567
  },
2566
2568
  request: {
2569
+ toolSchemaBreakdown: request.toolSchemaBreakdown,
2567
2570
  overheadTokens: request.requestOverheadTokens,
2568
2571
  reserveTokens: request.reserveTokens,
2569
2572
  },
@@ -2580,6 +2583,11 @@ export function App({ store, initialStatusLine = '' }) {
2580
2583
  skills: skills.count,
2581
2584
  plugins: plugins.count,
2582
2585
  },
2586
+ mcp: {
2587
+ connected: mcp.connectedCount,
2588
+ configured: mcp.configuredCount,
2589
+ failed: mcp.failedCount,
2590
+ },
2583
2591
  },
2584
2592
  rows: contextRows,
2585
2593
  });
@@ -4538,7 +4546,7 @@ export function App({ store, initialStatusLine = '' }) {
4538
4546
  return;
4539
4547
  }
4540
4548
  if (r.changed === false) {
4541
- store.pushNotice('No changes.', 'warn');
4549
+ store.pushNotice('nothing to compact', 'warn');
4542
4550
  return;
4543
4551
  }
4544
4552
  store.pushNotice('Compact done.', 'info');
@@ -5001,7 +5009,7 @@ export function App({ store, initialStatusLine = '' }) {
5001
5009
  const desiredFloatingPanelRows = picker
5002
5010
  ? (picker.fillAvailable ? maxFloatingPanelRows : PANEL_BASE_ROWS + OPTION_PANEL_EXTRA_ROWS)
5003
5011
  : contextPanel
5004
- ? PANEL_BASE_ROWS + OPTION_PANEL_EXTRA_ROWS
5012
+ ? PANEL_BASE_ROWS + OPTION_PANEL_EXTRA_ROWS + 3
5005
5013
  : usagePanel
5006
5014
  ? PANEL_BASE_ROWS + OPTION_PANEL_EXTRA_ROWS
5007
5015
  : slashPaletteOpen
@@ -75,25 +75,57 @@ function metricValue(parts) {
75
75
  return parts.filter(Boolean).join(' · ');
76
76
  }
77
77
 
78
- function MetricCell({ label, value, width, tone }) {
79
- const labelWidth = Math.min(12, Math.max(8, Math.floor(width * 0.34)));
80
- const valueWidth = Math.max(0, width - labelWidth - 1);
78
+ function DetailLine({ label, value, columns }) {
79
+ const innerWidth = Math.max(24, Math.floor(columns || 80) - 4);
80
+ const labelWidth = 10;
81
+ const valueWidth = Math.max(0, innerWidth - labelWidth - 2);
82
+ return (
83
+ <Box flexDirection="row" width="100%">
84
+ <Text color={theme.subtle}>{padCells(truncateText(label, labelWidth), labelWidth)}</Text>
85
+ <Text color={theme.inactive}> </Text>
86
+ <Text color={theme.text}>{truncateText(value, valueWidth)}</Text>
87
+ </Box>
88
+ );
89
+ }
90
+
91
+ function bucketTokens(map, names) {
92
+ return names.reduce((sum, name) => sum + finiteNumber(map?.[name]?.tokens), 0);
93
+ }
94
+
95
+ function bucketCount(map, names) {
96
+ return names.reduce((sum, name) => sum + finiteNumber(map?.[name]?.count), 0);
97
+ }
98
+
99
+ function semanticTokens(semantic, names) {
100
+ return names.reduce((sum, name) => sum + finiteNumber(semantic?.[name]?.tokens), 0);
101
+ }
102
+
103
+ function CategoryItem({ label, tokens, total, width }) {
104
+ const labelWidth = Math.min(11, Math.max(7, Math.floor(width * 0.22)));
105
+ const pctWidth = 6;
106
+ const tokenWidth = 7;
107
+ const barWidth = Math.max(6, Math.min(20, width - labelWidth - pctWidth - tokenWidth - 4));
108
+ const pct = percent(tokens, total);
81
109
  return (
82
110
  <Box flexDirection="row" width={width}>
83
- <Text color={theme.subtle}>{padCells(truncateText(label, labelWidth), labelWidth)} </Text>
84
- <Text color={tone || theme.text}>{truncateText(value, valueWidth)}</Text>
111
+ <Text color={theme.subtle}>{padCells(truncateText(label, labelWidth), labelWidth)}</Text>
112
+ <Text color={theme.inactive}> </Text>
113
+ <Text color={usageColor(pct)}>{padCells(percentLabel(tokens, total), pctWidth)}</Text>
114
+ <ProgressBar value={tokens} total={total} width={barWidth} />
115
+ <Text color={theme.inactive}> </Text>
116
+ <Text color={theme.text}>{padCells(formatTokens(tokens), tokenWidth)}</Text>
85
117
  </Box>
86
118
  );
87
119
  }
88
120
 
89
- function MetricGrid({ cells, columns }) {
121
+ function CategoryGrid({ categories, columns, total }) {
90
122
  const innerWidth = Math.max(24, Math.floor(columns || 80) - 4);
91
- const twoColumns = innerWidth >= 76;
123
+ const twoColumns = innerWidth >= 84;
92
124
  if (!twoColumns) {
93
125
  return (
94
126
  <Box flexDirection="column" width="100%">
95
- {cells.map((cell) => (
96
- <MetricCell key={cell.label} {...cell} width={innerWidth} />
127
+ {categories.map((category) => (
128
+ <CategoryItem key={category.label} {...category} total={total} width={innerWidth} />
97
129
  ))}
98
130
  </Box>
99
131
  );
@@ -102,14 +134,14 @@ function MetricGrid({ cells, columns }) {
102
134
  const leftWidth = Math.floor((innerWidth - gap) / 2);
103
135
  const rightWidth = innerWidth - gap - leftWidth;
104
136
  const pairs = [];
105
- for (let i = 0; i < cells.length; i += 2) pairs.push(cells.slice(i, i + 2));
137
+ for (let i = 0; i < categories.length; i += 2) pairs.push(categories.slice(i, i + 2));
106
138
  return (
107
139
  <Box flexDirection="column" width="100%">
108
140
  {pairs.map((pair, index) => (
109
141
  <Box key={index} flexDirection="row" width="100%">
110
- <MetricCell {...pair[0]} width={leftWidth} />
142
+ <CategoryItem {...pair[0]} total={total} width={leftWidth} />
111
143
  <Text>{' '.repeat(gap)}</Text>
112
- {pair[1] ? <MetricCell {...pair[1]} width={rightWidth} /> : null}
144
+ {pair[1] ? <CategoryItem {...pair[1]} total={total} width={rightWidth} /> : null}
113
145
  </Box>
114
146
  ))}
115
147
  </Box>
@@ -127,6 +159,9 @@ function ContextUsageView({ detail, columns }) {
127
159
  const lastApi = detail?.lastApi || {};
128
160
  const cache = detail?.cache || {};
129
161
  const extensions = detail?.extensions || {};
162
+ const mcp = detail?.mcp || {};
163
+ const semantic = messages.semantic || {};
164
+ const schema = request.toolSchemaBreakdown || {};
130
165
  const usedTokens = finiteNumber(usage.usedTokens);
131
166
  const windowTokens = finiteNumber(usage.windowTokens);
132
167
  const freeTokens = windowTokens ? Math.max(0, windowTokens - usedTokens) : finiteNumber(usage.freeTokens);
@@ -134,25 +169,34 @@ function ContextUsageView({ detail, columns }) {
134
169
  const summaryText = `${formatTokens(usedTokens)} / ${formatTokens(windowTokens)} · ${formatTokens(freeTokens)} free`;
135
170
  const pctText = `${percentLabel(usedTokens, windowTokens)} used`;
136
171
  const barWidth = Math.max(12, Math.min(34, innerWidth - stringWidth(summaryText) - stringWidth(pctText) - 5));
172
+ const builtInToolTokens = bucketTokens(schema, ['code', 'web', 'mutation', 'channels', 'setup', 'other']);
173
+ const builtInToolCount = bucketCount(schema, ['code', 'web', 'mutation', 'channels', 'setup', 'other']);
174
+ const projectTokens = semanticTokens(semantic, ['project', 'workspace', 'environment', 'other']);
137
175
  const compactionLine = metricValue([
138
- compaction.stage || 'pending',
176
+ compaction.stage && compaction.stage !== 'pending' ? compaction.stage : '',
139
177
  compaction.state,
140
178
  compaction.triggerTokens ? `trigger ${formatTokens(compaction.triggerTokens)}` : '',
141
- compaction.boundaryTokens ? `boundary ${formatTokens(compaction.boundaryTokens)}` : '',
142
179
  ]);
143
- const metaLine = metricValue([
144
- usage.source || 'estimated',
145
- usage.effective ? 'effective window' : '',
180
+ const sourceLine = metricValue([
181
+ usage.effective ? `effective ${formatTokens(windowTokens)}` : `window ${formatTokens(windowTokens)}`,
146
182
  usage.rawWindowTokens && usage.rawWindowTokens !== usage.windowTokens ? `raw ${formatTokens(usage.rawWindowTokens)}` : '',
147
183
  ]);
148
- const cells = [
149
- { label: 'Messages', value: metricValue([`${formatTokens(messages.tokens)} tokens`, `${messages.count || 0} msgs`]) },
150
- { label: 'Tools', value: metricValue([`${formatTokens(tools.schemaTokens)} schema`, `${tools.active || 0}/${tools.count || 0} active`]) },
151
- { label: 'Tool I/O', value: metricValue([`${toolIo.calls || 0} calls`, `${toolIo.results || 0} results`]) },
152
- { label: 'Overhead', value: metricValue([`${formatTokens(request.overheadTokens)} frame`, `${formatTokens(request.reserveTokens)} reserve`]) },
153
- { label: 'Last API', value: metricValue([`${formatTokens(lastApi.contextTokens)} ctx`, `${formatTokens(lastApi.inputTokens)} in`, `${formatTokens(lastApi.outputTokens)} out`]) },
154
- { label: 'Cache', value: metricValue([`${cache.hitRate || 'n/a'} hit`, `${formatTokens(cache.readTokens)} read`]) },
155
- { label: 'Extensions', value: metricValue([`${extensions.skills || 0} skills`, `${extensions.plugins || 0} plugins`]) },
184
+ const apiLine = metricValue([
185
+ `last ctx ${formatTokens(lastApi.contextTokens)}`,
186
+ `in/out ${formatTokens(lastApi.inputTokens)}/${formatTokens(lastApi.outputTokens)}`,
187
+ `cache ${cache.hitRate || 'n/a'}`,
188
+ ]);
189
+ const categories = [
190
+ { label: 'Messages', tokens: semanticTokens(semantic, ['chat', 'assistant']), meta: '' },
191
+ { label: 'Tools', tokens: builtInToolTokens, meta: `${tools.active || 0}/${tools.count || 0} active${builtInToolCount ? ` · ${builtInToolCount} defs` : ''}` },
192
+ { label: 'MCP', tokens: bucketTokens(schema, ['mcp']), meta: `${mcp.connected || 0}/${mcp.configured || 0} servers` },
193
+ { label: 'Skills', tokens: bucketTokens(schema, ['skills']), meta: `${extensions.skills || 0} skills` },
194
+ { label: 'Memory', tokens: semanticTokens(semantic, ['memory']) + bucketTokens(schema, ['memory']), meta: 'core + recall tools' },
195
+ { label: 'Project', tokens: projectTokens, meta: 'mixdog.md · workspace' },
196
+ { label: 'Workflow', tokens: semanticTokens(semantic, ['workflow']) + bucketTokens(schema, ['agents']), meta: 'workflow · agents' },
197
+ { label: 'System', tokens: semanticTokens(semantic, ['system']), meta: 'rules · role catalog' },
198
+ { label: 'Overhead', tokens: finiteNumber(request.overheadTokens), meta: 'request frame' },
199
+ { label: 'Tool I/O', tokens: semanticTokens(semantic, ['toolResults']), meta: `${toolIo.calls || 0} calls · ${toolIo.results || 0} results` },
156
200
  ];
157
201
 
158
202
  return (
@@ -165,11 +209,15 @@ function ContextUsageView({ detail, columns }) {
165
209
  <Text color={theme.text}>{truncateText(summaryText, Math.max(0, innerWidth - Math.min(10, innerWidth) - barWidth - 3))}</Text>
166
210
  </Box>
167
211
  <Box marginTop={1} flexDirection="column" width="100%">
168
- <Text color={theme.subtle}>{truncateText(metaLine, innerWidth)}</Text>
169
- <Text color={theme.text}>{truncateText(`Compaction ${compactionLine}`, innerWidth)}</Text>
212
+ <DetailLine label="Source" value={sourceLine} columns={columns} />
213
+ <DetailLine label="Compaction" value={compactionLine} columns={columns} />
214
+ <DetailLine label="API/cache" value={apiLine} columns={columns} />
170
215
  </Box>
171
216
  <Box marginTop={1} flexDirection="column" width="100%">
172
- <MetricGrid cells={cells} columns={columns} />
217
+ <Text color={theme.subtle}>Context mix</Text>
218
+ <Box marginTop={1} flexDirection="column" width="100%">
219
+ <CategoryGrid categories={categories} columns={columns} total={windowTokens} />
220
+ </Box>
173
221
  </Box>
174
222
  </Box>
175
223
  );
@@ -38,6 +38,7 @@ export function summarizeArgs(name, args) {
38
38
 
39
39
  export const MAX_RESULT_LINES = 8;
40
40
  const TOOL_BLINK_MS = 500;
41
+ const TOOL_BLINK_LIMIT_MS = 3000;
41
42
  const TOOL_PENDING_SHOW_DELAY_MS = 1000;
42
43
  const TOOL_HINT_DONE_COLOR = theme.subtle;
43
44
  const COUNT_TWEEN_MS = 700;
@@ -249,8 +250,6 @@ function statusCopy(normalizedName, label, count, doneCount, pending, isError) {
249
250
  case 'view_image':
250
251
  case 'read_mcp_resource':
251
252
  return copy('Reading', 'Read', 'file');
252
- case 'write':
253
- case 'edit':
254
253
  case 'apply_patch':
255
254
  return copy('Editing', 'Edited', 'file');
256
255
  case 'grep':
@@ -492,7 +491,7 @@ function progressDetail({ normalizedName, label, elapsed }) {
492
491
  if (n === 'explore' || l === 'explore') return `Exploring${suffix}`;
493
492
  if (n === 'grep' || n === 'glob' || n === 'list' || n === 'ls' || l === 'search') return `Searching${suffix}`;
494
493
  if (n === 'read' || n === 'view_image' || n === 'read_mcp_resource' || l === 'read') return `Reading${suffix}`;
495
- if (n === 'write' || n === 'edit' || n === 'apply_patch' || l === 'update') return `Editing${suffix}`;
494
+ if (n === 'apply_patch' || l === 'update') return `Editing${suffix}`;
496
495
  if (n === 'recall' || n === 'recall_memory' || n === 'search_memories' || l === 'memory') return `Checking Memory${suffix}`;
497
496
  if (l === 'setup') return `Setting Up${suffix}`;
498
497
  return `Working${suffix}`;
@@ -538,6 +537,7 @@ function toolStatusColor({ pending, groupCount, failedCount }) {
538
537
 
539
538
  export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, globalExpanded = false, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, headerFinalized = true }) {
540
539
  const [blinkOn, setBlinkOn] = useState(true);
540
+ const [blinkExpired, setBlinkExpired] = useState(false);
541
541
  const [pendingDelayElapsed, setPendingDelayElapsed] = useState(false);
542
542
  const groupCount = Math.max(1, Number(count || 1));
543
543
  const doneCount = Math.max(0, Math.min(groupCount, Number(completedCount || (result == null ? 0 : groupCount))));
@@ -581,10 +581,29 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
581
581
  }, [pending, startedAt]);
582
582
 
583
583
  useEffect(() => {
584
- if (!pending || !pendingDisplayReady) return undefined;
584
+ if (!pending || !pendingDisplayReady || blinkExpired) {
585
+ setBlinkOn(true);
586
+ return undefined;
587
+ }
585
588
  const timer = setInterval(() => setBlinkOn((on) => !on), TOOL_BLINK_MS);
586
589
  return () => clearInterval(timer);
587
- }, [pending, pendingDisplayReady]);
590
+ }, [pending, pendingDisplayReady, blinkExpired]);
591
+
592
+ useEffect(() => {
593
+ if (!pending || !pendingDisplayReady) {
594
+ setBlinkExpired(false);
595
+ return undefined;
596
+ }
597
+ const started = Number(startedAt || 0);
598
+ const remaining = TOOL_BLINK_LIMIT_MS - (started ? Math.max(0, Date.now() - started) : 0);
599
+ if (remaining <= 0) {
600
+ setBlinkExpired(true);
601
+ return undefined;
602
+ }
603
+ setBlinkExpired(false);
604
+ const timer = setTimeout(() => setBlinkExpired(true), remaining);
605
+ return () => clearTimeout(timer);
606
+ }, [pending, pendingDisplayReady, startedAt]);
588
607
 
589
608
  if (pending && !pendingDisplayReady) return null;
590
609
 
@@ -605,7 +624,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
605
624
  }
606
625
 
607
626
  const dotColor = statusColor;
608
- const dotText = pending && !blinkOn ? ' ' : TURN_MARKER;
627
+ const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
609
628
  const gutter = 2;
610
629
  const showHeaderExpandHint = hasRawResult;
611
630
  const hintLabel = showHeaderExpandHint ? `ctrl+o ${expanded ? 'collapse' : 'expand'}` : '';
@@ -700,7 +719,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
700
719
  const isAgentResponse = isAgentResult && hasAgentResponseResult(rt);
701
720
  const isAgentMetadataResult = isAgentResult && !isAgentResponse;
702
721
  const dotColor = statusColor;
703
- const dotText = pending && !blinkOn ? ' ' : TURN_MARKER;
722
+ const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
704
723
  let labelText;
705
724
  if (isAgentResponse) labelText = agentResponseTitle(parsedArgs);
706
725
  else if (isBackgroundResponse) labelText = backgroundTaskResultTitle(normalizedName, backgroundMeta || parsedArgs);