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.
- package/package.json +3 -2
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/recall-bench-cases.json +11 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +5 -2
- package/scripts/tool-smoke.mjs +101 -27
- package/src/agents/debugger/AGENT.md +3 -3
- package/src/agents/heavy-worker/AGENT.md +7 -10
- package/src/agents/maintainer/AGENT.md +1 -2
- package/src/agents/reviewer/AGENT.md +1 -2
- package/src/agents/worker/AGENT.md +5 -8
- package/src/defaults/agents.json +3 -0
- package/src/mixdog-session-runtime.mjs +23 -6
- package/src/rules/agent/00-core.md +4 -3
- package/src/rules/agent/30-explorer.md +53 -22
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +24 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
- package/src/runtime/channels/backends/discord.mjs +139 -7
- package/src/runtime/channels/index.mjs +290 -76
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
- package/src/runtime/channels/lib/memory-client.mjs +32 -14
- package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
- package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
- package/src/runtime/memory/lib/query-handlers.mjs +36 -4
- package/src/runtime/memory/lib/recall-format.mjs +106 -6
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +10 -4
- package/src/runtime/shared/background-tasks.mjs +4 -2
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +30 -1
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/cwd-plugins.mjs +46 -3
- package/src/session-runtime/mcp-glue.mjs +24 -3
- package/src/session-runtime/output-styles.mjs +44 -10
- package/src/session-runtime/workflow.mjs +16 -1
- package/src/standalone/channel-worker.mjs +88 -7
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +57 -77
- package/src/tui/app/channel-pickers.mjs +45 -0
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/transcript-window.mjs +66 -1
- package/src/tui/app/use-mouse-input.mjs +9 -2
- package/src/tui/app/use-prompt-handlers.mjs +7 -94
- package/src/tui/app/use-transcript-scroll.mjs +5 -1
- package/src/tui/app/use-transcript-window.mjs +74 -8
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +4744 -4806
- package/src/tui/engine.mjs +109 -20
- package/src/tui/lib/voice-setup.mjs +166 -0
- package/src/tui/paste-attachments.mjs +12 -5
- package/src/tui/prompt-history-store.mjs +125 -12
- package/scripts/bench/cache-probe-tasks.json +0 -8
- package/scripts/bench/lead-review-tasks-r3.json +0 -20
- package/scripts/bench/lead-review-tasks.json +0 -20
- package/scripts/bench/r4-mixed-tasks.json +0 -20
- package/scripts/bench/r5-orchestrated-task.json +0 -7
- package/scripts/bench/review-tasks.json +0 -20
- package/scripts/bench/round-codex.json +0 -114
- package/scripts/bench/round-mixdog-lead-r3.json +0 -269
- package/scripts/bench/round-mixdog-lead.json +0 -269
- package/scripts/bench/round-mixdog.json +0 -126
- package/scripts/bench/round-r10-bigsample.json +0 -679
- package/scripts/bench/round-r11-codexalign.json +0 -257
- package/scripts/bench/round-r13-clientmeta.json +0 -464
- package/scripts/bench/round-r14-betafeatures.json +0 -466
- package/scripts/bench/round-r15-fulldefault.json +0 -462
- package/scripts/bench/round-r16-sessionid.json +0 -466
- package/scripts/bench/round-r17-wirebytes.json +0 -456
- package/scripts/bench/round-r18-prewarm.json +0 -468
- package/scripts/bench/round-r19-clean.json +0 -472
- package/scripts/bench/round-r20-prewarm-clean.json +0 -475
- package/scripts/bench/round-r21-delta-retry.json +0 -473
- package/scripts/bench/round-r22-full-probe.json +0 -693
- package/scripts/bench/round-r23-itemprobe.json +0 -701
- package/scripts/bench/round-r24-shapefix.json +0 -677
- package/scripts/bench/round-r25-serial.json +0 -464
- package/scripts/bench/round-r26-parallel3.json +0 -671
- package/scripts/bench/round-r27-parallel10.json +0 -894
- package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
- package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
- package/scripts/bench/round-r30-instid.json +0 -253
- package/scripts/bench/round-r31-upgradeprobe.json +0 -256
- package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
- package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
- package/scripts/bench/round-r34-orchestrated.json +0 -120
- package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
- package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
- package/scripts/bench/round-r4-codex.json +0 -114
- package/scripts/bench/round-r4-mixed.json +0 -225
- package/scripts/bench/round-r5-gpt-lead.json +0 -259
- package/scripts/bench/round-r6-codex.json +0 -114
- package/scripts/bench/round-r6-solo.json +0 -257
- package/scripts/bench/round-r7-full.json +0 -254
- package/scripts/bench/round-r8-fulldefault.json +0 -255
- package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
- 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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
189
|
+
units.push({ ts: Number(m.ts) || 0, text: `[${mTs}] ${role}: ${content} #${m.id}` })
|
|
173
190
|
}
|
|
174
191
|
} else {
|
|
175
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
100
|
-
if (
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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;
|