mixdog 0.9.17 → 0.9.19

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 (129) hide show
  1. package/package.json +3 -2
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/output-style-smoke.mjs +2 -2
  4. package/scripts/recall-bench-cases.json +11 -0
  5. package/scripts/recall-bench.mjs +91 -2
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/tool-efficiency-diag.mjs +5 -2
  9. package/scripts/tool-smoke.mjs +101 -27
  10. package/src/agents/debugger/AGENT.md +3 -3
  11. package/src/agents/heavy-worker/AGENT.md +7 -10
  12. package/src/agents/maintainer/AGENT.md +1 -2
  13. package/src/agents/reviewer/AGENT.md +1 -2
  14. package/src/agents/worker/AGENT.md +5 -8
  15. package/src/defaults/agents.json +3 -0
  16. package/src/mixdog-session-runtime.mjs +23 -6
  17. package/src/rules/agent/00-core.md +4 -3
  18. package/src/rules/agent/30-explorer.md +53 -22
  19. package/src/rules/lead/02-channels.md +3 -3
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +24 -29
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  25. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  26. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  28. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  32. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  33. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  35. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  36. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  39. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  40. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  41. package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
  42. package/src/runtime/channels/backends/discord.mjs +139 -7
  43. package/src/runtime/channels/index.mjs +290 -76
  44. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  45. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  46. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  47. package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
  48. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  49. package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
  50. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  51. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  52. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  53. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  54. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  55. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  56. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  57. package/src/runtime/shared/atomic-file.mjs +10 -4
  58. package/src/runtime/shared/background-tasks.mjs +4 -2
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  61. package/src/runtime/shared/tool-surface.mjs +30 -1
  62. package/src/session-runtime/config-lifecycle.mjs +1 -1
  63. package/src/session-runtime/cwd-plugins.mjs +46 -3
  64. package/src/session-runtime/mcp-glue.mjs +24 -3
  65. package/src/session-runtime/output-styles.mjs +44 -10
  66. package/src/session-runtime/workflow.mjs +16 -1
  67. package/src/standalone/channel-worker.mjs +88 -7
  68. package/src/standalone/explore-tool.mjs +1 -1
  69. package/src/tui/App.jsx +57 -77
  70. package/src/tui/app/channel-pickers.mjs +45 -0
  71. package/src/tui/app/slash-commands.mjs +0 -1
  72. package/src/tui/app/slash-dispatch.mjs +0 -16
  73. package/src/tui/app/transcript-window.mjs +66 -1
  74. package/src/tui/app/use-mouse-input.mjs +9 -2
  75. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  76. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  77. package/src/tui/app/use-transcript-window.mjs +74 -8
  78. package/src/tui/components/PromptInput.jsx +33 -64
  79. package/src/tui/components/ToolExecution.jsx +2 -2
  80. package/src/tui/dist/index.mjs +4744 -4806
  81. package/src/tui/engine.mjs +109 -20
  82. package/src/tui/lib/voice-setup.mjs +166 -0
  83. package/src/tui/paste-attachments.mjs +12 -5
  84. package/src/tui/prompt-history-store.mjs +125 -12
  85. package/scripts/bench/cache-probe-tasks.json +0 -8
  86. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  87. package/scripts/bench/lead-review-tasks.json +0 -20
  88. package/scripts/bench/r4-mixed-tasks.json +0 -20
  89. package/scripts/bench/r5-orchestrated-task.json +0 -7
  90. package/scripts/bench/review-tasks.json +0 -20
  91. package/scripts/bench/round-codex.json +0 -114
  92. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  93. package/scripts/bench/round-mixdog-lead.json +0 -269
  94. package/scripts/bench/round-mixdog.json +0 -126
  95. package/scripts/bench/round-r10-bigsample.json +0 -679
  96. package/scripts/bench/round-r11-codexalign.json +0 -257
  97. package/scripts/bench/round-r13-clientmeta.json +0 -464
  98. package/scripts/bench/round-r14-betafeatures.json +0 -466
  99. package/scripts/bench/round-r15-fulldefault.json +0 -462
  100. package/scripts/bench/round-r16-sessionid.json +0 -466
  101. package/scripts/bench/round-r17-wirebytes.json +0 -456
  102. package/scripts/bench/round-r18-prewarm.json +0 -468
  103. package/scripts/bench/round-r19-clean.json +0 -472
  104. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  105. package/scripts/bench/round-r21-delta-retry.json +0 -473
  106. package/scripts/bench/round-r22-full-probe.json +0 -693
  107. package/scripts/bench/round-r23-itemprobe.json +0 -701
  108. package/scripts/bench/round-r24-shapefix.json +0 -677
  109. package/scripts/bench/round-r25-serial.json +0 -464
  110. package/scripts/bench/round-r26-parallel3.json +0 -671
  111. package/scripts/bench/round-r27-parallel10.json +0 -894
  112. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  113. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  114. package/scripts/bench/round-r30-instid.json +0 -253
  115. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  116. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  117. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  118. package/scripts/bench/round-r34-orchestrated.json +0 -120
  119. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  120. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  121. package/scripts/bench/round-r4-codex.json +0 -114
  122. package/scripts/bench/round-r4-mixed.json +0 -225
  123. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  124. package/scripts/bench/round-r6-codex.json +0 -114
  125. package/scripts/bench/round-r6-solo.json +0 -257
  126. package/scripts/bench/round-r7-full.json +0 -254
  127. package/scripts/bench/round-r8-fulldefault.json +0 -255
  128. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  129. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -36,7 +36,13 @@ const SEMANTIC_TOP_RANK_MAX = 3
36
36
  // 0.749 against real rows), so anything below ~0.70 is noise regardless of
37
37
  // rank; cross-language relevant pairs measured 0.67-0.74. 0.70 keeps most of
38
38
  // the cross-language rescue while rejecting pure-noise top ranks.
39
+ // The softer 0.70 floor SITS INSIDE the noise band (noise peaked at 0.749), so
40
+ // it only rescues rows that ALSO carry a lexical leg as corroboration. A
41
+ // semantic-only row (no sparse/trgm/exact) must clear SEMANTIC_TOP_RANK_STRICT_SIM
42
+ // instead — set above the measured 0.749 noise ceiling so unrelated top ranks
43
+ // (e.g. old narration surfacing for "embedding worker crash") stop leaking in.
39
44
  const SEMANTIC_TOP_RANK_MIN_SIM = 0.70
45
+ const SEMANTIC_TOP_RANK_STRICT_SIM = 0.78
40
46
  const SHORT_QUERY_TOKEN_MAX = 2
41
47
  // Dense-similarity weighting for retrievalScore. Lexical RRF sums 1/(K+rank)
42
48
  // per matching leg (K=60), so a row hitting 2 lexical legs at rank 1 scores
@@ -53,6 +59,64 @@ const SHORT_QUERY_TOKEN_MAX = 2
53
59
  // matches the noise floor used elsewhere for MiniLM-class embeddings.
54
60
  const SIM_FLOOR = 0.60
55
61
  const W_DENSE = 0.04
62
+ // Recency nudge applied ONLY when a [ts_from, ts_to] window is active. Inside a
63
+ // fixed window absolute-age freshness is often disabled (applyFreshness=false)
64
+ // or too flat to separate early-window from late-window rows, so a 07-04 row
65
+ // could outrank a 07-05 row of comparable relevance under a 24h period. This
66
+ // term is a normalized position-in-window score (newest→1) scaled small enough
67
+ // to break comparable-relevance ties toward newer rows without overriding a
68
+ // clearly stronger lexical/semantic match.
69
+ // Kept below a single rank-1 RRF leg (~0.016) so it only breaks ties between
70
+ // comparable-relevance rows and never overrides a clearly stronger match.
71
+ const W_WINDOW_RECENCY = 0.008
72
+ // Rare-token display boost. A query token that appears in the RENDERED display
73
+ // of only a small fraction of the candidate set (an IDF-style rarity signal) is
74
+ // strong evidence a row is genuinely about that term — even for a
75
+ // cross-language row whose only lexical tie is one distinctive English token
76
+ // (e.g. "embedding" inside otherwise-Korean content). We add a boost when such
77
+ // a rare token is present in the fields that actually render (element/summary
78
+ // for roots, content for member-hit turns), so a low-lexical row surfaces above
79
+ // common-token neighbours. Scoped to display fields so we never promote a row
80
+ // whose match hides in un-rendered body text. RARE_DF_MAX gates "rare": tokens
81
+ // present in >34% of candidates are common and earn nothing.
82
+ // Scaled to at most one rank-1 RRF leg (1/(K+1) ≈ 0.0164 at K=60): a rare
83
+ // display token is a genuine relevance signal, but the IDF boost should only
84
+ // carry the weight of a single extra retrieval-leg agreement — enough to break
85
+ // ties toward the distinctive-token row without overriding a clearly stronger
86
+ // multi-leg lexical/semantic match. W_RARE * (1 - df/N) peaks below this cap.
87
+ const W_RARE = 0.016
88
+ const RARE_DF_MAX = 0.34
89
+
90
+ // Lowercased, de-duplicated query tokens (>= 2 chars) for DF/boost/gate use.
91
+ // Capped at 12 (mirrors buildExactTerms) so a paragraph-length query can't
92
+ // turn the per-row DF scan into O(tokens × candidateWindow) on display text.
93
+ function queryTokensLower(query) {
94
+ const clean = String(query ?? '').replace(/\s+/g, ' ').trim()
95
+ const toks = (clean.match(/[\p{L}\p{N}_./:-]+/gu) || [])
96
+ .map(t => t.toLowerCase())
97
+ .filter(t => Array.from(t).length >= 2)
98
+ return [...new Set(toks)].slice(0, 12)
99
+ }
100
+
101
+ // The text that actually renders as a result LINE for a row: element+summary
102
+ // for a root (its members render on their own lines), plus content for a
103
+ // member-hit turn (its own content is the rendered line). Deliberately excludes
104
+ // a ROOT's body content, which never renders when the row is shown as a summary
105
+ // line — matching there is incidental, not topical.
106
+ function rowDisplayText(row) {
107
+ const base = `${row?.element ?? ''} ${row?.summary ?? ''}`
108
+ const withBody = Number(row?.is_root) === 0 ? `${base} ${row?.content ?? ''}` : base
109
+ return withBody.toLowerCase()
110
+ }
111
+
112
+ function windowRecencyFactor(ts, tsFrom, tsTo, nowMs) {
113
+ const t = Number(ts)
114
+ if (!Number.isFinite(t)) return 0
115
+ const hi = tsTo != null ? tsTo : nowMs
116
+ const lo = tsFrom != null ? tsFrom : hi - 86_400_000 // default 24h span when open-started
117
+ if (!(hi > lo)) return 0
118
+ return Math.max(0, Math.min(1, (t - lo) / (hi - lo)))
119
+ }
56
120
 
57
121
  function buildExactTerms(query) {
58
122
  const clean = String(query ?? '').replace(/\s+/g, ' ').trim()
@@ -100,7 +164,8 @@ function hasQueryTokenCoverage(row, queryTokenCount) {
100
164
  if (queryTokenCount <= SHORT_QUERY_TOKEN_MAX) return true
101
165
  const hits = Number(row?.exact_hits)
102
166
  // Majority coverage, not full coverage. Requiring EVERY query token to hit
103
- // made mixed-language queries ("usage spike 원인") return nothing when one
167
+ // made mixed-language queries (English "usage spike" plus a Korean noun for
168
+ // "cause") return nothing when one
104
169
  // token has no lexical counterpart in the row — ~60% of tokens matching is
105
170
  // strong enough lexical evidence alongside the semantic leg.
106
171
  const required = Math.max(2, Math.ceil(queryTokenCount * 0.6))
@@ -221,8 +286,14 @@ export async function searchRelevantHybrid(db, query, options = {}) {
221
286
  // Returns { clause: string, params: any[] }; clause begins with AND or is ''.
222
287
  function buildFilterClause(offset, opts = {}) {
223
288
  return buildRecallScopeFilter(offset, {
224
- ts_from: opts.skipTsWindow ? null : tsFrom,
225
- ts_to: opts.skipTsWindow ? null : tsTo,
289
+ // skipTsWindow must fully DROP the ts predicate for member-hit roots
290
+ // (their own ts can sit outside the window; the member ts was already
291
+ // gated). Pass `undefined`, not `null`: buildRecallScopeFilter coerces
292
+ // its ts inputs via Number(x), and Number(null) === 0 (finite) would
293
+ // inject `ts BETWEEN 0 AND 0`, silently dropping every member-hit root.
294
+ // Number(undefined) === NaN, which the finite-check correctly skips.
295
+ ts_from: opts.skipTsWindow ? undefined : tsFrom,
296
+ ts_to: opts.skipTsWindow ? undefined : tsTo,
226
297
  excludeStatuses,
227
298
  category: categories,
228
299
  projectScope,
@@ -514,6 +585,36 @@ LEFT JOIN exact x ON x.id = c.id`
514
585
  const K = 60
515
586
  const nowMs = Date.now()
516
587
 
588
+ // ── Rare-token display DF (IDF-style rarity) ─────────────────────────────
589
+ // Count, per query token, how many candidate rows carry it in their RENDERED
590
+ // display text. A token in only a small fraction of candidates is rare and
591
+ // earns a boost for the rows that show it (see rareTokenDisplayBoost).
592
+ const qTokens = queryTokensLower(clean)
593
+ const candCount = rawRows.length || 1
594
+ const displayDf = new Map()
595
+ if (qTokens.length > 0) {
596
+ const displayTexts = rawRows.map(rowDisplayText)
597
+ for (const t of qTokens) {
598
+ let c = 0
599
+ for (const dt of displayTexts) if (dt.includes(t)) c++
600
+ displayDf.set(t, c)
601
+ }
602
+ }
603
+ function rareTokenDisplayBoost(row) {
604
+ if (qTokens.length === 0) return 0
605
+ const disp = rowDisplayText(row)
606
+ let best = 0
607
+ for (const t of qTokens) {
608
+ const df = displayDf.get(t) ?? 0
609
+ if (df === 0) continue
610
+ if (df / candCount > RARE_DF_MAX) continue // common token: no rarity credit
611
+ if (!disp.includes(t)) continue
612
+ const b = W_RARE * (1 - df / candCount)
613
+ if (b > best) best = b
614
+ }
615
+ return best
616
+ }
617
+
517
618
  const scoredAll = rawRows.map(row => {
518
619
  const id = Number(row.id)
519
620
  const denseRank = row.dense_rank != null ? Number(row.dense_rank) : null
@@ -526,21 +627,37 @@ LEFT JOIN exact x ON x.id = c.id`
526
627
  + (exactRank ? 1 / (K + exactRank) : 0)
527
628
  const freshness = applyFreshness ? freshnessFactor(row.ts, nowMs) : 1.0
528
629
  const boost = exactTextBoost(clean, row, row.exact_hits)
630
+ const rareBoost = rareTokenDisplayBoost(row)
529
631
  const sim = Number(row.dense_sim)
530
632
  const denseTerm = Number.isFinite(sim)
531
633
  ? W_DENSE * Math.max(0, sim - SIM_FLOOR) / (1 - SIM_FLOOR) * freshness
532
634
  : 0
533
- return { id, row, rrf, freshness, retrievalScore: (rrf * freshness) + boost + denseTerm }
635
+ const windowRecency = hasTsFilter ? W_WINDOW_RECENCY * windowRecencyFactor(row.ts, tsFrom, tsTo, nowMs) : 0
636
+ return { id, row, rrf, freshness, retrievalScore: (rrf * freshness) + boost + rareBoost + denseTerm + windowRecency }
534
637
  })
535
638
  let semanticOnlyDropped = 0
536
639
  let weakTextDropped = 0
640
+ // Cross-language signature: a query whose candidate set produced NO lexical
641
+ // leg at all is almost always a cross-language recall where dense is the only
642
+ // usable signal. In that case relax the top-rank rescue back to the measured
643
+ // 0.70-0.74 band; when lexical evidence exists elsewhere, keep demanding
644
+ // lexical corroboration (or the stricter above-noise floor) per row.
645
+ const setHasLexicalLeg = scoredAll.some(({ row }) => row.sparse_rank != null || row.exact_rank != null)
537
646
  const scored = scoredAll.filter(({ row }) => {
538
647
  const hasTextSupport = row.sparse_rank != null || row.trgm_rank != null || row.exact_rank != null
539
648
  const sim = Number(row.dense_sim)
540
649
  const denseRankNum = row.dense_rank != null ? Number(row.dense_rank) : null
541
- const hasSemanticSupport = (Number.isFinite(sim) && sim >= SEMANTIC_ONLY_MIN_SIM)
542
- || (Number.isFinite(sim) && sim >= SEMANTIC_TOP_RANK_MIN_SIM
543
- && denseRankNum != null && denseRankNum <= SEMANTIC_TOP_RANK_MAX)
650
+ const hasStrongSemantic = Number.isFinite(sim) && sim >= SEMANTIC_ONLY_MIN_SIM
651
+ const hasLexicalLeg = row.sparse_rank != null || row.exact_rank != null
652
+ // Top-rank rescue now demands corroboration: the softer 0.70 floor only
653
+ // rescues rows that also carry a lexical leg, while a purely semantic row
654
+ // must clear the stricter above-noise floor. This stops sub-noise top
655
+ // ranks (unrelated narration) from surfacing on semantic-only queries.
656
+ const inTopRank = denseRankNum != null && denseRankNum <= SEMANTIC_TOP_RANK_MAX
657
+ const hasTopRankRescue = Number.isFinite(sim) && inTopRank
658
+ && ((sim >= SEMANTIC_TOP_RANK_MIN_SIM && (hasLexicalLeg || !setHasLexicalLeg))
659
+ || sim >= SEMANTIC_TOP_RANK_STRICT_SIM)
660
+ const hasSemanticSupport = hasStrongSemantic || hasTopRankRescue
544
661
  if (!hasTextSupport) {
545
662
  if (hasSemanticSupport) return true
546
663
  semanticOnlyDropped += 1
@@ -554,7 +671,37 @@ LEFT JOIN exact x ON x.id = c.id`
554
671
  if (categories.length > 0) return true
555
672
  const hasFullPhrase = hasFullQueryTextMatch(clean, row)
556
673
  if (hasFullPhrase) return true
557
- if (queryTokenCount <= SHORT_QUERY_TOKEN_MAX) return true
674
+ // Short keyword/identifier lookups: pass real lexical legs (FTS/exact)
675
+ // freely, but a trigram-only fuzzy hit riding a sub-floor dense leg is
676
+ // filler — require the strong semantic floor so an unrelated 2-token query
677
+ // (e.g. an identifier like "ProjectAA" plus a Korean word for "balance"
678
+ // against another project) returns empty rather
679
+ // than accidental trigram neighbours.
680
+ if (queryTokenCount <= SHORT_QUERY_TOKEN_MAX) {
681
+ // A real FTS (sparse) hit is a topical match — keep it. But an exact/trgm
682
+ // hit alone can be INCIDENTAL: the term appears only in un-rendered body
683
+ // text (e.g. a "C:\Project\ProjectAA" path buried in a codeGraph note),
684
+ // so the row renders a line that never mentions the query at all. For a
685
+ // short keyword query where every rendered line is expected to be about
686
+ // the term, require the token to actually land in the display fields
687
+ // (element/summary, or a member turn's own content) before accepting an
688
+ // exact/trgm-only row. This drops pure filler without touching genuine
689
+ // FTS or in-display keyword hits.
690
+ if (row.sparse_rank != null) return true
691
+ if (hasStrongSemantic) return true
692
+ const tokenInDisplay = qTokens.length > 0 && (() => {
693
+ const d = rowDisplayText(row)
694
+ return qTokens.some(t => d.includes(t))
695
+ })()
696
+ if (hasLexicalLeg && tokenInDisplay) return true
697
+ // Strong trigram match survives even with a cold/absent dense leg (full
698
+ // ILIKE=1.0, no-vector path pins 0.5 — both above the 0.3 real-fuzzy
699
+ // cutoff), but only when the term is on the rendered line, not incidental.
700
+ const trgSim = Number(row.trg_sim)
701
+ if (row.trgm_rank != null && Number.isFinite(trgSim) && trgSim >= 0.3 && tokenInDisplay) return true
702
+ weakTextDropped += 1
703
+ return false
704
+ }
558
705
  if (row.sparse_rank != null) return true
559
706
  // Semantic support as a second signal only applies when the dense leg
560
707
  // actually ran. With a cold embedding model (no queryVector) semantic
@@ -582,12 +729,21 @@ LEFT JOIN exact x ON x.id = c.id`
582
729
  // all matching roots at once, then resolve each member from rootById.
583
730
  const memberRootIds = []
584
731
  const memberRootSeen = new Set()
732
+ // Matched member ids grouped by their chunk root. A member-hit root is a
733
+ // grouping artifact surfaced because a SPECIFIC turn matched; rendering its
734
+ // full sibling set floods precision-sensitive queries (bench:
735
+ // negative-projectaa) with turns that never mention the term. Attach only the
736
+ // matched turns for such roots (see membersByRoot filtering below); roots
737
+ // matched on their own row keep full chunk expansion for context.
738
+ const matchedMembersByRoot = new Map()
585
739
  for (const { id } of filtered) {
586
740
  const r0 = byId.get(id)
587
741
  if (!r0 || r0.is_root === 1) continue
588
742
  if (r0.chunk_root != null && r0.chunk_root !== r0.id) {
589
743
  const rid = Number(r0.chunk_root)
590
744
  if (!memberRootSeen.has(rid)) { memberRootSeen.add(rid); memberRootIds.push(rid) }
745
+ if (!matchedMembersByRoot.has(rid)) matchedMembersByRoot.set(rid, new Set())
746
+ matchedMembersByRoot.get(rid).add(Number(r0.id))
591
747
  }
592
748
  }
593
749
  const rootById = new Map()
@@ -732,7 +888,24 @@ LEFT JOIN exact x ON x.id = c.id`
732
888
  if (!finalRoot) continue
733
889
  const out = { ...finalRoot, rrf, retrievalScore, retrievalRank }
734
890
  if (includeMembers && finalRoot.is_root === 1) {
735
- out.members = membersByRoot.get(Number(finalRoot.id)) ?? []
891
+ const allMembers = membersByRoot.get(Number(finalRoot.id)) ?? []
892
+ // Member-hit root: attach only the turns that actually matched (keeps the
893
+ // rendered lines on-topic; a broad conversation root that matched on one
894
+ // buried turn no longer floods with unrelated siblings). Root-matched
895
+ // chunks keep full expansion for context. Fall back to full expansion if
896
+ // the matched set somehow resolves empty.
897
+ const matched = matchedMembersByRoot.get(Number(finalRoot.id))
898
+ if (matched && matched.size > 0) {
899
+ const kept = allMembers.filter(m => matched.has(Number(m.id)))
900
+ const use = kept.length > 0 ? kept : allMembers
901
+ // Attach only the matched turns (general: avoids flooding with
902
+ // unrelated siblings of a broad conversation root), rendering each
903
+ // matched turn's FULL content — no per-line token trimming, which could
904
+ // drop the answer line when only the question line carries the term.
905
+ out.members = use
906
+ } else {
907
+ out.members = allMembers
908
+ }
736
909
  }
737
910
  results.push(out)
738
911
  }
@@ -18,6 +18,7 @@ import {
18
18
  interleaveRawRows,
19
19
  renderEntryLines,
20
20
  renderSessionGroupedLines,
21
+ collapseNearDuplicateRows,
21
22
  } from './recall-format.mjs'
22
23
  import { searchRelevantHybrid } from './memory-recall-store.mjs'
23
24
  import { fetchEntriesByIdsScoped } from './memory-recall-id-patch.mjs'
@@ -72,7 +73,7 @@ export function createQueryHandlers({
72
73
  return `(CASE WHEN ${textExpr} LIKE $${params.length} THEN 1 ELSE 0 END)`
73
74
  })
74
75
  // Multi-token queries: from 3+ terms require at least 2 matching terms
75
- // so one common token ("대화", "recall") can't drag unrelated raw rows
76
+ // so one common token ("chat", "recall") can't drag unrelated raw rows
76
77
  // into the page. 1-2 term queries keep single-hit contains semantics —
77
78
  // short Korean queries are often exactly two meaningful tokens and a
78
79
  // 2-of-2 requirement silently emptied the raw leg for them.
@@ -432,7 +433,7 @@ export function createQueryHandlers({
432
433
  const hasQueryForSort = Array.isArray(args.query)
433
434
  ? args.query.some((v) => String(v || '').trim())
434
435
  : String(args.query ?? '').trim() !== ''
435
- const sort = args.sort != null ? String(args.sort) : (hasQueryForSort ? 'importance' : 'date')
436
+ let sort = args.sort != null ? String(args.sort) : (hasQueryForSort ? 'importance' : 'date')
436
437
  // Chunk content is the primary recall output. Members default to true so
437
438
  // callers receive the raw chunk leaves (the cycle1-produced semantic
438
439
  // chunks) rather than just the root's cycle2-compressed summary line.
@@ -448,6 +449,13 @@ export function createQueryHandlers({
448
449
  const includeArchived = args.includeArchived !== false
449
450
  const category = args.category
450
451
  const temporal = parsePeriod(period, Boolean(query))
452
+ // Bounded-period query recall reads as a timeline ("today's work"), not a
453
+ // relevance ranking: when the caller EXPLICITLY supplied a period that
454
+ // resolves to a real time window and did NOT pin sort, force newest-first
455
+ // so the page is strictly chronological (bench: recency-today). Gated on
456
+ // the explicit `period` string — the implicit 30d default a bare query
457
+ // gets must keep importance ranking (else topical query recall regresses).
458
+ if (args.sort == null && query && period && temporal && temporal.startMs != null) sort = 'date'
451
459
 
452
460
  // Derive projectScope from caller cwd (falls back to process.cwd()).
453
461
  // Explicit args.projectScope (string) takes priority so callers can
@@ -584,7 +592,19 @@ export function createQueryHandlers({
584
592
  { projectScope, terms: sessionRecallTerms(query) },
585
593
  )
586
594
  const seenIds = new Set(filtered.map(r => r.id))
587
- const newRaw = rawRows.filter(r => !seenIds.has(r.id))
595
+ let newRaw = rawRows.filter(r => !seenIds.has(r.id))
596
+ // Relevance gate: readRawRowsInWindow's SQL term filter is loose
597
+ // (minHits 1 for <3-term queries), so unscored raw rows that share a
598
+ // single common token still get stride-interleaved into a ranked
599
+ // result set and push real hits down the page. In the query branch,
600
+ // keep only raw rows whose body actually contains >=1 query term.
601
+ const rawTerms = sessionRecallTerms(query)
602
+ if (rawTerms.length > 0) {
603
+ newRaw = newRaw.filter((r) => {
604
+ const hay = `${r.content ?? ''} ${r.element ?? ''} ${r.summary ?? ''}`.toLowerCase()
605
+ return rawTerms.some((t) => hay.includes(t))
606
+ })
607
+ }
588
608
  if (sort === 'date') {
589
609
  for (const r of newRaw) filtered.push(r)
590
610
  filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
@@ -599,6 +619,12 @@ export function createQueryHandlers({
599
619
  if (coreRows.length > 0) {
600
620
  filtered = [...coreRows, ...filtered]
601
621
  }
622
+ // Core rows are prepended by relevance and carry updated_at as ts, so on
623
+ // the chronological (date) path they'd break strict newest-first. Re-sort
624
+ // the merged list by ts desc before slicing so the timeline stays intact.
625
+ if (sort === 'date') {
626
+ filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
627
+ }
602
628
  const sliced = filtered.slice(offset, offset + limit)
603
629
  const _t2 = Date.now()
604
630
  if (process.env.MIXDOG_DEBUG_MEMORY) {
@@ -622,7 +648,13 @@ export function createQueryHandlers({
622
648
  }]).catch(e => log(`[trace] insertTraceEvents error: ${e?.message}\n`))
623
649
  }
624
650
  }
625
- const out = { text: recallCapPrefix + renderEntryLines(sliced) }
651
+ // Collapse near-duplicate long bodies within this single result set so
652
+ // paraphrased restatements don't each spend the full line budget. Search
653
+ // path only — id-lookup output above never calls this.
654
+ const deduped = collapseNearDuplicateRows(sliced)
655
+ // recencyOrder render on the date path flattens roots+members into one
656
+ // ts-desc stream so per-chunk (ts-ASC) members can't invert the timeline.
657
+ const out = { text: recallCapPrefix + renderEntryLines(deduped, { recencyOrder: sort === 'date' }) }
626
658
  if (process.env.MIXDOG_DEBUG_MEMORY) {
627
659
  log(`[search-time] render+trace=${Date.now() - _t2}ms total=${Date.now() - _t0}ms textLen=${out.text.length}\n`)
628
660
  }
@@ -149,30 +149,47 @@ export function interleaveRawRows(hybridRows, rawRows) {
149
149
  return out
150
150
  }
151
151
 
152
- export function renderEntryLines(rows) {
152
+ export function renderEntryLines(rows, { recencyOrder = false } = {}) {
153
153
  if (!rows || rows.length === 0) return '(no results)'
154
- const lines = []
154
+ // Each emitted line is tracked as a { ts, text } unit so the recencyOrder
155
+ // path can sort the WHOLE stream (roots + their members, plus leaf/raw rows)
156
+ // strictly newest-first. Members are fetched ts-ASC per chunk, so without
157
+ // this global re-sort a multi-member chunk would emit oldest-first lines and
158
+ // break a strict newest-first contract (bench: recency-today).
159
+ const units = []
155
160
  // Bound total emitted lines (roots x members) so a many-member recall can't
156
161
  // inject unbounded output. Per-line content is already capped at 1000 chars;
157
162
  // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
158
163
  const RECALL_LINE_CAP = 200
164
+ // recencyOrder collects every unit first (so the ts sort sees the full set)
165
+ // and caps AFTER sorting; the default path keeps the original cap-as-we-go.
166
+ const COLLECT_CAP = recencyOrder ? Infinity : RECALL_LINE_CAP
159
167
  let _capped = false
160
168
  outer:
161
169
  for (const r of rows) {
170
+ // Collapsed near-duplicate (search path only — set by
171
+ // collapseNearDuplicateRows). Emit a one-line stub carrying its #id so an
172
+ // id-lookup follow-up can still fetch the full body; never rendered for
173
+ // id-lookup output (those rows never carry _dupStub).
174
+ if (r && r._dupStub) {
175
+ if (units.length >= COLLECT_CAP) { _capped = true; break }
176
+ units.push({ ts: Number(r.ts) || 0, text: `[${formatTs(r.ts)}] (near-duplicate of #${r._dupOf} — collapsed) #${r.id}` })
177
+ continue
178
+ }
162
179
  const hasMembers = Array.isArray(r.members) && r.members.length > 0
163
180
  if (hasMembers) {
164
181
  // Chunks present: emit each member as its own line. Root row is a
165
182
  // grouping artifact for retrieval — the caller wants the chunk
166
183
  // content (cycle1 raw), not the cycle2-compressed summary.
167
184
  for (const m of r.members) {
168
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
185
+ if (units.length >= COLLECT_CAP) { _capped = true; break outer }
169
186
  const mTs = formatTs(m.ts)
170
187
  const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
171
188
  const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
172
- lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
189
+ units.push({ ts: Number(m.ts) || 0, text: `[${mTs}] ${role}: ${content} #${m.id}` })
173
190
  }
174
191
  } else {
175
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
192
+ if (units.length >= COLLECT_CAP) { _capped = true; break }
176
193
  // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
177
194
  // the row itself in the same shape. element/summary fall back to
178
195
  // raw content when both are absent.
@@ -192,13 +209,96 @@ export function renderEntryLines(rows) {
192
209
  // Unchunked raw leaf (cycle1 hasn't classified it yet): mark it so
193
210
  // callers can tell fresh-but-unprocessed rows from chunked memory.
194
211
  const pendingMark = (r.is_root === 0 && r.chunk_root == null) ? ' [pending]' : ''
195
- lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)}${pendingMark} #${r.id}`)
212
+ units.push({ ts: Number(r.ts) || 0, text: `[${ts}] ${rolePrefix}${body.slice(0, 1000)}${pendingMark} #${r.id}` })
196
213
  }
197
214
  }
215
+ if (recencyOrder) {
216
+ // Array.sort is stable in V8, so units sharing the same ts keep their
217
+ // insertion (root/member) order; only cross-unit ts breaks are corrected.
218
+ units.sort((a, b) => b.ts - a.ts)
219
+ if (units.length > RECALL_LINE_CAP) { units.length = RECALL_LINE_CAP; _capped = true }
220
+ }
221
+ const lines = units.map((u) => u.text)
198
222
  if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
199
223
  return lines.join('\n')
200
224
  }
201
225
 
226
+ // Search-result de-duplication. Within a SINGLE formatted result set, hybrid
227
+ // recall frequently returns several long rows that restate the same design in
228
+ // slightly different words (e.g. a root summary plus a chunk that paraphrases
229
+ // it). They each spend the ~1000-char line budget on near-identical text.
230
+ // Cheap heuristic (no embeddings): normalize each row's body to word tokens,
231
+ // build 3-gram shingle sets, and measure containment overlap = |A∩B| /
232
+ // min(|A|,|B|) against already-kept rows. Rows arrive rank/date-ordered, so the
233
+ // FIRST occurrence is the newest/highest-ranked and is kept full; a later
234
+ // high-overlap row is dropped (near-total) or rendered as an id-stub.
235
+ // - drop when overlap >= DROP_OVERLAP (0.9): body adds nothing.
236
+ // - stub when overlap >= STUB_OVERLAP (0.65): keep the #id reachable.
237
+ // Short bodies (< MIN_TOKENS words) are never collapsed — they can't carry
238
+ // enough signal for the overlap metric to be meaningful and are cheap anyway.
239
+ // Applies to search results only; id-lookup output must never call this.
240
+ function normalizedRowText(r) {
241
+ if (Array.isArray(r?.members) && r.members.length > 0) {
242
+ return r.members.map((m) => cleanMemoryText(String(m.content ?? ''))).join(' ').toLowerCase()
243
+ }
244
+ const element = r?.element ?? ''
245
+ const summary = r?.summary ?? ''
246
+ const body = (element || summary)
247
+ ? `${element} ${summary}`
248
+ : cleanMemoryText(String(r?.content ?? ''))
249
+ return String(body).toLowerCase()
250
+ }
251
+
252
+ export function collapseNearDuplicateRows(rows, {
253
+ stubOverlap = 0.65,
254
+ dropOverlap = 0.9,
255
+ minTokens = 12,
256
+ } = {}) {
257
+ if (!Array.isArray(rows) || rows.length < 2) return rows
258
+ const shingle = (r) => {
259
+ const toks = normalizedRowText(r).match(/[\p{L}\p{N}_]+/gu) || []
260
+ if (toks.length < minTokens) return null
261
+ const set = new Set()
262
+ if (toks.length < 3) { for (const t of toks) set.add(t); return set }
263
+ for (let i = 0; i + 3 <= toks.length; i += 1) set.add(`${toks[i]} ${toks[i + 1]} ${toks[i + 2]}`)
264
+ return set
265
+ }
266
+ const kept = [] // { row, shingles }
267
+ const out = []
268
+ for (const r of rows) {
269
+ const sh = shingle(r)
270
+ if (!sh) { out.push(r); continue }
271
+ // Two metrics per kept row:
272
+ // containment = |A∩B| / min(|A|,|B|) — catches paraphrase/superset
273
+ // jaccard = |A∩B| / |A∪B| — size-aware, immune to the
274
+ // short-vs-long 1.0 false positive
275
+ // Dropping on containment alone let a short distinct UPDATE fully contained
276
+ // in a long earlier row score 1.0 and vanish. Drop only on high jaccard OR
277
+ // high containment between comparably-sized rows (size ratio >= 0.5). Stub
278
+ // stays containment-based so paraphrase restatements still collapse to a
279
+ // reachable id-stub.
280
+ let bestStub = 0
281
+ let bestStubId = null
282
+ let drop = false
283
+ for (const k of kept) {
284
+ const [small, large] = sh.size <= k.shingles.size ? [sh, k.shingles] : [k.shingles, sh]
285
+ let inter = 0
286
+ for (const g of small) if (large.has(g)) inter += 1
287
+ const containment = small.size ? inter / small.size : 0
288
+ const union = sh.size + k.shingles.size - inter
289
+ const jaccard = union ? inter / union : 0
290
+ const sizeRatio = large.size ? small.size / large.size : 0
291
+ if (jaccard >= 0.85 || (containment >= dropOverlap && sizeRatio >= 0.5)) drop = true
292
+ if (containment > bestStub) { bestStub = containment; bestStubId = k.row.id }
293
+ }
294
+ if (drop) continue
295
+ if (bestStub >= stubOverlap) { out.push({ ...r, _dupStub: true, _dupOf: bestStubId }); continue }
296
+ kept.push({ row: r, shingles: sh })
297
+ out.push(r)
298
+ }
299
+ return out
300
+ }
301
+
202
302
  // Compact session label for group headers: keep short ids verbatim, shorten
203
303
  // long ones to a recognizable tail (ids are typically unique in the suffix —
204
304
  // timestamp/counter — not the prefix).
@@ -234,7 +234,7 @@ function stripUserTurnPrefixEnvelopes(text) {
234
234
  // `# Session\n` are EXACTLY the fixed fields buildSessionStartBlock emits
235
235
  // (`Cwd: `, `Model: `, `Workflow: `, each on its own line) before the
236
236
  // blank-line terminator. A human doc that merely STARTS with a `# Session`
237
- // heading followed by free prose (e.g. `프로젝트 회의록입니다`) does NOT match
237
+ // heading followed by free prose in any language (e.g. `meeting notes`) does NOT match
238
238
  // — its next line is not a `Cwd:/Model:/Workflow:` field — so it is
239
239
  // preserved verbatim (zero-loss). Anchored ^.
240
240
  out = out.replace(/^# Session\n(?:(?:Cwd|Model|Workflow): [^\n]*\n)+(?:\n|$)/, '')
@@ -75,7 +75,13 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
75
75
  }
76
76
  try {
77
77
  const st = statSync(lockPath);
78
- if (Date.now() - st.mtimeMs > staleMs) {
78
+ // Dead-owner fast path: if the lock's recorded owner pid is gone,
79
+ // force-release immediately instead of waiting out staleMs. A crashed
80
+ // owner would otherwise hold the path until the 30s stale window,
81
+ // starving waiters past the 8s acquire timeout (the restart stall).
82
+ const ownerPidEarly = _readLockOwnerPid(lockPath);
83
+ const ownerDead = ownerPidEarly !== null && _pidIsDead(ownerPidEarly);
84
+ if (ownerDead || Date.now() - st.mtimeMs > staleMs) {
79
85
  // Only steal a stale lock when we can prove the owner is
80
86
  // gone. Reading the pid from the lock file and probing it
81
87
  // with kill(pid, 0) protects a slow-but-live holder from
@@ -96,14 +102,14 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
96
102
  // path unlink is not a portable unlink-if-token-still-matches
97
103
  // primitive, so live-lock safety rests on the lockPath token
98
104
  // reverify immediately before the lockPath unlink below.
99
- const deadPid = _readLockOwnerPid(lockPath);
100
- if (deadPid !== null && _pidIsDead(deadPid)) {
105
+ const deadPid = ownerPidEarly;
106
+ if (ownerDead) {
101
107
  const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
102
108
  if (reclaim !== null) {
103
109
  let reclaimed = false;
104
110
  try {
105
111
  const currentSt = statSync(lockPath);
106
- if (Date.now() - currentSt.mtimeMs > staleMs) {
112
+ if (ownerDead || Date.now() - currentSt.mtimeMs > staleMs) {
107
113
  const currentPid = _readLockOwnerPid(lockPath);
108
114
  if (currentPid === deadPid && _pidIsDead(currentPid)) {
109
115
  try { unlinkSync(lockPath); reclaimed = true; } catch {}
@@ -48,8 +48,10 @@ export function resolveExecutionMode(args = {}, defaultMode = 'sync') {
48
48
  }
49
49
 
50
50
  export function executionModeSchemaDescription(defaultMode = 'sync') {
51
- const defaultText = defaultMode === 'async' ? 'Default async.' : 'Default sync.';
52
- return `sync = inline result; async = task_id + completion notification. ${defaultText}`;
51
+ if (defaultMode === 'async') {
52
+ return 'sync = inline result; async = task_id + completion notification. Default async.';
53
+ }
54
+ return 'Runs sync; long-running auto-promotes to a background task_id + completion notification. async forces background.';
53
55
  }
54
56
 
55
57
  export function taskIdFromArgs(args = {}) {
@@ -2,7 +2,7 @@ export const TOOL_SYNC_EXECUTION_CONTRACT =
2
2
  'Runs synchronously in this tool call.';
3
3
 
4
4
  export const TOOL_ASYNC_EXECUTION_CONTRACT =
5
- 'Async returns task_id; completion notification follows. status/read/wait are recovery/blocking only.';
5
+ 'Runs sync; long-running auto-promotes to a task_id + completion notification. async forces background. status/read/wait are recovery/blocking only.';
6
6
 
7
7
  export const TOOL_MANUAL_CONTROL_CONTRACT =
8
8
  'wait/read/status/cancel are for explicit blocking or recovery only.';
@@ -74,7 +74,7 @@ export function formatLineDelta(totals) {
74
74
  }
75
75
 
76
76
  export function parseUpdateSummary(text) {
77
- const match = /^(Updated|Created|Deleted)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
77
+ const match = /^(Updated|Created|Deleted|Checked)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
78
78
  if (!match) return null;
79
79
  const action = titleWord(match[1]);
80
80
  const target = match[2].trim();
@@ -703,7 +703,36 @@ export function formatAggregateDetail(summaries) {
703
703
  }
704
704
 
705
705
  const update = parseUpdateSummary(text);
706
- if (update) {
706
+ // Dry-run patch checks ("Checked foo.js · +7 -5") are validations, not
707
+ // edits: their line delta must NEVER be summed into the real edit total.
708
+ // They get their own metric so repeated checks still merge; the preview
709
+ // delta is shown only when the card has no real edit delta it could be
710
+ // confused with. Delta-less "Checked ..." texts (task/memory summaries)
711
+ // fall through to extras unchanged.
712
+ if (update && update.action === 'Checked') {
713
+ if (update.seen) {
714
+ const metric = addMetric('checked_files', {
715
+ files: new Set(),
716
+ fileCount: 0,
717
+ added: 0,
718
+ removed: 0,
719
+ seen: false,
720
+ render: (m) => {
721
+ const count = m.fileCount + m.files.size;
722
+ const target = count === 1 && m.fileCount === 0 ? [...m.files][0] : `${count} ${pluralize(count, 'file')}`;
723
+ const editDelta = formatLineDelta(metrics.get('updated_files'));
724
+ const delta = editDelta ? '' : formatLineDelta(m);
725
+ return delta ? `Checked ${target} · ${delta}` : `Checked ${target}`;
726
+ },
727
+ });
728
+ if (update.file) metric.files.add(update.file);
729
+ metric.fileCount += update.fileCount;
730
+ metric.added += update.added;
731
+ metric.removed += update.removed;
732
+ metric.seen = metric.seen || update.seen;
733
+ continue;
734
+ }
735
+ } else if (update) {
707
736
  const metric = addMetric('updated_files', {
708
737
  files: new Set(),
709
738
  fileCount: 0,
@@ -58,7 +58,7 @@ export function createConfigLifecycle({
58
58
  ) {
59
59
  return outputStyleStatusCache;
60
60
  }
61
- outputStyleStatusCache = outputStyleStatus(dataDir);
61
+ outputStyleStatusCache = outputStyleStatus(dataDir, { fresh });
62
62
  outputStyleStatusCacheAt = now;
63
63
  outputStyleStatusCacheDir = cacheDir;
64
64
  return outputStyleStatusCache;