mixdog 0.9.13 → 0.9.15
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 +1 -1
- package/src/defaults/cycle3-review-prompt.md +14 -0
- package/src/defaults/memory-promote-prompt.md +9 -9
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -1
- package/src/runtime/memory/index.mjs +7 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
- package/src/runtime/memory/lib/cycle-scheduler.mjs +46 -14
- package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +15 -8
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
- package/src/runtime/memory/lib/memory.mjs +7 -1
- package/src/runtime/memory/lib/query-handlers.mjs +1 -0
- package/src/tui/dist/index.mjs +283 -28
- package/src/tui/engine/tui-steering-persist.mjs +175 -0
- package/src/tui/engine.mjs +137 -4
- package/src/ui/statusline.mjs +2 -4
|
@@ -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
|
-
`
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
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
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
//
|
|
571
|
-
//
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|