mixdog 0.9.14 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench/cache-probe-tasks.json +1 -1
  3. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  4. package/scripts/bench/r4-mixed-tasks.json +1 -1
  5. package/scripts/bench/review-tasks.json +1 -1
  6. package/scripts/build-runtime-windows.ps1 +242 -242
  7. package/scripts/explore-bench.mjs +5 -4
  8. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  9. package/scripts/output-style-smoke.mjs +3 -3
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/termio-input-smoke.mjs +199 -0
  13. package/scripts/tool-efficiency-diag.mjs +88 -0
  14. package/scripts/tool-smoke.mjs +40 -24
  15. package/scripts/tui-frame-harness-shim.mjs +32 -0
  16. package/scripts/tui-frame-harness.mjs +306 -0
  17. package/src/agents/heavy-worker/AGENT.md +6 -7
  18. package/src/agents/worker/AGENT.md +6 -7
  19. package/src/lib/keychain-cjs.cjs +28 -11
  20. package/src/lib/rules-builder.cjs +6 -10
  21. package/src/mixdog-session-runtime.mjs +90 -20
  22. package/src/output-styles/simple.md +22 -24
  23. package/src/rules/agent/00-core.md +12 -11
  24. package/src/rules/agent/30-explorer.md +22 -21
  25. package/src/rules/lead/01-general.md +8 -8
  26. package/src/rules/lead/02-channels.md +3 -3
  27. package/src/rules/lead/lead-brief.md +11 -12
  28. package/src/rules/shared/01-tool.md +25 -14
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
  33. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  34. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  40. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  41. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  44. package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
  45. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  46. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  47. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  48. package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  50. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  51. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  54. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
  56. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  74. package/src/runtime/memory/index.mjs +95 -8
  75. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  76. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  77. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  78. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  79. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  80. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  81. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  82. package/src/runtime/memory/lib/memory.mjs +88 -1
  83. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  84. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  86. package/src/runtime/shared/child-guardian.mjs +4 -2
  87. package/src/runtime/shared/open-url.mjs +2 -1
  88. package/src/runtime/shared/singleton-owner.mjs +26 -0
  89. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  90. package/src/runtime/shared/transcript-writer.mjs +3 -1
  91. package/src/session-runtime/config-helpers.mjs +7 -2
  92. package/src/session-runtime/cwd-plugins.mjs +6 -1
  93. package/src/session-runtime/effort.mjs +6 -1
  94. package/src/session-runtime/plugin-mcp.mjs +116 -12
  95. package/src/session-runtime/settings-api.mjs +7 -1
  96. package/src/standalone/channel-worker.mjs +25 -3
  97. package/src/standalone/explore-tool.mjs +5 -5
  98. package/src/standalone/hook-bus/config.mjs +38 -2
  99. package/src/standalone/hook-bus/handlers.mjs +103 -11
  100. package/src/standalone/hook-bus.mjs +20 -6
  101. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  102. package/src/tui/App.jsx +214 -30
  103. package/src/tui/app/core-memory-picker.mjs +6 -6
  104. package/src/tui/app/model-options.mjs +10 -4
  105. package/src/tui/app/settings-picker.mjs +5 -30
  106. package/src/tui/app/slash-commands.mjs +0 -1
  107. package/src/tui/app/slash-dispatch.mjs +0 -16
  108. package/src/tui/app/text-layout.mjs +57 -0
  109. package/src/tui/app/transcript-window.mjs +53 -2
  110. package/src/tui/app/use-mouse-input.mjs +60 -47
  111. package/src/tui/app/use-transcript-window.mjs +38 -10
  112. package/src/tui/components/PromptInput.jsx +18 -1
  113. package/src/tui/components/TextEntryPanel.jsx +97 -33
  114. package/src/tui/dist/index.mjs +567 -229
  115. package/src/tui/engine/tool-card-results.mjs +22 -5
  116. package/src/tui/engine.mjs +126 -7
  117. package/src/tui/index.jsx +4 -1
  118. package/src/workflows/default/WORKFLOW.md +27 -31
  119. package/vendor/ink/build/components/App.js +62 -17
  120. package/vendor/ink/build/ink.js +78 -3
  121. package/vendor/ink/build/input-parser.d.ts +9 -4
  122. package/vendor/ink/build/input-parser.js +45 -176
  123. package/vendor/ink/build/log-update.js +47 -2
  124. package/vendor/ink/build/termio-keypress.js +240 -0
  125. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -65,6 +65,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
65
65
  const signal = options?.signal
66
66
  throwIfAborted(signal)
67
67
  const coalescedRetry = options?.coalescedRetry === true
68
+ const catchUpDrainPass = options?.catchUpDrainPass === true
68
69
  const retryAttempt = Math.max(0, Number(options?.coalescedRetryAttempt || 0))
69
70
  const maxRetries = resolveCoalesceMaxRetries(config, 3)
70
71
  const requestSignature = makeCycleRequestSignature('cycle2', config, {
@@ -74,7 +75,13 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
74
75
  const scheduleRetry = () => scheduleCoalescedCycleRetry(
75
76
  db,
76
77
  'cycle2',
77
- () => runCycle2(db, config, { ...options, signal: undefined, coalescedRetry: true, coalescedRetryAttempt: retryAttempt + 1 }, dataDir),
78
+ () => runCycle2(db, config, {
79
+ ...options,
80
+ signal: undefined,
81
+ catchUpDrainPass: false,
82
+ coalescedRetry: true,
83
+ coalescedRetryAttempt: retryAttempt + 1,
84
+ }, dataDir),
78
85
  config,
79
86
  requestSignature,
80
87
  )
@@ -119,12 +126,15 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
119
126
  let result = null
120
127
  let coalescedRuns = 0
121
128
  let coalescedRequests = 0
122
- if (coalescedRetry) {
129
+ if (coalescedRetry && !catchUpDrainPass) {
123
130
  const pending = await consumeCycleRequests(db, 'cycle2', requestSignature)
124
131
  if (pending <= 0) return { ok: true, ...partial, skippedInFlight: false, coalescedRetryNoop: true }
125
132
  coalescedRuns += 1
126
133
  coalescedRequests += pending
127
134
  __mixdogMemoryLog(`[cycle2] retrying coalesced requests=${pending}\n`)
135
+ } else if (coalescedRetry && catchUpDrainPass) {
136
+ coalescedRuns += 1
137
+ __mixdogMemoryLog('[cycle2] catch-up drain pass (direct)\n')
128
138
  }
129
139
  try {
130
140
  result = await _runCycle2Impl(db, config, options, dataDir)
@@ -135,7 +145,7 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
135
145
  }
136
146
  throw err
137
147
  }
138
- const maxDrains = resolveCoalesceMaxDrains(config, 1)
148
+ const maxDrains = catchUpDrainPass ? 0 : resolveCoalesceMaxDrains(config, 1)
139
149
  let drainLoops = 0
140
150
  while (drainLoops < maxDrains) {
141
151
  throwIfAborted(signal)
@@ -251,7 +261,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
251
261
  // below. Cleanup of duplicates/stale user-core overlap also runs via
252
262
  // phase_merge / cycle3.
253
263
  const rowsRes = await db.query(`
254
- SELECT id, element, category, summary, score, last_seen_at, project_id, status
264
+ SELECT id, element, category, summary, score, last_seen_at, project_id, status, reviewed_at
255
265
  FROM entries
256
266
  WHERE is_root = 1
257
267
  AND (status = 'pending' OR ($2::boolean AND status = 'active'))
@@ -275,7 +285,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
275
285
  if (activeRecheckQuota > 0 && activeCount > 0) {
276
286
  const seen = new Set(rows.map(r => Number(r.id)))
277
287
  const recheckRes = await db.query(`
278
- SELECT id, element, category, summary, score, last_seen_at, project_id, status
288
+ SELECT id, element, category, summary, score, last_seen_at, project_id, status, reviewed_at
279
289
  FROM entries
280
290
  WHERE is_root = 1 AND status = 'active'
281
291
  ORDER BY reviewed_at ASC NULLS FIRST, score ASC, id ASC
@@ -402,17 +412,20 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
402
412
 
403
413
  if (a.action === 'active') {
404
414
  if (row.status === 'pending') {
405
- statusBatch.push({ entry_id: id, new_status: 'active', was_pending: true })
415
+ statusBatch.push({ entry_id: id, new_status: 'active', was_pending: true, expected: { status: row.status, reviewedAt: row.reviewed_at } })
406
416
  } else if (row.status === 'active') {
407
417
  stats.kept += 1
408
418
  }
409
419
  await setCoreSummary(id, explicitCore)
410
420
  accepted = true
411
421
  } else if (a.action === 'archived') {
412
- statusBatch.push({ entry_id: id, new_status: 'archived', was_pending: row.status === 'pending' })
422
+ statusBatch.push({ entry_id: id, new_status: 'archived', was_pending: row.status === 'pending', expected: { status: row.status, reviewedAt: row.reviewed_at } })
413
423
  accepted = true
414
424
  } else if (a.action === 'update') {
415
- if (await applyUpdate(db, id, a.element, a.summary, { signal })) stats.updated += 1
425
+ // Optimistic guard: skip if the row moved since the gate read it
426
+ // (concurrent cycle/recall write). row is the snapshot the verdict
427
+ // was computed against.
428
+ if (await applyUpdate(db, id, a.element, a.summary, { signal, expected: { status: row.status, reviewedAt: row.reviewed_at } })) stats.updated += 1
416
429
  await setCoreSummary(id, explicitCore)
417
430
  accepted = true
418
431
  } else if (a.action === 'merge') {
@@ -14,6 +14,52 @@ import { createHash } from 'crypto'
14
14
  // flag set by a different DB.
15
15
  const _embCacheReady = new WeakSet()
16
16
 
17
+ // Opportunistic retention for embedding_cache. Unbounded growth otherwise:
18
+ // every distinct embedded text leaves a permanent row. Prune by row-count cap
19
+ // (oldest inserted rows first) so the working set of hot vectors survives while
20
+ // the tail is reclaimed. Applied opportunistically after cache writes, rate-
21
+ // limited per db handle so it never runs on the hot path more than once/interval.
22
+ const EMB_CACHE_MAX_ROWS = (() => {
23
+ const n = Number(process.env.MIXDOG_EMBED_CACHE_MAX_ROWS)
24
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 200_000
25
+ })()
26
+ const EMB_CACHE_PRUNE_INTERVAL_MS = 10 * 60_000
27
+ const _embCachePruneAt = new WeakMap() // db → next-allowed prune epoch ms
28
+
29
+ async function maybePruneEmbCache(db) {
30
+ const now = Date.now()
31
+ const next = _embCachePruneAt.get(db) ?? 0
32
+ if (now < next) return
33
+ _embCachePruneAt.set(db, now + EMB_CACHE_PRUNE_INTERVAL_MS)
34
+ try {
35
+ // Only prune when actually over the cap.
36
+ const { rows } = await db.query(`SELECT count(*)::bigint AS n FROM memory.embedding_cache`)
37
+ let over = Number(rows?.[0]?.n ?? 0) - EMB_CACHE_MAX_ROWS
38
+ if (over <= 0) return
39
+ // Delete OLDEST rows first (ctid ascending ≈ insertion age) in bounded
40
+ // batches so a large backlog never takes one long ACCESS-heavy DELETE that
41
+ // locks the table. Each batch caps at PRUNE_BATCH; loop until under cap.
42
+ const PRUNE_BATCH = 5000
43
+ let guard = 0
44
+ while (over > 0 && guard++ < 1000) {
45
+ const del = await db.query(
46
+ `DELETE FROM memory.embedding_cache
47
+ WHERE ctid IN (
48
+ SELECT ctid FROM memory.embedding_cache
49
+ ORDER BY ctid ASC
50
+ LIMIT $1
51
+ )`,
52
+ [Math.min(PRUNE_BATCH, over)],
53
+ )
54
+ const n = Number(del?.rowCount ?? 0)
55
+ if (n === 0) break
56
+ over -= n
57
+ }
58
+ } catch (err) {
59
+ __mixdogMemoryLog(`[embed] cache prune skipped: ${err?.message || err}\n`)
60
+ }
61
+ }
62
+
17
63
  export function inferChunkProjectId(members) {
18
64
  const storedIds = new Set()
19
65
  for (const m of members) {
@@ -103,6 +149,8 @@ export async function cachedEmbedTextBatch(db, texts, options = {}) {
103
149
  throwIfAborted(signal)
104
150
  for (const e of misses) hitMap.set(e.hash.toString('hex'), e.vector)
105
151
  }
152
+ // Opportunistic retention cap (rate-limited per db).
153
+ await maybePruneEmbCache(db)
106
154
 
107
155
  return entries.map(e => hitMap.get(e.hash.toString('hex')) ?? null)
108
156
  }
@@ -15,6 +15,16 @@ import { recallReadQuery } from './memory-recall-read-query.mjs'
15
15
  const _MV_HOT_ACTIVE_TTL_MS = 60_000
16
16
  const _mvHotActiveCache = new WeakMap() // db → { populated: boolean, ts: number }
17
17
  const SEMANTIC_ONLY_MIN_SIM = 0.72
18
+ // Member-hit time gate: a chunk MEMBER whose own ts falls inside the requested
19
+ // [ts_from, ts_to] window is an in-window match even when its ROOT's ts sits
20
+ // outside it. Returns true when the member ts is within the (open-ended) window.
21
+ function memberTsInWindow(row, tsFrom, tsTo) {
22
+ const ts = Number(row?.ts)
23
+ if (!Number.isFinite(ts)) return true // undated member: don't drop on window
24
+ if (tsFrom != null && ts < tsFrom) return false
25
+ if (tsTo != null && ts > tsTo) return false
26
+ return true
27
+ }
18
28
  // Cross-language semantic rescue: a query in one language against rows in
19
29
  // another rarely clears SEMANTIC_ONLY_MIN_SIM on a small (384-dim) embedding
20
30
  // model, yet the TOP dense ranks are still overwhelmingly relevant. Accept
@@ -209,10 +219,10 @@ export async function searchRelevantHybrid(db, query, options = {}) {
209
219
  // buildFilterClause: pushes ts/status/scope filters INTO candidate SELECTs.
210
220
  // offset = 1-based index of the first bind param it may consume.
211
221
  // Returns { clause: string, params: any[] }; clause begins with AND or is ''.
212
- function buildFilterClause(offset) {
222
+ function buildFilterClause(offset, opts = {}) {
213
223
  return buildRecallScopeFilter(offset, {
214
- ts_from: tsFrom,
215
- ts_to: tsTo,
224
+ ts_from: opts.skipTsWindow ? null : tsFrom,
225
+ ts_to: opts.skipTsWindow ? null : tsTo,
216
226
  excludeStatuses,
217
227
  category: categories,
218
228
  projectScope,
@@ -602,6 +612,12 @@ LEFT JOIN exact x ON x.id = c.id`
602
612
  } else if (row.chunk_root != null && row.chunk_root !== row.id) {
603
613
  const r = rootById.get(Number(row.chunk_root))
604
614
  if (!r) continue
615
+ // Time-filter on the MEMBER's own ts before resolving to the root. A
616
+ // member match that falls inside the requested [ts_from, ts_to] window
617
+ // was previously dropped when its ROOT's ts sat outside the window (the
618
+ // final fetch filters on root ts). Gate the member here on its own ts so
619
+ // in-window member hits survive root resolution.
620
+ if (!memberTsInWindow(row, tsFrom, tsTo)) continue
605
621
  memberHitRootIds.add(r.id)
606
622
  targetRow = r
607
623
  } else {
@@ -642,16 +658,44 @@ LEFT JOIN exact x ON x.id = c.id`
642
658
 
643
659
  // ── Final fetch: full row for each root by id = ANY(bigint[]) ────────────
644
660
  const topIds = rootIdsForReturn.map(x => Number(x.root.id))
645
- const { clause: finalFilter, params: finalFilterParams } = buildFilterClause(2)
646
- const { rows: finalRows } = await recallReadQuery(
647
- db,
648
- `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
649
- element, category, summary, project_id, status, score, last_seen_at
650
- FROM entries
651
- WHERE id = ANY($1::bigint[])
652
- ${finalFilter}`,
653
- [topIds, ...finalFilterParams],
654
- )
661
+ // Roots reached via an in-window MEMBER hit must NOT be re-dropped by the
662
+ // final ts window filter: the root's own ts can legitimately sit outside the
663
+ // window even though a member matched inside it (member ts already gated
664
+ // above). Fetch member-hit roots with a status/scope-only filter (no ts
665
+ // window); fetch the rest with the full window filter; merge.
666
+ const memberHitExemptIds = [...memberHitRootIds].map(Number)
667
+ let finalRows
668
+ if (memberHitExemptIds.length > 0) {
669
+ const exemptSet = new Set(memberHitExemptIds)
670
+ const nonExempt = topIds.filter(id => !exemptSet.has(id))
671
+ // Window+status filter for non-member-hit roots.
672
+ const { clause: winFilter, params: winParams } = buildFilterClause(2)
673
+ // Status/scope-only filter (no ts window) for member-hit roots.
674
+ const { clause: statusFilter, params: statusParams } = buildFilterClause(2, { skipTsWindow: true })
675
+ const [a, b] = await Promise.all([
676
+ nonExempt.length > 0
677
+ ? recallReadQuery(db,
678
+ `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
679
+ element, category, summary, project_id, status, score, last_seen_at
680
+ FROM entries WHERE id = ANY($1::bigint[]) ${winFilter}`,
681
+ [nonExempt, ...winParams])
682
+ : Promise.resolve({ rows: [] }),
683
+ recallReadQuery(db,
684
+ `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
685
+ element, category, summary, project_id, status, score, last_seen_at
686
+ FROM entries WHERE id = ANY($1::bigint[]) ${statusFilter}`,
687
+ [memberHitExemptIds, ...statusParams]),
688
+ ])
689
+ finalRows = [...a.rows, ...b.rows]
690
+ } else {
691
+ const { clause: winFilter, params: winParams } = buildFilterClause(2)
692
+ const r = await recallReadQuery(db,
693
+ `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
694
+ element, category, summary, project_id, status, score, last_seen_at
695
+ FROM entries WHERE id = ANY($1::bigint[]) ${winFilter}`,
696
+ [topIds, ...winParams])
697
+ finalRows = r.rows
698
+ }
655
699
  const finalById = new Map(finalRows.map(r => [Number(r.id), r]))
656
700
 
657
701
  // Members: single batch fetch keyed by chunk_root = ANY($1) — one
@@ -10,6 +10,8 @@ import { ensurePgInstance, closePgInstance, withSchemaBootstrapLock } from './pg
10
10
  import { mkdirSync } from 'fs'
11
11
  import { resolve } from 'path'
12
12
  import { cleanMemoryText } from './memory-extraction.mjs'
13
+ import { isInternalRuntimeNotificationText, isModelVisibleToolCompletionWrapper } from '../../shared/tool-execution-contract.mjs'
14
+ import { isUnquotedToolCompletionHead } from './session-ingest.mjs'
13
15
 
14
16
  const dbs = new Map()
15
17
  const opening = new Map()
@@ -421,6 +423,61 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
421
423
  } catch (err) {
422
424
  __mixdogMemoryLog(`[memory] attachment-placeholder cleanup failed: ${err?.message || err}\n`)
423
425
  }
426
+ // One-time cleanup: runtime tool-completion notification rows ("The async
427
+ // shell/agent task ... has finished ... - review this result in your next
428
+ // step." followed by an unquoted or `> `-quoted Result body, and
429
+ // "[mixdog-runtime] ..." nudges) that were persisted by the transcript
430
+ // watcher before it gained the shouldExcludeIngestMessage exclusion
431
+ // (transcript-ingest.mjs). Gated by a meta flag so this DELETE runs at
432
+ // most once ever, not on every boot (mirrors boot.schema_bootstrap_complete).
433
+ // SQL prefilter narrows candidates to role='user' rows only; the DELETE
434
+ // itself only fires for rows the SAME JS predicates ingest applies confirm
435
+ // as internal-runtime text — a row that merely starts with "background
436
+ // task" prose is never deleted unless isInternalRuntimeNotificationText
437
+ // (or the instruction-head match) itself confirms it. Best-effort so a
438
+ // failure never blocks boot.
439
+ const NOTIFICATION_CLEANUP_META_KEY = 'cleanup.notification_rows_v1'
440
+ try {
441
+ const already = await db.query(`SELECT 1 FROM meta WHERE key = $1`, [NOTIFICATION_CLEANUP_META_KEY])
442
+ if (!already?.rows?.length) {
443
+ const candidates = await db.query(
444
+ `SELECT id, content FROM entries
445
+ WHERE role = 'user' AND (
446
+ content LIKE 'The async % task % has finished%'
447
+ OR content LIKE '[mixdog-runtime]%'
448
+ OR content LIKE 'background task%'
449
+ )`,
450
+ )
451
+ const rows = Array.isArray(candidates?.rows) ? candidates.rows : []
452
+ const idsToDelete = rows
453
+ .filter((row) => {
454
+ const text = String(row?.content ?? '')
455
+ return /^\[mixdog-runtime\]/.test(text.trimStart())
456
+ || isInternalRuntimeNotificationText(text)
457
+ || isModelVisibleToolCompletionWrapper(text)
458
+ || isUnquotedToolCompletionHead(text)
459
+ })
460
+ .map((row) => row.id)
461
+ .filter((id) => id != null)
462
+ if (idsToDelete.length > 0) {
463
+ const deleted = await db.query(
464
+ `DELETE FROM entries WHERE id = ANY($1::bigint[])`,
465
+ [idsToDelete],
466
+ )
467
+ const n = Number(deleted?.rowCount ?? idsToDelete.length)
468
+ if (n > 0) {
469
+ __mixdogMemoryLog(`[memory] ensureCurrentSchemaExtensions: removed ${n} runtime notification rows\n`)
470
+ }
471
+ }
472
+ await db.query(
473
+ `INSERT INTO meta(key, value) VALUES ($1, $2::jsonb)
474
+ ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value`,
475
+ [NOTIFICATION_CLEANUP_META_KEY, JSON.stringify('1')],
476
+ )
477
+ }
478
+ } catch (err) {
479
+ __mixdogMemoryLog(`[memory] notification-row cleanup failed: ${err?.message || err}\n`)
480
+ }
424
481
  // core_entries gained an embedding column for cross-table semantic dedup
425
482
  // between user-curated rows and cycle2-promoted entries. ALTER + index are
426
483
  // idempotent and define the current runtime schema.
@@ -575,7 +632,37 @@ export async function closeDatabase(dataDir) {
575
632
  export async function isBootstrapComplete(db) {
576
633
  try {
577
634
  const r = await db.query(`SELECT 1 FROM meta WHERE key = 'boot.schema_bootstrap_complete'`)
578
- return r.rows.length > 0
635
+ if (r.rows.length === 0) return false
636
+ // The meta flag alone is not proof the schema is CURRENT: an older cluster
637
+ // whose pgdata predates a column/type addition can carry the flag while the
638
+ // physical schema has drifted, and trusting the flag would skip init() and
639
+ // leave inserts failing forever. Verify the critical objects init() creates
640
+ // actually exist; on drift return false so the caller re-runs init() (all
641
+ // DDL there is IF NOT EXISTS / additive, so re-running is a safe no-op on a
642
+ // healthy DB and self-heals a drifted one — no destructive change).
643
+ const drift = await db.query(`
644
+ SELECT
645
+ EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
646
+ WHERE t.typname = 'entry_status' AND n.nspname = 'memory') AS has_status_type,
647
+ EXISTS (SELECT 1 FROM information_schema.tables
648
+ WHERE table_schema = 'memory' AND table_name = 'entries') AS has_entries,
649
+ EXISTS (SELECT 1 FROM information_schema.columns
650
+ WHERE table_schema = 'memory' AND table_name = 'entries'
651
+ AND column_name = 'status') AS has_status_col,
652
+ EXISTS (SELECT 1 FROM information_schema.columns
653
+ WHERE table_schema = 'memory' AND table_name = 'entries'
654
+ AND column_name = 'chunk_root') AS has_chunk_root,
655
+ EXISTS (SELECT 1 FROM information_schema.tables
656
+ WHERE table_schema = 'memory' AND table_name = 'meta') AS has_meta
657
+ `)
658
+ const d = drift.rows?.[0] ?? {}
659
+ const healthy = d.has_status_type && d.has_entries && d.has_status_col
660
+ && d.has_chunk_root && d.has_meta
661
+ if (!healthy) {
662
+ __mixdogMemoryLog(`[memory] bootstrap flag present but schema drifted (${JSON.stringify(d)}); re-running init()\n`)
663
+ return false
664
+ }
665
+ return true
579
666
  } catch {
580
667
  return false
581
668
  }
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto'
2
- import { isInternalRuntimeNotificationText } from '../../shared/tool-execution-contract.mjs'
2
+ import { isInternalRuntimeNotificationText, isModelVisibleToolCompletionWrapper } from '../../shared/tool-execution-contract.mjs'
3
3
 
4
4
  // Side-effect-free helpers for ingest_session (recall fast-track hydration).
5
5
  // Extracted from memory/index.mjs so the pure logic (stable identity, sensitive
@@ -52,14 +52,20 @@ function toolIdentityIds(m) {
52
52
  // regardless of its position after compaction shrinks/reindexes the transcript.
53
53
  // Two textually identical untimestamped plain messages intentionally dedupe to
54
54
  // one row (stable dedupe preferred over positional separation).
55
- export function stableSessionSourceRef(sessionId, m, role, content) {
55
+ export function stableSessionSourceRef(sessionId, m, role, content, ordinal) {
56
56
  const toolIds = toolIdentityIds(m)
57
57
  // Only an ORIGINAL, caller-supplied timestamp counts as durable identity.
58
58
  const rawTs = m?.ts ?? m?.timestamp
59
59
  const originalTs = (typeof rawTs === 'number' && Number.isFinite(rawTs)) || (typeof rawTs === 'string' && rawTs.trim())
60
60
  ? String(rawTs)
61
61
  : ''
62
- const identity = [role, originalTs, toolIds.join(','), content].join('\u0000')
62
+ // Untimestamped, textually-identical turns previously collapsed to one row
63
+ // (same hash). Fold a stable ORDINAL (caller-supplied turn/index) into the
64
+ // identity so genuine repeats persist as distinct rows. The ordinal is only
65
+ // used when no durable original ts exists — a timestamped turn keeps its
66
+ // compaction-stable identity independent of array position.
67
+ const ordinalPart = originalTs ? '' : (Number.isFinite(Number(ordinal)) ? String(Math.floor(Number(ordinal))) : '')
68
+ const identity = [role, originalTs, ordinalPart, toolIds.join(','), content].join('\u0000')
63
69
  const hash = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 24)
64
70
  return `session:${sessionId}:${hash}`
65
71
  }
@@ -262,6 +268,24 @@ export function sessionMessageContentForIngest(m) {
262
268
  return base
263
269
  }
264
270
 
271
+ // Head-line shape of toolCompletionInstruction() (tool-execution-contract.mjs)
272
+ // as it is ACTUALLY persisted by mgr.enqueuePendingMessage — UNQUOTED, no
273
+ // `> ` prefix on the Result body (real DB rows look like:
274
+ // "The async shell task <id> has finished (completed, exit 0) - review this
275
+ // result in your next step.\nResult:\nbackground task\ntask_id: ...").
276
+ // isModelVisibleToolCompletionWrapper only matches the QUOTED (`> `) shape
277
+ // mirrored via the notify-wrapper's own quoting, so it misses this unquoted
278
+ // persisted form. The instruction head alone is a sufficient, unambiguous
279
+ // fingerprint (only the runtime ever emits this exact phrase).
280
+ const UNQUOTED_TOOL_COMPLETION_HEAD_RE = /^The async (?:shell task|agent task|\S+ execution|\S+) .*has finished\b.*review this result in your next step/i
281
+
282
+ // Exported so memory.mjs's one-time cleanup (ensureCurrentSchemaExtensions)
283
+ // can confirm SQL-prefiltered candidate rows with the SAME predicate the live
284
+ // ingest filter uses, rather than re-deriving the regex.
285
+ export function isUnquotedToolCompletionHead(text) {
286
+ return UNQUOTED_TOOL_COMPLETION_HEAD_RE.test(String(text ?? '').trimStart())
287
+ }
288
+
265
289
  // Row-exclusion predicate for ingest_session. Synthetic / non-conversation
266
290
  // rows (reference-files injections, compaction summaries, protected-context
267
291
  // `.` acks, internal runtime nudges) are dropped ENTIRELY — they are noise,
@@ -298,6 +322,13 @@ export function shouldExcludeIngestMessage(m) {
298
322
  const text = typeof raw === 'string' ? raw : ''
299
323
  if (/^\[mixdog-runtime\]/.test(text.trimStart())) return true
300
324
  if (isInternalRuntimeNotificationText(text)) return true
325
+ // Model-visible tool-completion mirror rows (mgr.enqueuePendingMessage of
326
+ // modelVisibleToolCompletionMessage — "The async shell/agent task ... has
327
+ // finished ... - review this result in your next step.\n\nResult:\n> ...").
328
+ // These are runtime notifications, not conversation, on both ingest paths.
329
+ if (isModelVisibleToolCompletionWrapper(text)) return true
330
+ // Unquoted persisted form of the same wrapper (see comment above).
331
+ if (isUnquotedToolCompletionHead(text)) return true
301
332
  }
302
333
  return false
303
334
  }
@@ -146,12 +146,23 @@ async function migrateSchemaDrift(client) {
146
146
  // readers/writers indefinitely and wedge boot (reviewer Medium). 5s is
147
147
  // generous for a metadata change; on timeout we leave the drift in place
148
148
  // (inserts keep failing as before — no worse) instead of hanging startup.
149
- await client.query(`SET LOCAL lock_timeout = '5s'`).catch(() => {})
149
+ //
150
+ // SET LOCAL only affects the enclosing transaction; with pg autocommit each
151
+ // stray statement was its own transaction, so `SET LOCAL lock_timeout` was a
152
+ // no-op that ended immediately and the ALTERs ran with the default (0 =
153
+ // unbounded) timeout — the exact wedge this was meant to prevent. Wrap the
154
+ // whole thing in an explicit transaction so SET LOCAL actually scopes the
155
+ // ALTERs; on any error roll back (leaving drift in place, inserts fail as
156
+ // before — no worse).
150
157
  try {
158
+ await client.query('BEGIN')
159
+ await client.query(`SET LOCAL lock_timeout = '5s'`)
151
160
  await client.query(`ALTER TABLE IF EXISTS trace_events ADD COLUMN IF NOT EXISTS agent TEXT`)
152
161
  await client.query(`ALTER TABLE IF EXISTS agent_sessions ADD COLUMN IF NOT EXISTS agent TEXT`)
153
- } finally {
154
- await client.query(`SET LOCAL lock_timeout = DEFAULT`).catch(() => {})
162
+ await client.query('COMMIT')
163
+ } catch (err) {
164
+ try { await client.query('ROLLBACK') } catch {}
165
+ __mixdogMemoryLog(`[trace-store] schema-drift migrate skipped: ${err?.message ?? err}\n`)
155
166
  }
156
167
  }
157
168
 
@@ -455,6 +466,43 @@ async function ensureCurrentAndNextMonthPartitions(client) {
455
466
  `)
456
467
  }
457
468
 
469
+ // Trace retention: drop named monthly partitions older than the configured
470
+ // window. Trace is regenerable observability data, so aging out old months is
471
+ // non-destructive to durable state. Config via MIXDOG_TRACE_RETENTION_MONTHS
472
+ // (default 6); set to 0 to disable. Only DROPs partitions strictly older than
473
+ // the cutoff month; the default catch-all partition is never dropped.
474
+ const TRACE_RETENTION_MONTHS = (() => {
475
+ const raw = process.env.MIXDOG_TRACE_RETENTION_MONTHS
476
+ if (raw == null || raw === '') return 6
477
+ const n = Number(raw)
478
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 6
479
+ })()
480
+
481
+ async function dropAgedTracePartitions(client) {
482
+ if (TRACE_RETENTION_MONTHS <= 0) return
483
+ // DO blocks take no bind params, so compute the cutoff month in SQL and pass
484
+ // the retention count as a literal (validated integer above — no injection).
485
+ const months = TRACE_RETENTION_MONTHS
486
+ await client.query(`
487
+ DO $$
488
+ DECLARE
489
+ cutoff TEXT := to_char(now() - interval '${months} months', 'YYYY_MM');
490
+ part RECORD;
491
+ BEGIN
492
+ FOR part IN
493
+ SELECT c.relname
494
+ FROM pg_class c
495
+ JOIN pg_namespace n ON n.oid = c.relnamespace
496
+ WHERE n.nspname = 'trace'
497
+ AND c.relname ~ '^trace_events_[0-9]{4}_[0-9]{2}$'
498
+ AND substring(c.relname from 'trace_events_(.*)') < cutoff
499
+ LOOP
500
+ EXECUTE format('DROP TABLE IF EXISTS trace.%I', part.relname);
501
+ END LOOP;
502
+ END $$
503
+ `)
504
+ }
505
+
458
506
  async function isBootstrapComplete(client) {
459
507
  try {
460
508
  // Harden the check: require (a) root is partitioned, (b) schema-version
@@ -579,6 +627,7 @@ export async function openTraceDatabase(dataDir) {
579
627
  try {
580
628
  await c.query(`SET search_path = trace, public`)
581
629
  await ensureCurrentAndNextMonthPartitions(c)
630
+ await dropAgedTracePartitions(c)
582
631
  } catch (err) {
583
632
  __mixdogMemoryLog(`[trace-store] periodic partition ensure failed: ${err?.message ?? err}\n`)
584
633
  } finally {
@@ -9,6 +9,7 @@
9
9
  // this module stays a pure factory with no import-time side effects.
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
+ import { normalizeIngestRole, sessionMessageContentForIngest, shouldExcludeIngestMessage } from './session-ingest.mjs'
12
13
 
13
14
  // Pure: coerce a transcript timestamp (seconds, ms, or ISO string) to ms.
14
15
  export function parseTsToMs(value) {
@@ -107,8 +108,8 @@ export function createTranscriptIngest({
107
108
 
108
109
  function snapshotTranscriptOffset(transcriptPath) {
109
110
  const stored = _transcriptOffsets.get(transcriptPath)
110
- if (!stored) return { bytes: 0, lineIndex: 0 }
111
- return { bytes: Number(stored.bytes) || 0, lineIndex: Number(stored.lineIndex) || 0 }
111
+ if (!stored) return { bytes: 0, lineIndex: 0, generation: 0 }
112
+ return { bytes: Number(stored.bytes) || 0, lineIndex: Number(stored.lineIndex) || 0, generation: Number(stored.generation) || 0 }
112
113
  }
113
114
 
114
115
  async function ingestTranscriptFileImpl(transcriptPath, { cwd } = {}) {
@@ -117,9 +118,18 @@ export function createTranscriptIngest({
117
118
  try { stat = await fs.promises.stat(transcriptPath) } catch { return 0 }
118
119
  const sessionUuid = path.basename(transcriptPath, '.jsonl')
119
120
  const prev = snapshotTranscriptOffset(transcriptPath)
121
+ // Generation counter: a truncate/rewrite of the transcript (size shrank
122
+ // below the persisted byte offset) resets bytes/lineIndex to 0, so the
123
+ // rewritten lines reuse the SAME transcript:${uuid}#${index} refs as the
124
+ // pre-truncate content. With ON CONFLICT DO NOTHING the rewritten rows were
125
+ // silently dropped. Bump a persisted per-file generation on each detected
126
+ // reset and fold it into source_ref so rewritten lines get fresh identities
127
+ // and persist. Existing rows (gen 0, no suffix) are unaffected.
128
+ let generation = Number(prev.generation) || 0
120
129
  if (stat.size < prev.bytes) {
121
130
  prev.bytes = 0
122
131
  prev.lineIndex = 0
132
+ generation += 1
123
133
  }
124
134
  if (stat.size <= prev.bytes) return 0
125
135
 
@@ -180,7 +190,17 @@ export function createTranscriptIngest({
180
190
  lastGoodLineIndex = index
181
191
  continue
182
192
  }
183
- const content = firstTextContent(parsed.message?.content)
193
+ // Reuse the ingest_session shape/exclude predicates so the transcript
194
+ // watcher stores ONLY pure conversation rows — same purity as
195
+ // ingest_session (strips manager.mjs prefix envelopes, drops synthetic
196
+ // reference-files/compaction/ack/internal-notification rows).
197
+ const shaped = { role, content: parsed.message?.content }
198
+ if (shouldExcludeIngestMessage(shaped)) {
199
+ lastGoodBytes += consumedBytes
200
+ lastGoodLineIndex = index
201
+ continue
202
+ }
203
+ const content = sessionMessageContentForIngest(shaped)
184
204
  if (!content || !content.trim()) {
185
205
  lastGoodBytes += consumedBytes
186
206
  lastGoodLineIndex = index
@@ -193,7 +213,9 @@ export function createTranscriptIngest({
193
213
  continue
194
214
  }
195
215
  const tsMs = parseTsToMs(parsed.timestamp ?? parsed.ts ?? Date.now())
196
- const sourceRef = `transcript:${sessionUuid}#${index}`
216
+ const sourceRef = generation > 0
217
+ ? `transcript:${sessionUuid}#${index}@g${generation}`
218
+ : `transcript:${sessionUuid}#${index}`
197
219
  try {
198
220
  const result = await db.query(
199
221
  `INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
@@ -215,6 +237,7 @@ export function createTranscriptIngest({
215
237
  _transcriptOffsets.set(transcriptPath, {
216
238
  bytes: lastGoodBytes,
217
239
  lineIndex: lastGoodLineIndex,
240
+ generation,
218
241
  })
219
242
  await persistTranscriptOffsets()
220
243
  return count
@@ -24,11 +24,13 @@ function alive(pid) {
24
24
  }
25
25
  function killTarget(force) {
26
26
  if (platform === 'win32') {
27
+ const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\\\Windows';
28
+ const taskkill = systemRoot + '\\\\System32\\\\taskkill.exe';
27
29
  if (force) {
28
- try { spawnSync('taskkill.exe', ['/PID', String(childPid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch {}
30
+ try { spawnSync(taskkill, ['/PID', String(childPid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch {}
29
31
  } else {
30
32
  try { process.kill(childPid, 'SIGTERM'); } catch {}
31
- try { spawnSync('taskkill.exe', ['/PID', String(childPid), '/T'], { stdio: 'ignore', windowsHide: true }); } catch {}
33
+ try { spawnSync(taskkill, ['/PID', String(childPid), '/T'], { stdio: 'ignore', windowsHide: true }); } catch {}
32
34
  }
33
35
  return;
34
36
  }
@@ -9,7 +9,8 @@ export function openInBrowser(url) {
9
9
  const u = String(url);
10
10
  let candidates;
11
11
  if (process.platform === 'win32') {
12
- candidates = [['rundll32', ['url.dll,FileProtocolHandler', u]]];
12
+ const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
13
+ candidates = [[`${systemRoot}\\System32\\rundll32.exe`, ['url.dll,FileProtocolHandler', u]]];
13
14
  } else if (process.platform === 'darwin') {
14
15
  candidates = [['open', [u]]];
15
16
  } else if (isWSL()) {
@@ -102,3 +102,29 @@ export function releaseSingletonOwner(path, pid = process.pid) {
102
102
  return false;
103
103
  }
104
104
  }
105
+
106
+ // Atomic ownership handoff: replace the owner record from fromPid to toOwner
107
+ // under the SAME claim lock, so a concurrent claimSingletonOwner() cannot slip
108
+ // between a release() and a re-claim() and fork a competing daemon. Verifies
109
+ // the current on-disk pid is still fromPid before writing; if it drifted
110
+ // (someone else won), the handoff is refused (owned:false) and the caller
111
+ // should fall back to the winner rather than overwrite it.
112
+ export function handoffSingletonOwner(path, fromPid, toOwner = {}) {
113
+ const lockPath = acquireClaimLock(path);
114
+ if (!lockPath) return { owned: false, owner: readJson(path) };
115
+ try {
116
+ const current = readJson(path);
117
+ if (current?.pid && Number(current.pid) !== Number(fromPid)) {
118
+ return { owned: false, owner: current };
119
+ }
120
+ const owner = {
121
+ kind: 'runtime',
122
+ claimedAt: new Date().toISOString(),
123
+ ...toOwner,
124
+ };
125
+ writeJsonAtomic(path, owner);
126
+ return { owned: true, owner };
127
+ } finally {
128
+ rmSync(lockPath, { recursive: true, force: true });
129
+ }
130
+ }