mixdog 0.9.13 → 0.9.14

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.
@@ -122,7 +122,8 @@ export function buildEntriesText(entries) {
122
122
  const DEFAULT_CYCLE1_RULES = [
123
123
  `Chunk these entries. Emit one chunk per line, NO JSON, NO tool calls, NO prose.`,
124
124
  `Format: idx_csv|element|category|summary.`,
125
- `Every substantive @N should appear in exactly one chunk; omit entries with no standalone memory value.`,
125
+ `Target ${3}-${8} @N indexes per chunk when entries share topic, goal, or cause→resolution; singleton chunks only when one entry is truly isolated.`,
126
+ `Every substantive @N must appear in exactly one chunk; omit only entries with zero standalone memory value — excessive omissions are invalid.`,
126
127
  `Group by coherent topic, keep cause and resolution together, and never merge across [sess:] markers.`,
127
128
  `Category must be one of rule / constraint / decision / fact / goal / preference / task / issue; choose the one that best preserves future recall intent.`,
128
129
  `Keep summary compact and source-grounded; preserve decisive identifiers, constraints, causes, and outcomes when present.`,
@@ -164,7 +165,92 @@ export function parseCycle1LineFormat(raw) {
164
165
  return chunks.length > 0 ? { chunks } : null
165
166
  }
166
167
 
168
+ // Compactness guard thresholds (post-parse); retry LLM once when exceeded on non-trivial windows.
169
+ const CYCLE1_TARGET_CHUNK_MIN = 3
170
+ const CYCLE1_TARGET_CHUNK_MAX = 8
171
+ const CYCLE1_MAX_SINGLETON_RATIO = 0.6
172
+ const CYCLE1_MAX_OMITTED_RATIO = 0.35
173
+ const CYCLE1_MIN_ROWS_FOR_COMPACTNESS_RETRY = 6
174
+
167
175
  // Partition by session_id; MIN_BATCH gates total pending rows, SESSION_CAP bounds per-tick session fan-out.
176
+ function computeCycle1GroupingQuality(chunks, rowCount) {
177
+ const rows = Math.max(0, Number(rowCount) || 0)
178
+ const list = Array.isArray(chunks) ? chunks : []
179
+ const usedIdx = new Set()
180
+ const referenced = new Set()
181
+ let singletonCount = 0
182
+ let chunkCount = 0
183
+ for (const chunk of list) {
184
+ const raw = Array.isArray(chunk?._idxList) ? chunk._idxList.map(n => Number(n)) : []
185
+ if (raw.some(n => !Number.isFinite(n) || n <= 0 || n > rows)) continue
186
+ if (raw.length !== new Set(raw).size) continue
187
+ if (raw.some(n => usedIdx.has(n))) continue
188
+ chunkCount += 1
189
+ if (raw.length === 1) singletonCount += 1
190
+ for (const n of raw) {
191
+ usedIdx.add(n)
192
+ referenced.add(n)
193
+ }
194
+ }
195
+ const omittedCount = Math.max(0, rows - referenced.size)
196
+ const singleton_ratio = chunkCount > 0 ? singletonCount / chunkCount : 0
197
+ const omitted_ratio = rows > 0 ? omittedCount / rows : 0
198
+ return {
199
+ chunkCount,
200
+ singletonCount,
201
+ omittedCount,
202
+ referencedCount: referenced.size,
203
+ singleton_ratio,
204
+ omitted_ratio,
205
+ }
206
+ }
207
+
208
+ function countCycle1CommittableChunks(chunkList, entryByIdx, entryById) {
209
+ const usedIds = new Set()
210
+ let count = 0
211
+ for (const chunk of chunkList) {
212
+ const idxList = chunk._idxList.map(n => Number(n))
213
+ const outOfRange = idxList.filter(n => !entryByIdx.has(n))
214
+ if (outOfRange.length > 0) continue
215
+ const rawIds = idxList.map(n => Number(entryByIdx.get(n).id))
216
+ const dupeWithin = rawIds.length !== new Set(rawIds).size
217
+ const externalIds = rawIds.filter(n => !Number.isFinite(n) || !entryById.has(n))
218
+ const reusedIds = rawIds.filter(n => usedIds.has(n))
219
+ const memberIds = rawIds.filter(n => Number.isFinite(n) && entryById.has(n) && !usedIds.has(n))
220
+ const element = String(chunk?.element ?? '').trim()
221
+ const category = String(chunk?.category ?? '').trim().toLowerCase()
222
+ const summary = String(chunk?.summary ?? '').trim()
223
+ if (dupeWithin || externalIds.length > 0 || reusedIds.length > 0) continue
224
+ if (memberIds.length === 0 || !element || !summary || !VALID_CATEGORIES.has(category)) continue
225
+ if (_isStructurallyInvalidSummary(summary)) continue
226
+ const members = memberIds.map(id => entryById.get(id))
227
+ if (selectRootId(members) === null) continue
228
+ for (const mid of memberIds) usedIds.add(mid)
229
+ count += 1
230
+ }
231
+ return count
232
+ }
233
+
234
+ function cycle1GroupingQualityScore(metrics) {
235
+ return 1 - (metrics.omitted_ratio * 1.5 + metrics.singleton_ratio * 0.75)
236
+ }
237
+
238
+ function shouldRetryCycle1Grouping(metrics, rowCount) {
239
+ if (rowCount < CYCLE1_MIN_ROWS_FOR_COMPACTNESS_RETRY) return false
240
+ if (!metrics || metrics.chunkCount === 0) return false
241
+ return metrics.singleton_ratio > CYCLE1_MAX_SINGLETON_RATIO
242
+ || metrics.omitted_ratio > CYCLE1_MAX_OMITTED_RATIO
243
+ }
244
+
245
+ function buildCycle1CorrectiveRules(metrics) {
246
+ return [
247
+ `CORRECTION: prior grouping was too fragmented (singleton_ratio=${metrics.singleton_ratio.toFixed(2)}, omitted_ratio=${metrics.omitted_ratio.toFixed(2)}).`,
248
+ `Merge related @N into chunks of about ${CYCLE1_TARGET_CHUNK_MIN}-${CYCLE1_TARGET_CHUNK_MAX} entries when they share topic, goal, or causal chain.`,
249
+ `Use singleton chunks only when one entry is truly isolated; otherwise combine neighbors.`,
250
+ `Reference every substantive @N; minimize omissions.`,
251
+ ]
252
+ }
253
+
168
254
  const CYCLE1_MIN_BATCH = 3
169
255
  const CYCLE1_SESSION_CAP = 10
170
256
 
@@ -513,13 +599,11 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
513
599
  }
514
600
  }
515
601
 
516
- const userMessage = buildCycle1ChunkPrompt(rows)
517
602
  const llmCall = typeof options?.callLlm === 'function' ? options.callLlm : callAgentDispatch
518
603
 
519
- let raw
520
- const _tLlm = Date.now()
521
- try {
522
- raw = await llmCall({
604
+ async function callCycle1Grouping(userMessage) {
605
+ const _tLlm = Date.now()
606
+ const raw = await llmCall({
523
607
  agent: 'cycle1-agent',
524
608
  taskType: 'maintenance',
525
609
  mode: 'cycle1',
@@ -528,8 +612,64 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
528
612
  // Pin cwd to null so every memory cycle call hits the same agent cache shard.
529
613
  cwd: null,
530
614
  }, userMessage)
615
+ throwIfAborted(signal)
616
+ __mixdogMemoryLog(`[cycle1-time] window=${windowIdx} llmMs=${Date.now() - _tLlm}\n`)
617
+ const parsed = parseCycle1LineFormat(raw)
618
+ const chunks = Array.isArray(parsed?.chunks) ? parsed.chunks : null
619
+ return { raw, chunks }
620
+ }
621
+
622
+ let groupingAttempt = 1
623
+ let groupingRetried = false
624
+ let chosenQuality = null
625
+ let firstChunkList = null
626
+ let secondChunkList = null
627
+ let chunkList
628
+ try {
629
+ const first = await callCycle1Grouping(buildCycle1ChunkPrompt(rows))
630
+ if (!first.chunks) {
631
+ __mixdogMemoryLog(`[cycle1] unparseable response (window=${windowIdx}) (${String(first.raw).slice(0, 200)})\n`)
632
+ return {
633
+ committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
634
+ invalidChunks: [{ reason: 'unparseable_response', member_ids: rows.map(r => Number(r.id)) }],
635
+ failedRowIds: rows.map(r => Number(r.id)),
636
+ omittedRowIds: prefilteredRowIds,
637
+ prefilteredRowIds,
638
+ prefilterMarked,
639
+ prefilterMarkFailed,
640
+ }
641
+ }
642
+ firstChunkList = first.chunks
643
+ chunkList = firstChunkList
644
+ chosenQuality = computeCycle1GroupingQuality(chunkList, rows.length)
645
+
646
+ if (shouldRetryCycle1Grouping(chosenQuality, rows.length)) {
647
+ groupingRetried = true
648
+ const correctiveRules = [
649
+ ...DEFAULT_CYCLE1_RULES,
650
+ '',
651
+ ...buildCycle1CorrectiveRules(chosenQuality),
652
+ ]
653
+ try {
654
+ const second = await callCycle1Grouping(buildCycle1ChunkPrompt(rows, correctiveRules))
655
+ if (second.chunks) {
656
+ secondChunkList = second.chunks
657
+ const secondQuality = computeCycle1GroupingQuality(second.chunks, rows.length)
658
+ const scoreFirst = cycle1GroupingQualityScore(chosenQuality)
659
+ const scoreSecond = cycle1GroupingQualityScore(secondQuality)
660
+ if (scoreSecond > scoreFirst) {
661
+ chunkList = second.chunks
662
+ chosenQuality = secondQuality
663
+ groupingAttempt = 2
664
+ }
665
+ }
666
+ } catch (retryErr) {
667
+ if (signal?.aborted) throw retryErr.reason ?? retryErr
668
+ __mixdogMemoryLog(`[cycle1] compactness retry LLM error (window=${windowIdx}): ${retryErr.message}\n`)
669
+ }
670
+ }
531
671
  } catch (err) {
532
- if (signal?.aborted) throw signal.reason ?? err
672
+ if (signal?.aborted) throw err.reason ?? err
533
673
  __mixdogMemoryLog(`[cycle1] LLM error (window=${windowIdx}): ${err.message}\n`)
534
674
  return {
535
675
  committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
@@ -541,26 +681,29 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
541
681
  prefilterMarkFailed,
542
682
  }
543
683
  }
544
- throwIfAborted(signal)
545
- __mixdogMemoryLog(`[cycle1-time] window=${windowIdx} llmMs=${Date.now() - _tLlm}\n`)
546
684
 
547
- const parsed = parseCycle1LineFormat(raw)
548
- const chunkList = Array.isArray(parsed?.chunks) ? parsed.chunks : null
549
- if (!chunkList) {
550
- __mixdogMemoryLog(`[cycle1] unparseable response (window=${windowIdx}) (${String(raw).slice(0, 200)})\n`)
551
- return {
552
- committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
553
- invalidChunks: [{ reason: 'unparseable_response', member_ids: rows.map(r => Number(r.id)) }],
554
- failedRowIds: rows.map(r => Number(r.id)),
555
- omittedRowIds: prefilteredRowIds,
556
- prefilteredRowIds,
557
- prefilterMarked,
558
- prefilterMarkFailed,
685
+ const entryByIdx = new Map(rows.map((r, i) => [i + 1, r]))
686
+ const entryById = new Map(rows.map(r => [Number(r.id), r]))
687
+
688
+ if (countCycle1CommittableChunks(chunkList, entryByIdx, entryById) === 0) {
689
+ const fallback = groupingAttempt === 2 ? firstChunkList : secondChunkList
690
+ if (fallback && countCycle1CommittableChunks(fallback, entryByIdx, entryById) > 0) {
691
+ chunkList = fallback
692
+ groupingAttempt = groupingAttempt === 2 ? 1 : 2
693
+ chosenQuality = computeCycle1GroupingQuality(chunkList, rows.length)
694
+ __mixdogMemoryLog(
695
+ `[cycle1] grouping_fallback window=${windowIdx} chosen_attempt=${groupingAttempt}\n`,
696
+ )
559
697
  }
560
698
  }
561
699
 
562
- const entryByIdx = new Map(rows.map((r, i) => [i + 1, r]))
563
- const entryById = new Map(rows.map(r => [Number(r.id), r]))
700
+ __mixdogMemoryLog(
701
+ `[cycle1] grouping_quality window=${windowIdx} prompt_entries=${rows.length}` +
702
+ ` chunks=${chosenQuality.chunkCount} singleton_ratio=${chosenQuality.singleton_ratio.toFixed(3)}` +
703
+ ` omitted_ratio=${chosenQuality.omitted_ratio.toFixed(3)}` +
704
+ ` retry=${groupingRetried ? 1 : 0} chosen_attempt=${groupingAttempt}\n`,
705
+ )
706
+
564
707
  const usedIds = new Set()
565
708
  const committedRowIds = new Set()
566
709
  let committedChunks = 0
@@ -2,7 +2,7 @@
2
2
  // pipe-verdict parsing/validation, the rules-digest cache, the single LLM
3
3
  // gate pass (runUnifiedGate) and the Sonnet re-judge cascade. Facade
4
4
  // (memory-cycle2.mjs) re-exports the public members unchanged.
5
- import { existsSync, readFileSync } from 'fs'
5
+ import { existsSync, readFileSync, readdirSync } from 'fs'
6
6
  import { join } from 'path'
7
7
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
8
8
  import { callAgentDispatch } from './agent-ipc.mjs'
@@ -204,13 +204,20 @@ let _currentRulesDigestTs = 0
204
204
  export function loadCurrentRulesDigest() {
205
205
  const now = Date.now()
206
206
  if (_currentRulesDigest && now - _currentRulesDigestTs < 60_000) return _currentRulesDigest
207
- const sources = [
208
- join(resourceDir(), 'rules', 'shared', '01-general.md'),
209
- join(resourceDir(), 'rules', 'shared', '01-tool.md'),
210
- join(resourceDir(), 'rules', 'shared', '04-memory.md'),
211
- join(resourceDir(), 'rules', 'shared', '06-team.md'),
212
- join(resourceDir(), 'rules', 'shared', '07-workflow.md'),
213
- ]
207
+ // Collect every rule file that loads into live sessions (lead + shared).
208
+ // Discovered dynamically so rule-layout refactors can't silently empty the
209
+ // digest again (the old hardcoded shared/* list rotted to one file and the
210
+ // dedup gate compared against nothing).
211
+ const sources = []
212
+ for (const dir of ['lead', 'shared']) {
213
+ const base = join(resourceDir(), 'rules', dir)
214
+ try {
215
+ if (!existsSync(base)) continue
216
+ for (const f of readdirSync(base).sort()) {
217
+ if (f.endsWith('.md')) sources.push(join(base, f))
218
+ }
219
+ } catch {}
220
+ }
214
221
  const parts = []
215
222
  for (const p of sources) {
216
223
  try {
@@ -9,6 +9,42 @@ const TIER1_THRESHOLD = 0.78
9
9
  const TIER2_LOW = 0.65
10
10
  const LLM_JUDGE_CAP = 20
11
11
 
12
+ const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
13
+
14
+ // After the gate, cap how many pending→active promotions may land in one batch.
15
+ // Overflow stays pending (not archived). Tiebreak: durable category before
16
+ // task/issue, then score DESC, then older last_seen_at, then id ASC.
17
+ export function clampPendingPromotions(statusBatch, rowsById, activeCount, activeTargetCap) {
18
+ if (!statusBatch?.length) return { batch: statusBatch ?? [], clamped: 0 }
19
+ const archives = []
20
+ const promotions = []
21
+ for (const item of statusBatch) {
22
+ if (item.was_pending && item.new_status === 'active') promotions.push(item)
23
+ else archives.push(item)
24
+ }
25
+ const slots = Math.max(0, Number(activeTargetCap) - Number(activeCount))
26
+ if (promotions.length <= slots) return { batch: statusBatch, clamped: 0 }
27
+
28
+ promotions.sort((a, b) => {
29
+ const ra = rowsById.get(Number(a.entry_id))
30
+ const rb = rowsById.get(Number(b.entry_id))
31
+ const ta = TRANSIENT_PROMOTE_CATEGORIES.has(String(ra?.category ?? '').toLowerCase()) ? 1 : 0
32
+ const tb = TRANSIENT_PROMOTE_CATEGORIES.has(String(rb?.category ?? '').toLowerCase()) ? 1 : 0
33
+ if (ta !== tb) return ta - tb
34
+ const sa = Number(ra?.score ?? 0)
35
+ const sb = Number(rb?.score ?? 0)
36
+ if (sb !== sa) return sb - sa
37
+ const la = Number(ra?.last_seen_at ?? 0)
38
+ const lb = Number(rb?.last_seen_at ?? 0)
39
+ if (la !== lb) return la - lb
40
+ return Number(a.entry_id) - Number(b.entry_id)
41
+ })
42
+
43
+ const allowed = promotions.slice(0, slots)
44
+ const clamped = promotions.length - allowed.length
45
+ return { batch: [...archives, ...allowed], clamped }
46
+ }
47
+
12
48
  // Batch CTE UPDATE for status-only verdicts (active/archived from pending or active rows).
13
49
  // Trigger handles score recompute automatically — no app-side score writes.
14
50
  export async function applyBatchStatusVerdicts(db, batch, nowMs) {
@@ -268,6 +304,7 @@ export async function runPhaseMerge(db, options = {}) {
268
304
  1 - (e.embedding <=> inner_c.embedding)::float8 AS sim
269
305
  FROM core_entries inner_c
270
306
  WHERE inner_c.embedding IS NOT NULL
307
+ AND (inner_c.status IS NULL OR inner_c.status = 'active')
271
308
  AND (inner_c.project_id IS NULL OR inner_c.project_id IS NOT DISTINCT FROM e.project_id)
272
309
  ORDER BY
273
310
  CASE WHEN inner_c.project_id IS NOT DISTINCT FROM e.project_id THEN 0 ELSE 1 END,
@@ -10,7 +10,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
10
10
  import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
11
11
  import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
12
12
  import {
13
- applyBatchStatusVerdicts, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
13
+ applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
14
14
  } from './memory-cycle2-mutations.mjs'
15
15
  import {
16
16
  CYCLE2_ACTIVE_TARGET_CAP, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
@@ -82,6 +82,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
82
82
  promoted: 0, archived: 0, merged: 0, updated: 0, kept: 0, rejected_verb: 0,
83
83
  merge_rejected: 0,
84
84
  missing_core_summary: 0,
85
+ promotion_clamped: 0,
85
86
  core_embedding_backfill: 0,
86
87
  rescore: { updated: 0 },
87
88
  phase_merge: { merged: 0, llm_calls: 0, tier1_pairs: 0, tier2_pairs: 0, core_overlap: 0 },
@@ -255,7 +256,10 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
255
256
  WHERE is_root = 1
256
257
  AND (status = 'pending' OR ($2::boolean AND status = 'active'))
257
258
  ORDER BY
258
- CASE status WHEN 'pending' THEN 0 WHEN 'active' THEN 1 END ASC,
259
+ CASE WHEN $2::boolean THEN CASE status WHEN 'active' THEN 0 WHEN 'pending' THEN 1 END
260
+ ELSE CASE status WHEN 'pending' THEN 0 WHEN 'active' THEN 1 END
261
+ END ASC,
262
+ CASE WHEN NOT $2::boolean AND LOWER(category) IN ('task', 'issue') THEN 1 ELSE 0 END ASC,
259
263
  reviewed_at ASC NULLS FIRST,
260
264
  error_count ASC,
261
265
  score ${scoreDir},
@@ -472,7 +476,14 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
472
476
  // Status verdicts are applied as one SQL batch; checkpoint before the
473
477
  // batch and then again at the next cycle2 unit boundary.
474
478
  throwIfAborted(signal)
475
- const batchRes = await applyBatchStatusVerdicts(db, statusBatch, nowMs)
479
+ let activeDemotionsInBatch = 0
480
+ for (const item of statusBatch) {
481
+ if (item.new_status === 'archived' && !item.was_pending) activeDemotionsInBatch += 1
482
+ }
483
+ const activeCountForClamp = Math.max(0, activeCount - activeDemotionsInBatch)
484
+ const clampRes = clampPendingPromotions(statusBatch, rowsById, activeCountForClamp, activeTargetCap)
485
+ stats.promotion_clamped = clampRes.clamped
486
+ const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
476
487
  stats.promoted += batchRes.promoted
477
488
  stats.archived += batchRes.archived
478
489
  }
@@ -567,10 +578,8 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
567
578
  }
568
579
  }
569
580
 
570
- // Active-cap enforcement is delegated to the gate (phases 1-3): the prompt
571
- // exposes Active/cap counts and instructs aggressive `archived` verdicts on
572
- // overflow. No deterministic safety net here — if the gate ever fails to
573
- // contain growth, fix the prompt, not bolt a fallback back on.
581
+ // Post-gate promotion clamp (clampPendingPromotions) is the deterministic
582
+ // active-cap safety net; the gate still contracts when Active > cap.
574
583
 
575
584
  // Chronic gate-failure sweep: pending roots that have failed the gate 5+
576
585
  // times AND are 30+ days old will realistically never pass (their content
@@ -604,6 +613,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
604
613
  ` core_backfill=${stats.core_embedding_backfill}` +
605
614
  ` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
606
615
  ` | gate promoted=${stats.promoted} archived=${stats.archived}` +
616
+ ` promotion_clamped=${stats.promotion_clamped}` +
607
617
  ` updated=${stats.updated} kept=${stats.kept}` +
608
618
  ` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
609
619
  ` missing_core=${stats.missing_core_summary}` +
@@ -24,7 +24,7 @@ import { join } from 'path'
24
24
  import { fileURLToPath } from 'url'
25
25
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
26
26
  import { callAgentDispatch } from './agent-ipc.mjs'
27
- import { listCore, editCore, deleteCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
27
+ import { listCore, editCore, deleteCore, archiveCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
28
28
  import { loadCurrentRulesDigest } from './memory-cycle2.mjs'
29
29
  import { embedText } from './embedding-provider.mjs'
30
30
  import { searchRelevantHybrid } from './memory-recall-store.mjs'
@@ -207,6 +207,16 @@ function parseVerdicts(raw, idSet) {
207
207
  actions.push({ id, verb: 'merge', targetId, sourceIds })
208
208
  } else if (verb === 'delete') {
209
209
  actions.push({ id, verb: 'delete' })
210
+ } else if (verb === 'superseded' || verb === 'supersede') {
211
+ // Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
212
+ // newer active core id the supersession has no evidence → drop to keep.
213
+ const newerId = Number((parts[2] ?? '').trim())
214
+ if (!Number.isFinite(newerId) || !idSet.has(newerId) || newerId === id) {
215
+ __mixdogMemoryLog(`[cycle3] superseded rejected: id=${id} invalid/missing newer_id=${parts[2] ?? ''} → keep\n`)
216
+ actions.push({ id, verb: 'keep' })
217
+ continue
218
+ }
219
+ actions.push({ id, verb: 'superseded', newerId })
210
220
  }
211
221
  }
212
222
  if (!sawValid) return null
@@ -481,9 +491,66 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
481
491
  if (!actionIds.has(id)) parsed.actions.push({ id, verb: 'keep' })
482
492
  }
483
493
 
484
- let kept = 0, updated = 0, merged = 0, deleted = 0
485
- const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0 }
486
- const held = { updated: 0, merged: 0, deleted: 0 }
494
+ // Supersession target liveness: a `superseded` verdict points at a newerId
495
+ // that must remain LIVE after this batch. If newerId is itself superseded/
496
+ // delete/merged-away in the same batch, archiving against it retires a row
497
+ // against a non-live replacement. Resolve the newerId transitively through
498
+ // chained supersessions to the final live target; if that target is retired
499
+ // (deleted/merged-away or a supersession cycle/dead-end) → downgrade to keep.
500
+ {
501
+ const byId = new Map(parsed.actions.map(a => [Number(a.id), a]))
502
+ // Two-phase, cycle-safe. Phase 1 computes every resolution against a FROZEN
503
+ // snapshot of the original verbs/newerIds — the walk never observes another
504
+ // action's in-progress mutation, so an A→B, B→A mutual cycle can't have A
505
+ // downgrade first and then let B see A as live. Any id that enters a
506
+ // supersession cycle yields null (no live target) for ALL its members.
507
+ // Phase 2 applies the computed resolutions in one pass.
508
+ const snap = new Map(parsed.actions.map(a => [Number(a.id), {
509
+ verb: a.verb,
510
+ newerId: a.newerId != null ? Number(a.newerId) : null,
511
+ id: Number(a.id),
512
+ sourceIds: a.sourceIds,
513
+ targetId: a.targetId,
514
+ }]))
515
+ // ids removed from the live pool by their own verdict (not superseded —
516
+ // that is chased transitively below).
517
+ const retiredBySnap = (s) => s && (s.verb === 'delete' ||
518
+ (s.verb === 'merge' && s.sourceIds?.includes(s.id) && s.targetId !== s.id))
519
+ const resolveLiveTarget = (startId) => {
520
+ let cur = Number(startId)
521
+ const seen = new Set()
522
+ while (true) {
523
+ if (seen.has(cur)) return null // supersession cycle → no live target
524
+ seen.add(cur)
525
+ const s = snap.get(cur)
526
+ if (!s) return null
527
+ if (retiredBySnap(s)) return null // target itself removed this batch
528
+ if (s.verb === 'superseded') { cur = Number(s.newerId); continue }
529
+ return cur // keep/update/merge-survivor → live
530
+ }
531
+ }
532
+ // Phase 1: resolve against frozen snapshot.
533
+ const resolutions = []
534
+ for (const a of parsed.actions) {
535
+ if (a.verb !== 'superseded') continue
536
+ resolutions.push({ action: a, live: resolveLiveTarget(a.newerId) })
537
+ }
538
+ // Phase 2: apply.
539
+ for (const { action: a, live } of resolutions) {
540
+ if (live == null) {
541
+ __mixdogMemoryLog(`[cycle3] superseded downgraded to keep: id=${a.id} newerId=${a.newerId} not live after batch\n`)
542
+ a.verb = 'keep'
543
+ delete a.newerId
544
+ } else if (live !== Number(a.newerId)) {
545
+ __mixdogMemoryLog(`[cycle3] superseded newerId resolved transitively: id=${a.id} ${a.newerId}->${live}\n`)
546
+ a.newerId = live
547
+ }
548
+ }
549
+ }
550
+
551
+ let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0
552
+ const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0 }
553
+ const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
487
554
  const details = []
488
555
  const touched = new Set() // ids already acted on this cycle — avoid double action
489
556
 
@@ -550,6 +617,38 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
550
617
  }
551
618
  continue
552
619
  }
620
+ if (a.verb === 'superseded') {
621
+ proposed.superseded++
622
+ // Supersession archives (status flip), never physical DELETE, and applies
623
+ // in DEFAULT (conservative) mode — reversible and audit-retained, so it is
624
+ // safe without the confirmed gate that outright deletes require.
625
+ if (!mutate) {
626
+ held.superseded++
627
+ details.push({
628
+ id: a.id, verb: 'superseded', applied: false, held: true,
629
+ reason: 'proposal mode',
630
+ })
631
+ touched.add(a.id)
632
+ continue
633
+ }
634
+ try {
635
+ const core = coreById.get(a.id)
636
+ const res = await archiveCore(dataDir, a.id, core ? { element: core.element, summary: core.summary } : null)
637
+ if (res?.skipped) {
638
+ held.superseded++
639
+ details.push({ id: a.id, verb: 'superseded', newerId: a.newerId, applied: false, held: true, reason: res.reason })
640
+ } else {
641
+ superseded++
642
+ details.push({ id: a.id, verb: 'superseded', newerId: a.newerId, applied: true })
643
+ }
644
+ touched.add(a.id)
645
+ } catch (err) {
646
+ if (signal?.aborted) throw signal.reason ?? err
647
+ __mixdogMemoryLog(`[cycle3] supersede archive failed id=${a.id}: ${err.message}\n`)
648
+ details.push({ id: a.id, verb: 'superseded', error: err.message })
649
+ }
650
+ continue
651
+ }
553
652
  if (a.verb === 'merge') {
554
653
  if (a.targetId !== a.id && !a.sourceIds.includes(a.id)) {
555
654
  __mixdogMemoryLog(
@@ -638,9 +737,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
638
737
  `[cycle3] reviewed=${cores.length} kept=${kept}` +
639
738
  ` proposed_update=${proposed.updated} proposed_merge=${proposed.merged} proposed_delete=${proposed.deleted}` +
640
739
  ` applied_update=${updated} applied_merge=${merged} applied_delete=${deleted}` +
641
- ` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted}` +
740
+ ` applied_superseded=${superseded}` +
741
+ ` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted} held_superseded=${held.superseded}` +
642
742
  ` mode=${applyMode}\n`,
643
743
  )
644
744
 
645
- return { reviewed: cores.length, kept, updated, merged, deleted, proposed, held, applied: mutate, applyMode, details }
745
+ return { reviewed: cores.length, kept, updated, merged, deleted, superseded, proposed, held, applied: mutate, applyMode, details }
646
746
  }
@@ -455,6 +455,11 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
455
455
  // re-nominates the same entry.
456
456
  await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_candidate_status text`)
457
457
  await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_candidate_at bigint`)
458
+ // cycle3 supersession retirement: archive (status flip) instead of physical
459
+ // DELETE for facts a newer active fact replaced. Additive nullable columns —
460
+ // legacy rows read as NULL status = active. No data migration required.
461
+ await db.exec(`ALTER TABLE core_entries ADD COLUMN IF NOT EXISTS status text`)
462
+ await db.exec(`ALTER TABLE core_entries ADD COLUMN IF NOT EXISTS archived_at bigint`)
458
463
  // No index on core_candidate_status by design (round-2 finding #4): the only
459
464
  // readers are listCoreCandidates (user picker, on-demand) and
460
465
  // nominateCoreCandidates (once per cycle2, hourly). Both are rare and the
@@ -470,7 +475,8 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
470
475
  SELECT id,
471
476
  row_number() OVER (
472
477
  PARTITION BY project_id, element
473
- ORDER BY updated_at DESC NULLS LAST, id DESC
478
+ ORDER BY (CASE WHEN status IS NULL OR status = 'active' THEN 0 ELSE 1 END),
479
+ updated_at DESC NULLS LAST, id DESC
474
480
  ) AS rn
475
481
  FROM core_entries
476
482
  ), deleted AS (
@@ -241,6 +241,7 @@ export function createQueryHandlers({
241
241
  (${hitExpr}) AS hit_count
242
242
  FROM core_entries
243
243
  WHERE ${where.join(' AND ')}
244
+ AND (status IS NULL OR status = 'active')
244
245
  ORDER BY hit_count DESC, updated_at DESC, id ASC
245
246
  LIMIT $${params.length}
246
247
  `, params)).rows