mixdog 0.9.24 → 0.9.26
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 +2 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench-tmp.mjs +1 -1
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +166 -58
- package/src/tui/engine/agent-job-feed.mjs +42 -3
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/vendor/ink/build/display-width.js +5 -4
- package/src/runtime/shared/schedules-store.mjs +0 -82
|
@@ -11,6 +11,65 @@ const LLM_JUDGE_CAP = 20
|
|
|
11
11
|
|
|
12
12
|
const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
|
|
13
13
|
|
|
14
|
+
// Category grades the pipeline trusts outright: user/cycle1 tagged durable
|
|
15
|
+
// knowledge. These are NEVER content-scanned — a curated constraint like
|
|
16
|
+
// "95% pass threshold" or a rule that cites a metric must still promote.
|
|
17
|
+
const DURABLE_TRUSTED_CATEGORIES = new Set(['rule', 'constraint', 'decision', 'preference', 'goal'])
|
|
18
|
+
|
|
19
|
+
// Deterministic (non-LLM) signature for transient work-state: status/review
|
|
20
|
+
// churn and benchmark/measurement RESULT snapshots. Narrow on purpose — it
|
|
21
|
+
// requires snapshot phrasing (a measurement verb or an explicit result-metric
|
|
22
|
+
// noun), NOT a bare percentage, so SLO/threshold constraints don't trip it.
|
|
23
|
+
// Applied only to non-durable categories (e.g. 'fact'), catching benchmark
|
|
24
|
+
// snapshots mis-tagged as durable-ish. The gate prompt only *discourages*
|
|
25
|
+
// these; this is the structural enforcement.
|
|
26
|
+
const TRANSIENT_SNAPSHOT_RE = new RegExp(
|
|
27
|
+
[
|
|
28
|
+
'terminal-bench',
|
|
29
|
+
'\\bbenchmark(ed|ing|\\s+(run|result|score))\\b',
|
|
30
|
+
'pass@\\d',
|
|
31
|
+
'\\btokens?\\s?\\/\\s?s(ec)?\\b',
|
|
32
|
+
'status\\s+snapshot',
|
|
33
|
+
'review(er)?\\s+(validation\\s+)?cycles?',
|
|
34
|
+
'in[-\\s]progress',
|
|
35
|
+
'\\bWIP\\b',
|
|
36
|
+
'\\b(scored|achieved|measured|reached)\\b[^.]{0,40}\\d+(\\.\\d+)?\\s?%',
|
|
37
|
+
'\\d+(\\.\\d+)?\\s?%\\s+(pass\\s+rate|accuracy|score|throughput|latency)',
|
|
38
|
+
].join('|'),
|
|
39
|
+
'i',
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Structural promotion gate: is this row transient content (task/issue chatter
|
|
43
|
+
// or a status/benchmark snapshot) that must not be promoted pending→active?
|
|
44
|
+
// task/issue block by category. Durable knowledge categories are trusted and
|
|
45
|
+
// skip the content scan. Everything else (e.g. 'fact') is content-scanned so
|
|
46
|
+
// snapshots mis-categorized as durable are still blocked.
|
|
47
|
+
export function isTransientPromotion(row) {
|
|
48
|
+
if (!row) return false
|
|
49
|
+
const cat = String(row.category ?? '').toLowerCase()
|
|
50
|
+
if (TRANSIENT_PROMOTE_CATEGORIES.has(cat)) return true
|
|
51
|
+
if (DURABLE_TRUSTED_CATEGORIES.has(cat)) return false
|
|
52
|
+
return TRANSIENT_SNAPSHOT_RE.test(`${row.element ?? ''} ${row.summary ?? ''}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strip pending→active promotions for transient content out of the status
|
|
56
|
+
// batch BEFORE the cap clamp. Blocked rows are simply dropped from the batch,
|
|
57
|
+
// so they stay pending (held for re-confirmation on a later cycle) — never
|
|
58
|
+
// archived here, and reviewed_at is still bumped via the caller's reviewedIds.
|
|
59
|
+
export function blockTransientPromotions(statusBatch, rowsById) {
|
|
60
|
+
if (!statusBatch?.length) return { batch: statusBatch ?? [], blocked: 0 }
|
|
61
|
+
let blocked = 0
|
|
62
|
+
const batch = statusBatch.filter((item) => {
|
|
63
|
+
if (item.was_pending && item.new_status === 'active'
|
|
64
|
+
&& isTransientPromotion(rowsById.get(Number(item.entry_id)))) {
|
|
65
|
+
blocked += 1
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
return true
|
|
69
|
+
})
|
|
70
|
+
return { batch, blocked }
|
|
71
|
+
}
|
|
72
|
+
|
|
14
73
|
// After the gate, cap how many pending→active promotions may land in one batch.
|
|
15
74
|
// Overflow stays pending (not archived). Tiebreak: durable category before
|
|
16
75
|
// task/issue, then score DESC, then older last_seen_at, then id ASC.
|
|
@@ -11,7 +11,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
|
|
|
11
11
|
import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
|
|
12
12
|
import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
|
|
13
13
|
import {
|
|
14
|
-
applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
|
|
14
|
+
applyBatchStatusVerdicts, clampPendingPromotions, blockTransientPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
|
|
15
15
|
} from './memory-cycle2-mutations.mjs'
|
|
16
16
|
import {
|
|
17
17
|
CYCLE2_ACTIVE_TARGET_CAP, CYCLE2_ACTIVE_MIN_FLOOR, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
|
|
@@ -54,6 +54,7 @@ function mergeCycle2Results(a, b) {
|
|
|
54
54
|
rejected_verb: Number(a.rejected_verb || 0) + Number(b.rejected_verb || 0),
|
|
55
55
|
merge_rejected: Number(a.merge_rejected || 0) + Number(b.merge_rejected || 0),
|
|
56
56
|
missing_core_summary: Number(a.missing_core_summary || 0) + Number(b.missing_core_summary || 0),
|
|
57
|
+
promotion_blocked: Number(a.promotion_blocked || 0) + Number(b.promotion_blocked || 0),
|
|
57
58
|
core_embedding_backfill: Number(a.core_embedding_backfill || 0) + Number(b.core_embedding_backfill || 0),
|
|
58
59
|
core_candidates_nominated: Number(a.core_candidates_nominated || 0) + Number(b.core_candidates_nominated || 0),
|
|
59
60
|
rescore: mergeNestedNumeric(a.rescore, b.rescore),
|
|
@@ -211,6 +212,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
211
212
|
updated: 0, kept: 0, rejected_verb: 0,
|
|
212
213
|
merge_rejected: 0,
|
|
213
214
|
missing_core_summary: 0,
|
|
215
|
+
promotion_blocked: 0,
|
|
214
216
|
core_embedding_backfill: 0,
|
|
215
217
|
core_candidates_nominated: 0,
|
|
216
218
|
rescore: { updated: 0 },
|
|
@@ -523,8 +525,13 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
523
525
|
}
|
|
524
526
|
guardedBatch.push(item)
|
|
525
527
|
}
|
|
528
|
+
// Structural transient block runs BEFORE the cap clamp: task/issue chatter
|
|
529
|
+
// and status/benchmark snapshots can never promote pending→active, so they
|
|
530
|
+
// are stripped here (held pending) regardless of remaining cap slots.
|
|
531
|
+
const blockRes = blockTransientPromotions(guardedBatch, rowsById)
|
|
532
|
+
stats.promotion_blocked = blockRes.blocked
|
|
526
533
|
const activeCountForClamp = Math.max(0, activeCount - reservedDemotions)
|
|
527
|
-
const clampRes = clampPendingPromotions(
|
|
534
|
+
const clampRes = clampPendingPromotions(blockRes.batch, rowsById, activeCountForClamp, activeTargetCap)
|
|
528
535
|
stats.promotion_clamped = clampRes.clamped
|
|
529
536
|
const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
|
|
530
537
|
stats.promoted += batchRes.promoted
|
|
@@ -702,7 +709,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
702
709
|
` core_backfill=${stats.core_embedding_backfill}` +
|
|
703
710
|
` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
|
|
704
711
|
` | gate promoted=${stats.promoted} archived=${stats.archived}` +
|
|
705
|
-
` promotion_clamped=${stats.promotion_clamped}` +
|
|
712
|
+
` promotion_clamped=${stats.promotion_clamped} promotion_blocked=${stats.promotion_blocked}` +
|
|
706
713
|
` updated=${stats.updated} kept=${stats.kept}` +
|
|
707
714
|
` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
|
|
708
715
|
` missing_core=${stats.missing_core_summary}` +
|
|
@@ -11,13 +11,17 @@ function __mixdogMemoryLog(...args) {
|
|
|
11
11
|
// {{CORE_REVIEW}} block for defaults/cycle3-review-prompt.md, then asks the
|
|
12
12
|
// maintenance-preset LLM for one verdict per id. By default Cycle3 performs
|
|
13
13
|
// conservative cleanup: safe compression updates and strict duplicate merges
|
|
14
|
-
// are applied
|
|
14
|
+
// are applied. Deletes now also apply in conservative mode when the model
|
|
15
|
+
// tags the entry as clear junk (a whitelisted junk reason) and — for
|
|
16
|
+
// redundant-with-default reasons — the text actually echoes a current rule,
|
|
17
|
+
// bounded by a per-run delete cap so a run keeps a safety margin instead of
|
|
18
|
+
// nuking the whole set. Unreasoned / non-junk deletes still require APPLY.
|
|
15
19
|
//
|
|
16
20
|
// Verdict line grammar (mirrors parseUnifiedFormat in memory-cycle2.mjs):
|
|
17
21
|
// <id>|keep
|
|
18
22
|
// <id>|update|<element>|<summary>
|
|
19
23
|
// <id>|merge|<target_id>|<source_ids_csv>
|
|
20
|
-
// <id>|delete
|
|
24
|
+
// <id>|delete|<reason>
|
|
21
25
|
|
|
22
26
|
import { existsSync, readFileSync } from 'fs'
|
|
23
27
|
import { join } from 'path'
|
|
@@ -136,6 +140,58 @@ function isStrictDuplicate(a, b) {
|
|
|
136
140
|
return sim >= 0.78
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
// Whitelisted delete reasons that conservative mode may auto-apply. These are
|
|
144
|
+
// the "clear junk" classes: a copy of a built-in/default rule, a bare
|
|
145
|
+
// restatement, an obsolete/already-implemented decision, or a past-event log.
|
|
146
|
+
// Anything outside this set (or a bare `delete` with no reason) stays held for
|
|
147
|
+
// APPLY CYCLE3 so genuinely durable rules are never removed unattended.
|
|
148
|
+
const SAFE_DELETE_REASONS = new Set([
|
|
149
|
+
'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
|
|
150
|
+
'restatement', 'restate', 'restates_default',
|
|
151
|
+
'obsolete', 'implemented', 'done', 'completed', 'resolved',
|
|
152
|
+
'superseded_decision', 'stale', 'past_event', 'event_log', 'log',
|
|
153
|
+
])
|
|
154
|
+
// Reasons that claim redundancy with a built-in/default rule — these demand
|
|
155
|
+
// corroboration (the core text must actually echo the current rules digest)
|
|
156
|
+
// before a conservative auto-delete, so a mislabelled durable rule survives.
|
|
157
|
+
const DEFAULT_ECHO_REASONS = new Set([
|
|
158
|
+
'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
|
|
159
|
+
'restatement', 'restate', 'restates_default',
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
function normalizeDeleteReason(reason) {
|
|
163
|
+
return String(reason ?? '')
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.trim()
|
|
166
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
167
|
+
.replace(/^_+|_+$/g, '')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Max trigram similarity of `text` against any substantive line of the current
|
|
171
|
+
// rules digest — how strongly a core entry echoes a built-in rule.
|
|
172
|
+
function digestRedundancy(text, rulesDigest) {
|
|
173
|
+
if (!text || !rulesDigest) return 0
|
|
174
|
+
const lines = String(rulesDigest).split('\n').map(l => l.trim()).filter(l => l.length >= 12)
|
|
175
|
+
let best = 0
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
const d = charDice(text, line)
|
|
178
|
+
if (d > best) best = d
|
|
179
|
+
if (best >= 0.9) break
|
|
180
|
+
}
|
|
181
|
+
return best
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isSafeConservativeDelete(core, action, rulesDigest) {
|
|
185
|
+
const reason = normalizeDeleteReason(action?.reason)
|
|
186
|
+
if (!reason) return { ok: false, reason: 'delete needs a junk reason → APPLY CYCLE3' }
|
|
187
|
+
if (!SAFE_DELETE_REASONS.has(reason)) return { ok: false, reason: `delete reason "${reason}" not in safe set → APPLY CYCLE3` }
|
|
188
|
+
if (DEFAULT_ECHO_REASONS.has(reason)) {
|
|
189
|
+
const red = digestRedundancy(coreText(core), rulesDigest)
|
|
190
|
+
if (red < 0.5) return { ok: false, reason: `not redundant with defaults (sim=${red.toFixed(2)})` }
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, reason }
|
|
193
|
+
}
|
|
194
|
+
|
|
139
195
|
function formatRelatedRow(r) {
|
|
140
196
|
const tag = r.project_id ? r.project_id : 'COMMON'
|
|
141
197
|
const stat = r.status ? `[${r.status}]` : '[?]'
|
|
@@ -206,7 +262,8 @@ function parseVerdicts(raw, idSet) {
|
|
|
206
262
|
}
|
|
207
263
|
actions.push({ id, verb: 'merge', targetId, sourceIds })
|
|
208
264
|
} else if (verb === 'delete') {
|
|
209
|
-
|
|
265
|
+
const reason = (parts[2] ?? '').trim()
|
|
266
|
+
actions.push({ id, verb: 'delete', reason: reason || null })
|
|
210
267
|
} else if (verb === 'superseded' || verb === 'supersede') {
|
|
211
268
|
// Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
|
|
212
269
|
// newer active core id the supersession has no evidence → drop to keep.
|
|
@@ -553,6 +610,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
553
610
|
const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
|
|
554
611
|
const details = []
|
|
555
612
|
const touched = new Set() // ids already acted on this cycle — avoid double action
|
|
613
|
+
// Safety margin: even when every junk verdict is legitimate, a single
|
|
614
|
+
// conservative run applies at most this many outright deletes and holds the
|
|
615
|
+
// rest for the next pass. Prevents an over-eager model from clearing the set.
|
|
616
|
+
const conservativeDeleteCap = Math.max(1, Math.ceil(cores.length * 0.35))
|
|
556
617
|
|
|
557
618
|
// Core-store edit/delete calls are the mutation unit; checkpoints sit before
|
|
558
619
|
// each action/source and after each awaited unit, not inside one file-store write.
|
|
@@ -596,19 +657,28 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
596
657
|
}
|
|
597
658
|
if (a.verb === 'delete') {
|
|
598
659
|
proposed.deleted++
|
|
599
|
-
|
|
660
|
+
// confirmed → always delete. conservative → delete only clear junk (safe
|
|
661
|
+
// reason, corroborated for redundant-with-default), capped per run.
|
|
662
|
+
// proposal → always hold.
|
|
663
|
+
let applyDelete = confirmed
|
|
664
|
+
let holdReason = 'proposal mode'
|
|
665
|
+
let safeReason = null
|
|
666
|
+
if (!confirmed && conservative) {
|
|
667
|
+
const safeDel = isSafeConservativeDelete(coreById.get(a.id), a, rulesDigest)
|
|
668
|
+
if (!safeDel.ok) holdReason = safeDel.reason
|
|
669
|
+
else if (deleted >= conservativeDeleteCap) holdReason = `conservative delete cap ${conservativeDeleteCap} reached`
|
|
670
|
+
else { applyDelete = true; safeReason = safeDel.reason }
|
|
671
|
+
}
|
|
672
|
+
if (!applyDelete) {
|
|
600
673
|
held.deleted++
|
|
601
|
-
details.push({
|
|
602
|
-
id: a.id, verb: 'delete', applied: false, held: true,
|
|
603
|
-
reason: conservative ? 'delete requires APPLY CYCLE3' : 'proposal mode',
|
|
604
|
-
})
|
|
674
|
+
details.push({ id: a.id, verb: 'delete', applied: false, held: true, reason: holdReason })
|
|
605
675
|
touched.add(a.id)
|
|
606
676
|
continue
|
|
607
677
|
}
|
|
608
678
|
try {
|
|
609
679
|
await deleteCore(dataDir, a.id)
|
|
610
680
|
deleted++
|
|
611
|
-
details.push({ id: a.id, verb: 'delete', applied: true })
|
|
681
|
+
details.push({ id: a.id, verb: 'delete', applied: true, reason: safeReason || 'confirmed' })
|
|
612
682
|
touched.add(a.id)
|
|
613
683
|
} catch (err) {
|
|
614
684
|
if (signal?.aborted) throw signal.reason ?? err
|
|
@@ -75,7 +75,11 @@ async function _initClient(client, schema) {
|
|
|
75
75
|
// memory.entries from a trace-schema connection, or recall × trace JOIN
|
|
76
76
|
// analytics) MUST use fully-qualified names (memory.entries / trace.trace_events).
|
|
77
77
|
// Relying on search_path silently in cross-schema code = bug magnet.
|
|
78
|
-
const sp = schema === 'trace'
|
|
78
|
+
const sp = schema === 'trace'
|
|
79
|
+
? 'trace, public'
|
|
80
|
+
: schema === 'scheduler'
|
|
81
|
+
? 'scheduler, public'
|
|
82
|
+
: 'memory, public'
|
|
79
83
|
await client.query(`SET search_path = ${sp}`)
|
|
80
84
|
// pg_trgm similarity threshold: session-local, must be set per connection.
|
|
81
85
|
await client.query(`SELECT set_limit(0.10)`)
|
|
@@ -331,6 +335,7 @@ async function bootstrapInstance(pgPool, dataDirKey) {
|
|
|
331
335
|
await client.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public`)
|
|
332
336
|
await client.query(`CREATE SCHEMA IF NOT EXISTS memory`)
|
|
333
337
|
await client.query(`CREATE SCHEMA IF NOT EXISTS trace`)
|
|
338
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS scheduler`)
|
|
334
339
|
} finally {
|
|
335
340
|
client.release()
|
|
336
341
|
}
|
|
@@ -290,7 +290,12 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
290
290
|
// so we return the instant pg_isready succeeds rather than waiting on pg_ctl.
|
|
291
291
|
// Only the long-lived child handle is unref'd; poll timers stay ref'd.
|
|
292
292
|
async function startAndWaitReady() {
|
|
293
|
-
|
|
293
|
+
// detached on win32: give pg_ctl (and the postmaster it spawns) its own
|
|
294
|
+
// process group / console so an ancestor `taskkill /F /T` cannot enumerate
|
|
295
|
+
// it via the Node process tree. The orphaned postmaster survives and is
|
|
296
|
+
// re-adopted next boot via supervisor tryReusePgInstance (postmaster.pid).
|
|
297
|
+
const detached = process.platform === 'win32'
|
|
298
|
+
const child = spawn(pgctl, startArgs, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached })
|
|
294
299
|
child.unref?.()
|
|
295
300
|
let stdout = '', stderr = '', closed = false, exitCode = null
|
|
296
301
|
child.stdout?.on('data', d => { stdout += d.toString() })
|
|
@@ -375,6 +380,19 @@ export async function stopPg({ runtimeDir, pgdataDir }) {
|
|
|
375
380
|
}
|
|
376
381
|
}
|
|
377
382
|
|
|
383
|
+
// Synchronous best-effort variant for a process 'exit' hook (only sync work is
|
|
384
|
+
// possible there). One bounded `pg_ctl stop -m fast`; never throws. On Windows
|
|
385
|
+
// a force-kill of the daemon can orphan the postmaster mid-write, so this is
|
|
386
|
+
// the last-ditch graceful attempt before the process goes away.
|
|
387
|
+
export function stopPgSync({ runtimeDir, pgdataDir }) {
|
|
388
|
+
try {
|
|
389
|
+
spawnSync(pgBin(runtimeDir, 'pg_ctl'), ['stop', '-m', 'fast', '-D', pgdataDir], {
|
|
390
|
+
env: libEnv(runtimeDir), stdio: 'ignore', timeout: 8_000, windowsHide: true,
|
|
391
|
+
})
|
|
392
|
+
__mixdogMemoryLog('[pg-process] stopPgSync: pg_ctl stop -m fast issued on exit\n')
|
|
393
|
+
} catch {}
|
|
394
|
+
}
|
|
395
|
+
|
|
378
396
|
// ---------------------------------------------------------------------------
|
|
379
397
|
// healthcheckPg
|
|
380
398
|
// ---------------------------------------------------------------------------
|
|
@@ -28,7 +28,12 @@ import {
|
|
|
28
28
|
} from 'node:fs';
|
|
29
29
|
import { join, resolve } from 'node:path';
|
|
30
30
|
import { tmpdir } from 'node:os';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
discoveryPath as _pgDiscoveryPath,
|
|
33
|
+
readServiceAdvert as _readPgServiceAdvert,
|
|
34
|
+
writeServiceAdvert as _writePgServiceAdvert,
|
|
35
|
+
} from '../../../shared/service-discovery.mjs';
|
|
36
|
+
import { withFileLockSync } from '../../../shared/atomic-file.mjs';
|
|
32
37
|
|
|
33
38
|
// ── pg-process interface (Track A) ───────────────────────────────────────────
|
|
34
39
|
// Dynamic import so this module loads even before Track A's file exists.
|
|
@@ -42,6 +47,7 @@ async function _getPgProc() {
|
|
|
42
47
|
stopPg: mod.stopPg,
|
|
43
48
|
healthcheckPg: mod.healthcheckPg,
|
|
44
49
|
reconcileConfV2: mod.reconcileConfV2,
|
|
50
|
+
stopPgSync: mod.stopPgSync,
|
|
45
51
|
};
|
|
46
52
|
return _pgProc;
|
|
47
53
|
}
|
|
@@ -181,23 +187,95 @@ const _RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
|
|
|
181
187
|
? resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
182
188
|
: join(tmpdir(), 'mixdog');
|
|
183
189
|
const _ACTIVE_FILE = join(_RUNTIME_ROOT, 'active-instance.json');
|
|
190
|
+
// Single-writer PG advert. Port discovery (pg_port et al.) moved OUT of the
|
|
191
|
+
// shared, lock-guarded active-instance.json into discovery/pg.json, written by
|
|
192
|
+
// this supervisor via a plain atomic rename (NO .lock) so it can never be
|
|
193
|
+
// starved by active-instance lock contention.
|
|
194
|
+
const _PG_DISCOVERY = 'pg';
|
|
195
|
+
|
|
196
|
+
// Read the PG advert, preferring the single-writer discovery file
|
|
197
|
+
// (discovery/pg.json) and falling back to the legacy active-instance.json pg_*
|
|
198
|
+
// fields for cross-version compat. Field names are identical in both sources.
|
|
199
|
+
function _readPgAdvert() {
|
|
200
|
+
const advert = _readPgServiceAdvert(_PG_DISCOVERY);
|
|
201
|
+
if (advert && Number(advert.pg_port) > 0) return advert;
|
|
202
|
+
try {
|
|
203
|
+
const ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8'));
|
|
204
|
+
if (ai && Number(ai.pg_port) > 0) return ai;
|
|
205
|
+
} catch {}
|
|
206
|
+
return advert ?? null;
|
|
207
|
+
}
|
|
184
208
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
209
|
+
// Transient write-contention codes. ELOCK* survive from the old locked path;
|
|
210
|
+
// EPERM/EBUSY/EACCES cover Windows atomic-rename hiccups on the plain-rename
|
|
211
|
+
// discovery write so it backs off and retries rather than dropping the advert.
|
|
212
|
+
const _LOCK_CONTENTION_CODES = new Set(['ELOCKTIMEOUT', 'ELOCKCONTENDED', 'EPERM', 'EBUSY', 'EACCES']);
|
|
213
|
+
|
|
214
|
+
// Is the current on-disk advert owned by a DIFFERENT, still-live supervisor?
|
|
215
|
+
// A clear (pg_port → null) must never unlink a NEWER supervisor's fresh advert:
|
|
216
|
+
// re-read under the lock and, if pg_owner_pid names another live process, leave
|
|
217
|
+
// it. Our own pid, a dead owner, or a missing/legacy owner → safe to unlink.
|
|
218
|
+
function _pgAdvertIsForeignLive(adv) {
|
|
219
|
+
const owner = Number(adv?.pg_owner_pid);
|
|
220
|
+
if (!Number.isInteger(owner) || owner <= 0) return false;
|
|
221
|
+
if (owner === process.pid) return false;
|
|
222
|
+
return isPidAlive(owner);
|
|
223
|
+
}
|
|
188
224
|
|
|
189
225
|
function _applyActiveInstancePatch(fields, timeoutMs) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
226
|
+
// pg.json is NOT single-writer: start/attach/stop paths write it across
|
|
227
|
+
// separate supervisor processes, so the unlocked read-merge-write raced and
|
|
228
|
+
// lost cross-process updates. Guard it with pg.json's OWN private lock —
|
|
229
|
+
// supervisor-only contention, so it never recreates the shared
|
|
230
|
+
// active-instance.json bottleneck (memory.json stays lock-free).
|
|
231
|
+
const file = _pgDiscoveryPath(_PG_DISCOVERY);
|
|
232
|
+
withFileLockSync(`${file}.lock`, () => {
|
|
233
|
+
const cur = _readPgServiceAdvert(_PG_DISCOVERY) ?? {};
|
|
234
|
+
// Drop stale fields (pid/startedAt/updatedAt) written by older versions.
|
|
235
|
+
const { pid: _legacyPid, startedAt: _legacyStartedAt, updatedAt: _prevUpdatedAt, ...merged } = cur;
|
|
195
236
|
for (const [k, v] of Object.entries(fields)) {
|
|
196
237
|
if (v == null) delete merged[k];
|
|
197
238
|
else merged[k] = v;
|
|
198
239
|
}
|
|
199
|
-
|
|
200
|
-
|
|
240
|
+
const port = Number(merged.pg_port);
|
|
241
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
242
|
+
// No live port left → drop the advert (clean pg shutdown). But a late
|
|
243
|
+
// clear from an old shutdown/recovery path must NOT delete a fresh advert
|
|
244
|
+
// a newer supervisor just published — unlink only when it is ours or a
|
|
245
|
+
// dead corpse, never another live owner's.
|
|
246
|
+
if (_pgAdvertIsForeignLive(cur)) return;
|
|
247
|
+
try { unlinkSync(file); } catch {}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Stamp our supervisor pid so a later clear can prove advert ownership.
|
|
251
|
+
merged.pg_owner_pid = process.pid;
|
|
252
|
+
_writePgServiceAdvert(_PG_DISCOVERY, merged);
|
|
253
|
+
}, { timeoutMs });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Re-stamp advert ownership on REUSE without clobbering a concurrently-updated
|
|
257
|
+
// advert. Unlike patchActiveInstance (which blindly overwrites pg_port/pg_pgdata
|
|
258
|
+
// /pg_runtime_dir from the reuse-time snapshot — already stale by the time the
|
|
259
|
+
// lock is acquired, so a restart or a different live owner that landed meanwhile
|
|
260
|
+
// gets clobbered), this RE-READS under the lock and only stamps our pid when the
|
|
261
|
+
// advert STILL describes the same instance we reused (same pgdata + port) and no
|
|
262
|
+
// DIFFERENT live owner has since taken it. It touches only pg_owner_pid — never
|
|
263
|
+
// rewriting other fields from the stale snapshot.
|
|
264
|
+
function _restampReuseOwner({ pgdata, port }) {
|
|
265
|
+
const file = _pgDiscoveryPath(_PG_DISCOVERY);
|
|
266
|
+
try {
|
|
267
|
+
withFileLockSync(`${file}.lock`, () => {
|
|
268
|
+
const cur = _readPgServiceAdvert(_PG_DISCOVERY);
|
|
269
|
+
if (!cur) return; // advert gone — nothing to re-stamp
|
|
270
|
+
if (Number(cur.pg_port) !== Number(port)) return; // restarted on a new port
|
|
271
|
+
if (!cur.pg_pgdata || resolve(cur.pg_pgdata) !== resolve(pgdata)) return; // different instance
|
|
272
|
+
if (_pgAdvertIsForeignLive(cur)) return; // a different live owner holds it
|
|
273
|
+
if (Number(cur.pg_owner_pid) === process.pid) return; // already ours
|
|
274
|
+
const { pid: _legacyPid, startedAt: _legacyStartedAt, updatedAt: _prevUpdatedAt, ...merged } = cur;
|
|
275
|
+
merged.pg_owner_pid = process.pid; // minimal: owner pid only (updatedAt re-stamped on write)
|
|
276
|
+
_writePgServiceAdvert(_PG_DISCOVERY, merged);
|
|
277
|
+
}, { timeoutMs: 1000 });
|
|
278
|
+
} catch { /* best-effort: lock contention → skip re-stamp (guard reverts to prior owner) */ }
|
|
201
279
|
}
|
|
202
280
|
|
|
203
281
|
// Single module-level background-retry slot: a delayed retry must NEVER replay
|
|
@@ -347,7 +425,7 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
|
|
|
347
425
|
let existingPort = null;
|
|
348
426
|
let existingRtDir = runtimeDir;
|
|
349
427
|
try {
|
|
350
|
-
ai =
|
|
428
|
+
ai = _readPgAdvert();
|
|
351
429
|
if (ai?.pg_port && ai?.pg_pgdata && resolve(ai.pg_pgdata) === resolve(pgdata)) {
|
|
352
430
|
existingPort = ai.pg_port;
|
|
353
431
|
existingRtDir = ai?.pg_runtime_dir ?? runtimeDir;
|
|
@@ -359,6 +437,14 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
|
|
|
359
437
|
if (await healthcheckPg({ port: existingPort })) {
|
|
360
438
|
__mixdogMemoryLog(`[supervisor-pg] reusing PG on port ${existingPort} (${source}:active-instance)\n`);
|
|
361
439
|
_live = { port: existingPort, pgdata, runtimeDir: existingRtDir, proc: null };
|
|
440
|
+
// Re-stamp advert ownership: this process is now a live owner of the
|
|
441
|
+
// reused postmaster. Without it the advert keeps the previous (possibly
|
|
442
|
+
// dead) owner pid, and a later sync exit hook's _pgAdvertIsForeignLive
|
|
443
|
+
// guard would judge it "not foreign-live" and wrongly sync-stop a
|
|
444
|
+
// postmaster this reuser still uses. Use the revalidating re-stamp (not
|
|
445
|
+
// patchActiveInstance) so a concurrent restart/owner change is not
|
|
446
|
+
// clobbered by this reuse-time snapshot.
|
|
447
|
+
_restampReuseOwner({ pgdata, port: existingPort });
|
|
362
448
|
return { host: '127.0.0.1', port: existingPort, runtimeDir: existingRtDir, pgdataDir: pgdata };
|
|
363
449
|
}
|
|
364
450
|
} catch {}
|
|
@@ -452,7 +538,7 @@ async function _doEnsure(dataDir) {
|
|
|
452
538
|
let existingPort = null;
|
|
453
539
|
let ai = null;
|
|
454
540
|
try {
|
|
455
|
-
ai =
|
|
541
|
+
ai = _readPgAdvert();
|
|
456
542
|
// Only reuse a recorded instance when it was started for THIS pgdata.
|
|
457
543
|
// active-instance.json is process-global; without matching pg_pgdata a
|
|
458
544
|
// healthy PG serving a different data directory would be reused, binding
|
|
@@ -536,7 +622,7 @@ export async function stopPgForShutdown() {
|
|
|
536
622
|
// _live may be null if PG was started by another process or adapter call.
|
|
537
623
|
// Attempt graceful stop via active-instance.json.
|
|
538
624
|
let ai = null;
|
|
539
|
-
try { ai =
|
|
625
|
+
try { ai = _readPgAdvert(); } catch {}
|
|
540
626
|
if (!ai?.pg_port || !ai?.pg_pgdata) return;
|
|
541
627
|
const pgdataDir2 = ai.pg_pgdata;
|
|
542
628
|
const runtimeDir2 = ai.pg_runtime_dir;
|
|
@@ -570,3 +656,28 @@ export async function stopPgForShutdown() {
|
|
|
570
656
|
// tolerated — readers healthcheck-verify the port before reuse.
|
|
571
657
|
patchActiveInstance({ pg_port: null, pg_started_at: null, pg_pgdata: null, pg_runtime_dir: null }, { timeoutMs: 2000, syncRetries: 1 });
|
|
572
658
|
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Synchronous last-ditch PG stop for a process 'exit' hook. Covers the paths
|
|
662
|
+
* where the async stopPgForShutdown() above never runs/completes — a Windows
|
|
663
|
+
* force-kill, or stop() hitting its exit timeout — which otherwise kill the
|
|
664
|
+
* postmaster mid-write and force WAL crash recovery on next boot. No-op once
|
|
665
|
+
* the async path has cleared _live + active-instance (its record is gone), so
|
|
666
|
+
* a graceful shutdown is never double-stopped.
|
|
667
|
+
*/
|
|
668
|
+
export function stopPgForShutdownSync() {
|
|
669
|
+
// Mirror the async clear's ownership guard (_pgAdvertIsForeignLive) and apply
|
|
670
|
+
// it UNCONDITIONALLY — even when _live is set. Async shutdown or a
|
|
671
|
+
// cross-process reuse may have handed this postmaster to a different, still-
|
|
672
|
+
// live owner that re-stamped pg_owner_pid; that owner will stop it itself, so
|
|
673
|
+
// the exit hook must never sync-stop it out from under them.
|
|
674
|
+
let ai = null;
|
|
675
|
+
try { ai = _readPgAdvert(); } catch {}
|
|
676
|
+
if (_pgAdvertIsForeignLive(ai)) return;
|
|
677
|
+
let runtimeDir = _live?.runtimeDir, pgdataDir = _live?.pgdata;
|
|
678
|
+
if (!runtimeDir || !pgdataDir) {
|
|
679
|
+
runtimeDir = ai?.pg_runtime_dir; pgdataDir = ai?.pg_pgdata;
|
|
680
|
+
}
|
|
681
|
+
if (!runtimeDir || !pgdataDir || !_pgProc?.stopPgSync) return;
|
|
682
|
+
try { _pgProc.stopPgSync({ runtimeDir, pgdataDir }); } catch {}
|
|
683
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { searchRelevantHybrid } from './memory-recall-store.mjs'
|
|
24
24
|
import { fetchEntriesByIdsScoped } from './memory-recall-id-patch.mjs'
|
|
25
25
|
import { retrieveEntries } from './memory-retrievers.mjs'
|
|
26
|
+
import { buildPromotedExclusionClauses } from './memory-recall-scope-filter.mjs'
|
|
26
27
|
import { cleanMemoryText } from './memory.mjs'
|
|
27
28
|
import { insertTraceEvents } from './trace-store.mjs'
|
|
28
29
|
import {
|
|
@@ -661,6 +662,107 @@ export function createQueryHandlers({
|
|
|
661
662
|
return out
|
|
662
663
|
}
|
|
663
664
|
|
|
665
|
+
// period='last': session-grouped browse. Pick the N most-recently-active
|
|
666
|
+
// sessions (limit = session count, default 5; offset = session-level
|
|
667
|
+
// paging) ranked by MAX(ts) DESC, then fill each with its newest rows
|
|
668
|
+
// under a per-session row cap. Session selection and per-session fetch
|
|
669
|
+
// reuse the same projectScope / excludeStatuses filters as the generic
|
|
670
|
+
// browse below; the grouped renderer adds activity-span headers. Output
|
|
671
|
+
// size is bounded by session count x per-session row cap plus the
|
|
672
|
+
// orchestrator-level tool-output KB cap — no recall-local line budget.
|
|
673
|
+
if (temporal?.mode === 'last') {
|
|
674
|
+
const sessionCount = Number.isFinite(requestedLimit) ? limit : 5
|
|
675
|
+
const PER_SESSION_ROW_CAP = 10
|
|
676
|
+
const excludeStatuses = includeArchived ? [] : ['archived']
|
|
677
|
+
const VALID_LAST_CATS = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
|
|
678
|
+
const catList = category == null
|
|
679
|
+
? []
|
|
680
|
+
: (Array.isArray(category) ? category : [category])
|
|
681
|
+
.map((c) => String(c).trim().toLowerCase())
|
|
682
|
+
.filter((c) => VALID_LAST_CATS.has(c))
|
|
683
|
+
// 1) rank sessions by most-recent activity (MAX(ts)) over the SAME pool
|
|
684
|
+
// the fill draws from — roots (status-filtered) + unchunked raw
|
|
685
|
+
// leaves, member rows excluded — so a session ranked here can never
|
|
686
|
+
// fill empty (e.g. an archived-root session whose only surviving rows
|
|
687
|
+
// are members). projectScope + excludeStatuses + category + promoted
|
|
688
|
+
// exclusion all match the fill filters below.
|
|
689
|
+
const selWhere = [
|
|
690
|
+
'session_id IS NOT NULL',
|
|
691
|
+
"btrim(session_id) <> ''",
|
|
692
|
+
'(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)',
|
|
693
|
+
]
|
|
694
|
+
const selParams = []
|
|
695
|
+
if (projectScope === 'common') {
|
|
696
|
+
selWhere.push('project_id IS NULL')
|
|
697
|
+
} else if (typeof projectScope === 'string' && projectScope && projectScope !== 'all') {
|
|
698
|
+
selParams.push(projectScope)
|
|
699
|
+
selWhere.push(`(project_id IS NULL OR project_id = $${selParams.length})`)
|
|
700
|
+
}
|
|
701
|
+
if (excludeStatuses.length > 0) {
|
|
702
|
+
const ph = excludeStatuses.map((s) => { selParams.push(s); return `$${selParams.length}` }).join(',')
|
|
703
|
+
selWhere.push(`(status IS NULL OR status NOT IN (${ph}))`)
|
|
704
|
+
}
|
|
705
|
+
if (catList.length > 0) {
|
|
706
|
+
const ph = catList.map((c) => { selParams.push(c); return `$${selParams.length}` }).join(',')
|
|
707
|
+
selWhere.push(`category IN (${ph})`)
|
|
708
|
+
}
|
|
709
|
+
for (const c of buildPromotedExclusionClauses()) selWhere.push(c)
|
|
710
|
+
selParams.push(sessionCount, offset)
|
|
711
|
+
// Deterministic tie-breaker: equal MAX(ts) across sessions would let the
|
|
712
|
+
// LIMIT/OFFSET window skip or duplicate a session between offset pages
|
|
713
|
+
// without a stable secondary sort.
|
|
714
|
+
const sessSql = `SELECT session_id, MAX(ts) AS last_ts
|
|
715
|
+
FROM entries
|
|
716
|
+
WHERE ${selWhere.join(' AND ')}
|
|
717
|
+
GROUP BY session_id
|
|
718
|
+
ORDER BY last_ts DESC, session_id DESC
|
|
719
|
+
LIMIT $${selParams.length - 1} OFFSET $${selParams.length}`
|
|
720
|
+
const sessRows = (await db.query(sessSql, selParams)).rows
|
|
721
|
+
// 2) per selected session, fetch its newest rows (roots+members, sort by
|
|
722
|
+
// date) plus the fresh raw window, capped at PER_SESSION_ROW_CAP.
|
|
723
|
+
const allRows = []
|
|
724
|
+
for (const s of sessRows) {
|
|
725
|
+
const sid = String(s?.session_id || '').trim()
|
|
726
|
+
if (!sid) continue
|
|
727
|
+
const sf = { limit: PER_SESSION_ROW_CAP, session_id: sid, projectScope, sort: 'date' }
|
|
728
|
+
if (includeMembers) sf.includeMembers = true
|
|
729
|
+
if (excludeStatuses.length > 0) sf.excludeStatuses = excludeStatuses
|
|
730
|
+
if (catList.length > 0) sf.category = catList
|
|
731
|
+
const sRows = await retrieveEntries(db, sf)
|
|
732
|
+
let merged = sRows
|
|
733
|
+
if (includeRaw) {
|
|
734
|
+
const rawRows = await readRawRowsInWindow(db, null, Date.now(), PER_SESSION_ROW_CAP, { projectScope, sessionId: sid })
|
|
735
|
+
const seenIds = new Set(sRows.map((r) => Number(r.id)))
|
|
736
|
+
for (const r of sRows) if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
|
|
737
|
+
// readRawRowsInWindow carries no category filter, so a category-
|
|
738
|
+
// scoped last must gate raw rows here to match the ranking/fill
|
|
739
|
+
// category predicate (unclassified raw rows have no category and
|
|
740
|
+
// are correctly dropped when a category is requested).
|
|
741
|
+
let newRaw = rawRows.filter((r) => !seenIds.has(Number(r.id)))
|
|
742
|
+
if (catList.length > 0) {
|
|
743
|
+
newRaw = newRaw.filter((r) => catList.includes(String(r.category || '').trim().toLowerCase()))
|
|
744
|
+
}
|
|
745
|
+
// readRawRowsInWindow carries no status filter either, so an
|
|
746
|
+
// includeArchived:false browse must drop archived raw rows here to
|
|
747
|
+
// match the ranking/root-fill excludeStatuses predicate.
|
|
748
|
+
if (excludeStatuses.length > 0) {
|
|
749
|
+
newRaw = newRaw.filter((r) => {
|
|
750
|
+
const st = String(r.status || '').trim().toLowerCase()
|
|
751
|
+
return !st || !excludeStatuses.includes(st)
|
|
752
|
+
})
|
|
753
|
+
}
|
|
754
|
+
if (newRaw.length > 0) {
|
|
755
|
+
merged = [...sRows, ...newRaw]
|
|
756
|
+
merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
|
|
757
|
+
merged = merged.slice(0, PER_SESSION_ROW_CAP)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
for (const r of merged) allRows.push(r)
|
|
761
|
+
}
|
|
762
|
+
const _currentSessionHint = String(args?.currentSessionId || '').trim()
|
|
763
|
+
return { text: recallCapPrefix + renderSessionGroupedLines(allRows, { currentSessionId: _currentSessionHint, recencyOrder: true, spanHeaders: true }) }
|
|
764
|
+
}
|
|
765
|
+
|
|
664
766
|
const filters = { limit: limit + offset }
|
|
665
767
|
if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
|
|
666
768
|
filters.projectScope = projectScope
|