mixdog 0.9.2 → 0.9.3

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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -9,7 +9,6 @@ process.removeAllListeners('warning')
9
9
  process.on('warning', () => {})
10
10
 
11
11
  import http from 'node:http'
12
- import crypto from 'node:crypto'
13
12
  import os from 'node:os'
14
13
  import fs from 'node:fs'
15
14
  import path from 'node:path'
@@ -17,47 +16,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
17
16
 
18
17
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
19
18
 
20
- function readPluginVersion() {
21
- try {
22
- return JSON.parse(fs.readFileSync(path.join(PLUGIN_ROOT, 'package.json'), 'utf8')).version || '0.0.1'
23
- } catch { return '0.0.1' }
24
- }
25
- const PLUGIN_VERSION = readPluginVersion()
26
- const PROMOTION_FINGERPRINT_ROOTS = ['src/memory']
27
- function collectPromotionFingerprintFiles() {
28
- const out = []
29
- const walk = (relDir) => {
30
- let entries = []
31
- try { entries = fs.readdirSync(path.join(PLUGIN_ROOT, relDir), { withFileTypes: true }) }
32
- catch { return }
33
- for (const ent of entries) {
34
- const rel = `${relDir}/${ent.name}`.replace(/\\/g, '/')
35
- if (ent.isDirectory()) {
36
- walk(rel)
37
- } else if (ent.isFile() && rel.endsWith('.mjs')) {
38
- out.push(rel)
39
- }
40
- }
41
- }
42
- for (const root of PROMOTION_FINGERPRINT_ROOTS) walk(root)
43
- return out.sort()
44
- }
45
- function readPromotionCodeFingerprint() {
46
- const hash = crypto.createHash('sha256')
47
- const files = collectPromotionFingerprintFiles()
48
- for (const rel of files) {
49
- hash.update(rel)
50
- hash.update('\0')
51
- try {
52
- hash.update(fs.readFileSync(path.join(PLUGIN_ROOT, rel)))
53
- } catch {
54
- hash.update('missing')
55
- }
56
- hash.update('\0')
57
- }
58
- return `src/memory:${files.length}:${hash.digest('hex').slice(0, 16)}`
59
- }
60
- const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint()
19
+ import { readPluginVersion, readPromotionCodeFingerprint } from './lib/promotion-fingerprint.mjs'
20
+ const PLUGIN_VERSION = readPluginVersion(PLUGIN_ROOT)
21
+ const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint(PLUGIN_ROOT)
61
22
 
62
23
  try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL) } catch {}
63
24
  try {
@@ -101,18 +62,21 @@ import { initProviders } from '../agent/orchestrator/providers/registry.mjs'
101
62
  import { makeAgentDispatch } from '../agent/orchestrator/agent-runtime/agent-dispatch.mjs'
102
63
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
103
64
  import { drainSessionCycle1 } from '../agent/orchestrator/session/compact.mjs'
104
- import { claimAndMarkScheduledCycle, makeCycleRequestSignature, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
65
+ import { claimAndMarkScheduledCycle, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
105
66
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
106
67
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
107
68
  import { retrieveEntries } from './lib/memory-retrievers.mjs'
108
69
  import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
109
70
  import { computeEntryScore } from './lib/memory-score.mjs'
110
71
  import { runFullBackfill } from './lib/memory-ops-policy.mjs'
111
- import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
72
+ import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
112
73
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
113
74
  import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
114
75
  import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
115
76
  import { resolvePluginData, mixdogHome } from '../shared/plugin-paths.mjs'
77
+ import { parsePeriod, formatTs, coreRecallTerms, normalizeRecallProjectScope, sessionRecallTerms, interleaveRawRows, renderEntryLines } from './lib/recall-format.mjs'
78
+ import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './lib/http-wire.mjs'
79
+ import { scheduledCycle1Signature, scheduledCycle2Signature, scheduledCycle3Signature } from './lib/cycle-signatures.mjs'
116
80
  const IS_MEMORY_ENTRY = !!process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
117
81
  const USE_ARG_DATA_DIR = IS_MEMORY_ENTRY || process.env.MIXDOG_WORKER_MODE === '1'
118
82
  const DATA_DIR = process.env.MIXDOG_DATA_DIR || (USE_ARG_DATA_DIR ? process.argv[2] : '') || resolvePluginData()
@@ -655,79 +619,78 @@ async function setCycleLastRun(kind, ts) {
655
619
  // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
656
620
  // chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
657
621
  // cycle1 runs, so they always carry the freshest turns in the DB.
658
- async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope } = {}) {
622
+ async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId } = {}) {
659
623
  try {
660
- let sql, params
624
+ // Composable WHERE assembly (mirrors retrieveEntries' filter semantics so
625
+ // raw and chunked legs stay in filter parity: projectScope AND sessionId
626
+ // apply identically to both pools).
627
+ const where = ['chunk_root IS NULL', 'is_root = 0', 'ts >= $1', 'ts <= $2']
628
+ const params = [tsFromMs ?? 0, tsToMs ?? Date.now()]
661
629
  if (projectScope === 'common') {
662
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
663
- element, category, summary, status, score, last_seen_at, project_id
664
- FROM entries
665
- WHERE chunk_root IS NULL AND is_root = 0
666
- AND ts >= $1 AND ts <= $2
667
- AND project_id IS NULL
668
- ORDER BY ts DESC
669
- LIMIT $3`
670
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
630
+ where.push('project_id IS NULL')
671
631
  } else if (projectScope && projectScope !== 'all') {
672
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
673
- element, category, summary, status, score, last_seen_at, project_id
674
- FROM entries
675
- WHERE chunk_root IS NULL AND is_root = 0
676
- AND ts >= $1 AND ts <= $2
677
- AND (project_id IS NULL OR project_id = $3)
678
- ORDER BY ts DESC
679
- LIMIT $4`
680
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), projectScope, hardLimit]
681
- } else {
682
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
632
+ params.push(projectScope)
633
+ where.push(`(project_id IS NULL OR project_id = $${params.length})`)
634
+ }
635
+ const sid = String(sessionId || '').trim()
636
+ if (sid) {
637
+ params.push(sid)
638
+ where.push(`session_id = $${params.length}`)
639
+ }
640
+ params.push(hardLimit)
641
+ const sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
683
642
  element, category, summary, status, score, last_seen_at, project_id
684
643
  FROM entries
685
- WHERE chunk_root IS NULL AND is_root = 0
686
- AND ts >= $1 AND ts <= $2
644
+ WHERE ${where.join(' AND ')}
687
645
  ORDER BY ts DESC
688
- LIMIT $3`
689
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
690
- }
646
+ LIMIT $${params.length}`
691
647
  const rows = (await db.query(sql, params)).rows
692
648
  return rows.map(r => ({ ...r, retrievalScore: 0, rrf: 0 }))
693
649
  } catch { return [] }
694
650
  }
695
651
 
696
- // Spread raw (unchunked) rows evenly across a hybrid-ranked list at a fixed
697
- // stride so every offset page draws its proportional share of raw rows,
698
- // instead of them all landing after the hybrid page-0 window (the old
699
- // append-only behaviour). Stride is derived from the ratio of hybrid:raw
700
- // counts so a small raw set doesn't get pushed arbitrarily deep, and a large
701
- // raw set doesn't crowd out every hybrid hit near the top.
702
- function interleaveRawRows(hybridRows, rawRows) {
703
- if (!Array.isArray(rawRows) || rawRows.length === 0) return hybridRows
704
- const out = []
705
- const stride = Math.max(1, Math.round(hybridRows.length / (rawRows.length + 1)))
706
- let rawIdx = 0
707
- for (let i = 0; i < hybridRows.length; i += 1) {
708
- out.push(hybridRows[i])
709
- if ((i + 1) % stride === 0 && rawIdx < rawRows.length) {
710
- out.push(rawRows[rawIdx])
711
- rawIdx += 1
712
- }
713
- }
714
- while (rawIdx < rawRows.length) out.push(rawRows[rawIdx++])
715
- return out
716
- }
717
-
718
- function sessionRecallTerms(query) {
719
- return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
720
- .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
721
- .slice(0, 12)
722
- }
723
-
724
652
  async function recallSessionRows(args = {}) {
725
653
  const sessionId = String(args.sessionId || args.session_id || '').trim()
726
654
  if (!sessionId) return { text: '(no current session)' }
727
655
  const limit = Math.max(1, Math.min(100, Number(args.limit) || 20))
728
656
  const terms = sessionRecallTerms(args.query)
729
657
  const params = [sessionId]
730
- const where = ['session_id = $1']
658
+ // Roots + not-yet-chunked leaves only. Once cycle1 turns raw leaves into
659
+ // (root, members) pairs, selecting every row unfiltered emitted the root's
660
+ // summary AND its own member rows in the same browse — duplicate content.
661
+ // A committed member (is_root=0 with a chunk_root) is always reachable via
662
+ // its root's `members` expansion below, so it never needs to be selected
663
+ // directly here.
664
+ const where = ['session_id = $1', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
665
+ // Current-turn cutoff: the newest unchunked row is very often the calling
666
+ // turn's OWN recall request/tool-args, still being written when this query
667
+ // runs. Exclude it from a bare (no-query) browse so the in-flight turn
668
+ // doesn't self-echo; a query browse (explicit search intent) keeps it.
669
+ // Only treat the newest unchunked turn as "in-flight" when its latest row
670
+ // is fresh (within FRESHNESS_MS of now) — an older newest-unchunked-turn
671
+ // is completed history (cycle1 just hasn't gotten to it, or drain timed
672
+ // out) and must stay visible, not be silently hidden every browse.
673
+ const IN_FLIGHT_TURN_FRESHNESS_MS = 5 * 60 * 1000
674
+ let excludeSourceTurnId = null
675
+ if (terms.length === 0) {
676
+ try {
677
+ const r = await db.query(
678
+ `SELECT source_turn t, MAX(ts) last_ts FROM entries
679
+ WHERE session_id = $1 AND chunk_root IS NULL
680
+ GROUP BY source_turn ORDER BY source_turn DESC LIMIT 1`,
681
+ [sessionId],
682
+ )
683
+ const t = r.rows?.[0]?.t
684
+ const lastTs = Number(r.rows?.[0]?.last_ts)
685
+ if (t != null && Number.isFinite(lastTs) && (Date.now() - lastTs) <= IN_FLIGHT_TURN_FRESHNESS_MS) {
686
+ excludeSourceTurnId = Number(t)
687
+ }
688
+ } catch {}
689
+ }
690
+ if (Number.isFinite(excludeSourceTurnId)) {
691
+ params.push(excludeSourceTurnId)
692
+ where.push(`NOT (chunk_root IS NULL AND source_turn = $${params.length})`)
693
+ }
731
694
  if (terms.length > 0) {
732
695
  const textExpr = `lower(coalesce(content, '') || ' ' || coalesce(element, '') || ' ' || coalesce(summary, ''))`
733
696
  const clauses = terms.map((term) => {
@@ -748,16 +711,22 @@ async function recallSessionRows(args = {}) {
748
711
  if (rows.length < limit) {
749
712
  const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
750
713
  const fillLimit = Math.max(0, limit - rows.length)
714
+ const fillWhere = ['session_id = $1', 'id <> ALL($2::bigint[])', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
715
+ const fillParams = [sessionId, [...seen]]
716
+ if (Number.isFinite(excludeSourceTurnId)) {
717
+ fillParams.push(excludeSourceTurnId)
718
+ fillWhere.push(`NOT (chunk_root IS NULL AND source_turn = $${fillParams.length})`)
719
+ }
720
+ fillParams.push(fillLimit)
751
721
  const fillRows = fillLimit > 0
752
722
  ? (await db.query(`
753
723
  SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
754
724
  element, category, summary, status, score, last_seen_at, project_id
755
725
  FROM entries
756
- WHERE session_id = $1
757
- AND id <> ALL($2::bigint[])
726
+ WHERE ${fillWhere.join(' AND ')}
758
727
  ORDER BY ts DESC, id DESC
759
- LIMIT $3
760
- `, [sessionId, [...seen], fillLimit])).rows
728
+ LIMIT $${fillParams.length}
729
+ `, fillParams)).rows
761
730
  : []
762
731
  if (fillRows.length > 0) rows = [...rows, ...fillRows]
763
732
  }
@@ -1296,6 +1265,62 @@ async function recordCycle1Result(result) {
1296
1265
  if (!skipped && !coalescedNoop && !allFailed) {
1297
1266
  await setCycleLastRun('cycle1', now)
1298
1267
  }
1268
+ if (!skipped && !coalescedNoop) markCycleDone('cycle1', !allFailed, allFailed ? 'all rows skipped' : null)
1269
+ }
1270
+
1271
+ // ── Cycle health state (no new commands; consumed by /health + statusline) ──
1272
+ // In-memory per-cycle run ledger + a small state file the TUI statusline can
1273
+ // read without HTTP. "Silently stalled cycles" (2026-07: cycle2 poison
1274
+ // rotation sat unnoticed for months) become visible three ways: /health
1275
+ // fields, a WARN log with cooldown, and the L2 "Memory" segment.
1276
+ const CYCLE_STATE_FILE = path.join(DATA_DIR, 'memory-cycle-state.json')
1277
+ const _cycleHealth = {
1278
+ cycle1: { last_success_at: 0, last_error_at: 0, last_error: null, consecutive_failures: 0 },
1279
+ cycle2: { last_success_at: 0, last_error_at: 0, last_error: null, consecutive_failures: 0 },
1280
+ cycle3: { last_success_at: 0, last_error_at: 0, last_error: null, consecutive_failures: 0 },
1281
+ }
1282
+ let _cycleRunning = null // { cycle, started_at }
1283
+ let _cycleBacklogSnapshot = { unchunked: 0, cycle2_pending: 0, at: 0 }
1284
+ let _lastBacklogWarnAt = 0
1285
+ const BACKLOG_WARN_COOLDOWN_MS = 10 * 60_000
1286
+ const BACKLOG_WARN_PENDING = 500
1287
+ const BACKLOG_WARN_FAILURES = 5
1288
+
1289
+ function _writeCycleStateFile() {
1290
+ try {
1291
+ fs.writeFileSync(CYCLE_STATE_FILE, JSON.stringify({
1292
+ running: _cycleRunning,
1293
+ backlog: _cycleBacklogSnapshot,
1294
+ cycles: _cycleHealth,
1295
+ updatedAt: Date.now(),
1296
+ }))
1297
+ } catch { /* best-effort; statusline just shows nothing */ }
1298
+ }
1299
+
1300
+ function markCycleRunning(cycle) {
1301
+ _cycleRunning = { cycle, started_at: Date.now() }
1302
+ _writeCycleStateFile()
1303
+ }
1304
+
1305
+ function markCycleDone(cycle, ok, err = null) {
1306
+ const h = _cycleHealth[cycle]
1307
+ if (h) {
1308
+ const now = Date.now()
1309
+ if (ok) { h.last_success_at = now; h.consecutive_failures = 0; h.last_error = null }
1310
+ else { h.last_error_at = now; h.consecutive_failures += 1; h.last_error = String(err || 'unknown').slice(0, 200) }
1311
+ if (!ok && h.consecutive_failures >= BACKLOG_WARN_FAILURES) {
1312
+ _warnCycleHealth(`${cycle} failing repeatedly (consecutive=${h.consecutive_failures}, last="${h.last_error}")`)
1313
+ }
1314
+ }
1315
+ if (_cycleRunning?.cycle === cycle) _cycleRunning = null
1316
+ _writeCycleStateFile()
1317
+ }
1318
+
1319
+ function _warnCycleHealth(msg) {
1320
+ const now = Date.now()
1321
+ if (now - _lastBacklogWarnAt < BACKLOG_WARN_COOLDOWN_MS) return
1322
+ _lastBacklogWarnAt = now
1323
+ __mixdogMemoryLog(`[cycle-health] WARN ${msg}\n`)
1299
1324
  }
1300
1325
 
1301
1326
  function _startCycle1Run(config = {}, options = {}) {
@@ -1306,6 +1331,7 @@ function _startCycle1Run(config = {}, options = {}) {
1306
1331
  if (typeof options?.callLlm !== 'function') {
1307
1332
  options = { ...options, callLlm: getCycle1CallLlm() }
1308
1333
  }
1334
+ markCycleRunning('cycle1')
1309
1335
  _cycle1InFlight = (async () => {
1310
1336
  try {
1311
1337
  const result = await runCycle1(db, config, options, DATA_DIR)
@@ -1318,7 +1344,11 @@ function _startCycle1Run(config = {}, options = {}) {
1318
1344
  await recordCycle1Result(result)
1319
1345
  }
1320
1346
  return result
1347
+ } catch (err) {
1348
+ markCycleDone('cycle1', false, err?.message || err)
1349
+ throw err
1321
1350
  } finally {
1351
+ if (_cycleRunning?.cycle === 'cycle1') { _cycleRunning = null; _writeCycleStateFile() }
1322
1352
  if (_cycle1InFlight === promise) _cycle1InFlight = null
1323
1353
  }
1324
1354
  })()
@@ -1345,7 +1375,14 @@ async function _awaitCycle1Run(config = {}, options = {}) {
1345
1375
  let timer
1346
1376
  const deadlinePromise = new Promise((resolve) => {
1347
1377
  timer = setTimeout(() => {
1348
- if (_cycle1InFlight === target) _cycle1InFlight = null
1378
+ // Do NOT clear _cycle1InFlight here — the underlying runCycle1 is still
1379
+ // running in the background and owns that slot; its own `finally`
1380
+ // (_startCycle1Run above) clears it when the real work settles. Clearing
1381
+ // it early made a later unbounded drain (deadlineMs:0, e.g. the query
1382
+ // recall path) re-enter _startCycle1Run and spawn a SECOND concurrent
1383
+ // cycle1 run instead of awaiting the one already in flight, defeating
1384
+ // drain-to-zero silently. The caller here just stops WAITING at the
1385
+ // deadline; state ownership stays with the promise itself.
1349
1386
  resolve({
1350
1387
  processed: 0,
1351
1388
  chunks: 0,
@@ -1381,34 +1418,6 @@ function periodicCycle1Config() {
1381
1418
  }
1382
1419
  }
1383
1420
 
1384
- function scheduledCycle1Signature(config) {
1385
- return makeCycleRequestSignature('cycle1', config, {
1386
- preset: undefined,
1387
- concurrency: undefined,
1388
- maxConcurrent: undefined,
1389
- })
1390
- }
1391
-
1392
- function scheduledCycle2Signature(config) {
1393
- return makeCycleRequestSignature('cycle2', config, {
1394
- cascadePreset: undefined,
1395
- concurrency: undefined,
1396
- })
1397
- }
1398
-
1399
- function scheduledCycle3ApplyMode(config) {
1400
- const raw = String(config?.cycle3?.applyMode || 'conservative').trim().toLowerCase()
1401
- return (raw === 'proposal' || raw === 'dry-run' || raw === 'dryrun') ? 'proposal' : 'conservative'
1402
- }
1403
-
1404
- function scheduledCycle3Signature(config) {
1405
- const retryConfig = config?.cycle3 || config
1406
- return makeCycleRequestSignature('cycle3', retryConfig, {
1407
- applyMode: scheduledCycle3ApplyMode(config),
1408
- apply: undefined,
1409
- })
1410
- }
1411
-
1412
1421
  async function enqueueScheduledCycle(kind, intervalMs, signature) {
1413
1422
  const claim = await claimAndMarkScheduledCycle(db, kind, intervalMs, signature, { reason: 'scheduled' })
1414
1423
  return claim.claimed === true
@@ -1469,6 +1478,7 @@ function scheduleScheduledCycle2(config, signature, attempt = 0) {
1469
1478
  return
1470
1479
  }
1471
1480
  _cycle2InFlight = true
1481
+ markCycleRunning('cycle2')
1472
1482
  try {
1473
1483
  let c2Options = {
1474
1484
  coalescedRetry: true,
@@ -1487,8 +1497,10 @@ function scheduleScheduledCycle2(config, signature, attempt = 0) {
1487
1497
  }
1488
1498
  } catch (err) {
1489
1499
  __mixdogMemoryLog(`[cycle2] scheduled queue failed: ${err?.message || err}\n`)
1500
+ markCycleDone('cycle2', false, err?.message || err)
1490
1501
  } finally {
1491
1502
  _cycle2InFlight = false
1503
+ if (_cycleRunning?.cycle === 'cycle2') { _cycleRunning = null; _writeCycleStateFile() }
1492
1504
  }
1493
1505
  }, config, signature)
1494
1506
  }
@@ -1506,10 +1518,11 @@ function scheduleScheduledCycle3(config, signature, attempt = 0) {
1506
1518
  return
1507
1519
  }
1508
1520
  _cycle3InFlight = true
1521
+ markCycleRunning('cycle3')
1509
1522
  try {
1510
1523
  let c3Options = {
1511
1524
  coalescedRetry: true,
1512
- onCoalescedSuccess: () => setCycleLastRun('cycle3', Date.now()),
1525
+ onCoalescedSuccess: async () => { await setCycleLastRun('cycle3', Date.now()); markCycleDone('cycle3', true) },
1513
1526
  }
1514
1527
  if (typeof c3Options?.callLlm !== 'function') {
1515
1528
  c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
@@ -1522,8 +1535,10 @@ function scheduleScheduledCycle3(config, signature, attempt = 0) {
1522
1535
  }
1523
1536
  } catch (err) {
1524
1537
  __mixdogMemoryLog(`[cycle3] scheduled queue failed: ${err?.message || err}\n`)
1538
+ markCycleDone('cycle3', false, err?.message || err)
1525
1539
  } finally {
1526
1540
  _cycle3InFlight = false
1541
+ if (_cycleRunning?.cycle === 'cycle3') { _cycleRunning = null; _writeCycleStateFile() }
1527
1542
  }
1528
1543
  }, retryConfig, signature)
1529
1544
  }
@@ -1537,9 +1552,11 @@ async function _finalizeCycle2Run(result) {
1537
1552
  await setCycleLastRun('cycle2', Date.now())
1538
1553
  await setCycleLastRun('cycle2_last_error', '')
1539
1554
  __mixdogMemoryLog('[cycle2] completed\n')
1555
+ markCycleDone('cycle2', true)
1540
1556
  } else {
1541
1557
  await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
1542
1558
  __mixdogMemoryLog(`[cycle2] failed: ${result.error}\n`)
1559
+ markCycleDone('cycle2', false, result.error || 'unknown error')
1543
1560
  }
1544
1561
  }
1545
1562
 
@@ -1616,6 +1633,36 @@ async function checkCycles() {
1616
1633
  if (now - last.cycle3 >= cycle3Ms) {
1617
1634
  await enqueueScheduledCycle3(cycle3Ms, 'scheduled')
1618
1635
  }
1636
+
1637
+ // ── Backlog watch + self-kick (cycle-health) ─────────────────────────────
1638
+ // Cheap counts each tick (60s): surface a WARN when the pipeline is
1639
+ // building a backlog, and if a cycle has not SUCCEEDED for 2h+ while its
1640
+ // backlog is over threshold, kick one unscheduled run now instead of
1641
+ // waiting for the interval — a stalled cycle otherwise hides behind
1642
+ // "the schedule ran it" (attempt != success; see 2026-07 cycle2 stall).
1643
+ try {
1644
+ const unchunked = Number((await db.query(
1645
+ `SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL AND NULLIF(btrim(session_id), '') IS NOT NULL`,
1646
+ )).rows[0]?.c ?? 0)
1647
+ const cycle2Pending = Number((await db.query(
1648
+ `SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`,
1649
+ )).rows[0]?.c ?? 0)
1650
+ _cycleBacklogSnapshot = { unchunked, cycle2_pending: cycle2Pending, at: now }
1651
+ _writeCycleStateFile()
1652
+ if (unchunked > BACKLOG_WARN_PENDING || cycle2Pending > BACKLOG_WARN_PENDING) {
1653
+ _warnCycleHealth(`backlog unchunked=${unchunked} cycle2_pending=${cycle2Pending}`)
1654
+ }
1655
+ const SELF_KICK_STALE_MS = 2 * 3600_000
1656
+ const c1Stale = (_cycleHealth.cycle1.last_success_at || last.cycle1 || 0) < now - SELF_KICK_STALE_MS
1657
+ const c2Stale = (_cycleHealth.cycle2.last_success_at || last.cycle2 || 0) < now - SELF_KICK_STALE_MS
1658
+ if (c1Stale && unchunked > BACKLOG_WARN_PENDING) {
1659
+ _warnCycleHealth(`cycle1 self-kick: no success 2h+, unchunked=${unchunked}`)
1660
+ await enqueueScheduledCycle1(0, 'self-kick')
1661
+ } else if (c2Stale && cycle2Pending > BACKLOG_WARN_PENDING) {
1662
+ _warnCycleHealth(`cycle2 self-kick: no success 2h+, pending=${cycle2Pending}`)
1663
+ await enqueueScheduledCycle2(0, 'self-kick')
1664
+ }
1665
+ } catch { /* counts are best-effort; never fail the tick */ }
1619
1666
  }
1620
1667
  let _cycle2InFlight = false
1621
1668
  let _cycle3InFlight = false
@@ -1696,114 +1743,6 @@ function _beginRuntimeInit() {
1696
1743
  return _initPromise
1697
1744
  }
1698
1745
 
1699
-
1700
- function parsePeriod(period, hasQuery) {
1701
- if (!period && hasQuery) period = '30d'
1702
- if (!period) return null
1703
- if (period === 'all') return null
1704
- if (period === 'last') return { mode: 'last' }
1705
- // Calendar-day windows: 'today' anchors at local midnight rather than
1706
- // rolling 24h. Without this, a query asking 'today' at 01:30 would silently
1707
- // include yesterday's last 22.5h of activity, mislabelling them as
1708
- // 'today's work'. 'yesterday' is the previous calendar day.
1709
- if (period === 'today') {
1710
- const start = new Date()
1711
- start.setHours(0, 0, 0, 0)
1712
- return { startMs: start.getTime(), endMs: Date.now() }
1713
- }
1714
- if (period === 'yesterday') {
1715
- const start = new Date()
1716
- start.setDate(start.getDate() - 1)
1717
- start.setHours(0, 0, 0, 0)
1718
- const end = new Date(start)
1719
- end.setHours(23, 59, 59, 999)
1720
- return { startMs: start.getTime(), endMs: end.getTime() }
1721
- }
1722
- if (period === 'this_week' || period === 'last_week') {
1723
- // R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
1724
- // convention. Replaces R5 rolling 7-14d range which was empty for
1725
- // sessions where "last week" decisions actually fell on Mon (4/27) of
1726
- // this week. Precise calendar bounds match natural-language intuition.
1727
- const d = new Date()
1728
- d.setHours(0, 0, 0, 0)
1729
- const dayOfWeek = d.getDay()
1730
- const daysSinceMon = (dayOfWeek + 6) % 7
1731
- const thisWeekMon = new Date(d)
1732
- thisWeekMon.setDate(d.getDate() - daysSinceMon)
1733
- if (period === 'this_week') {
1734
- return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
1735
- }
1736
- const lastWeekMon = new Date(thisWeekMon)
1737
- lastWeekMon.setDate(thisWeekMon.getDate() - 7)
1738
- const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
1739
- return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
1740
- }
1741
- const relMatch = period.match(/^(\d+)(m|h|d)$/)
1742
- if (relMatch) {
1743
- const n = parseInt(relMatch[1])
1744
- const unit = relMatch[2]
1745
- const now = new Date()
1746
- if (unit === 'm') {
1747
- // Minute granularity is for "resume from the previous turn / pick
1748
- // up where we left off" style recall — sub-hour windows where 1h
1749
- // is too coarse. n=0 is invalid (the regex requires \d+ which
1750
- // matches "0" but a zero-width window returns no rows; leave that
1751
- // as caller-supplied no-op).
1752
- const start = new Date(now.getTime() - n * 60_000)
1753
- return { startMs: start.getTime(), endMs: now.getTime() }
1754
- }
1755
- if (unit === 'h') {
1756
- const start = new Date(now.getTime() - n * 3600_000)
1757
- return { startMs: start.getTime(), endMs: now.getTime() }
1758
- }
1759
- const start = new Date(now)
1760
- start.setDate(start.getDate() - n)
1761
- return { startMs: start.getTime(), endMs: now.getTime() }
1762
- }
1763
- const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
1764
- if (rangeMatch) {
1765
- return {
1766
- startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
1767
- endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
1768
- }
1769
- }
1770
- const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
1771
- if (dateMatch) {
1772
- return {
1773
- startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
1774
- endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
1775
- exact: true,
1776
- }
1777
- }
1778
- return null
1779
- }
1780
-
1781
- function formatTs(tsMs) {
1782
- const n = Number(tsMs)
1783
- if (Number.isFinite(n) && n > 1e12) {
1784
- return new Date(n).toLocaleString('sv-SE').slice(0, 16)
1785
- }
1786
- return String(tsMs ?? '').slice(0, 16)
1787
- }
1788
-
1789
- const CORE_RECALL_STOPWORDS = new Set([
1790
- 'about', 'after', 'again', 'before', 'check', 'color', 'decision', 'decided',
1791
- 'earlier', 'memory', 'previous', 'routing', 'stored', 'tell', 'what',
1792
- ])
1793
-
1794
- function coreRecallTerms(query) {
1795
- return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) || [])]
1796
- .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
1797
- .slice(0, 8)
1798
- }
1799
-
1800
- function normalizeRecallProjectScope(projectScope) {
1801
- const raw = String(projectScope || 'common').trim()
1802
- if (!raw || raw.toLowerCase() === 'common') return null
1803
- if (raw.toLowerCase() === 'all') return '*'
1804
- return raw
1805
- }
1806
-
1807
1746
  async function recallCoreRows(query, { projectScope, category, limit } = {}) {
1808
1747
  const terms = coreRecallTerms(query)
1809
1748
  if (terms.length === 0) return []
@@ -1864,9 +1803,11 @@ async function recallCoreRows(query, { projectScope, category, limit } = {}) {
1864
1803
  // (memory-cycle1.mjs:394) — other sessions' pending rows are left for the
1865
1804
  // periodic cycle1 sweep. Reuses recall-fasttrack's drainSessionCycle1
1866
1805
  // (session/compact.mjs:504) which loops cycle1 until raw rows hit 0 or a
1867
- // no-progress pass. Safety limits: a 2,000-row hard cap (both in the pending
1806
+ // no-progress pass. Safety limits: a 500-row hard cap (both in the pending
1868
1807
  // pre-check and as the per-call cycle1 batch ceiling) and cooperative
1869
1808
  // abort-signal propagation through handleMemoryAction — no time budget.
1809
+ // Window 20 rows per classifier prompt × concurrency 5 (haiku — cost is
1810
+ // negligible; 500 rows ≈ 25 prompts ≈ 5 waves).
1870
1811
  //
1871
1812
  // Multi-terminal safety: the memory daemon is one shared instance across
1872
1813
  // terminals (active-instance.json memory_port). Concurrent recall(query)
@@ -1875,8 +1816,8 @@ async function recallCoreRows(query, { projectScope, category, limit } = {}) {
1875
1816
  // advisory-lock/coalesce guard in memory-cycle1.mjs still serializes actual
1876
1817
  // DB writes). A drain only gates recall for its OWN session — every other
1877
1818
  // terminal's recall (different session, or no session) proceeds unblocked.
1878
- const RECALL_DRAIN_HARD_CAP_ROWS = 2000
1879
- async function _drainSessionForRecallQuery(sessionId, signal) {
1819
+ const RECALL_DRAIN_HARD_CAP_ROWS = 500
1820
+ async function _drainSessionForRecallQuery(sessionId, signal, deadlineMs = 0) {
1880
1821
  if (!sessionId || signal?.aborted) return
1881
1822
  const existing = _recallDrainInFlight.get(sessionId)
1882
1823
  if (existing) { try { await existing } catch {} return }
@@ -1897,14 +1838,15 @@ async function _drainSessionForRecallQuery(sessionId, signal) {
1897
1838
  action: 'dump_session_roots',
1898
1839
  sessionId,
1899
1840
  includeRaw: true,
1900
- limit: Math.min(RECALL_DRAIN_HARD_CAP_ROWS, Math.max(1000, pendingCount)),
1841
+ limit: Math.min(RECALL_DRAIN_HARD_CAP_ROWS, Math.max(1, pendingCount)),
1901
1842
  }
1902
1843
  const drainPromise = (async () => {
1903
1844
  try {
1904
1845
  return await drainSessionCycle1(runTool, {
1905
1846
  sessionId,
1906
1847
  dumpArgs,
1907
- deadlineMs: 0, // no time budget drive the session's backlog to 0
1848
+ deadlineMs, // 0 = no time budget (query path); bare browse passes a
1849
+ // short bound so a browse call never blocks minutes on LLM chunking.
1908
1850
  maxPasses: 20, // safety stopper; drainSessionCycle1 also self-stops on no-progress
1909
1851
  cycleArgs: {
1910
1852
  min_batch: 1,
@@ -1912,7 +1854,7 @@ async function _drainSessionForRecallQuery(sessionId, signal) {
1912
1854
  batch_size: RECALL_DRAIN_HARD_CAP_ROWS,
1913
1855
  rows_per_session: RECALL_DRAIN_HARD_CAP_ROWS,
1914
1856
  window_size: 20,
1915
- concurrency: 4,
1857
+ concurrency: 5,
1916
1858
  },
1917
1859
  })
1918
1860
  } catch (err) {
@@ -1928,23 +1870,46 @@ async function _drainSessionForRecallQuery(sessionId, signal) {
1928
1870
  }
1929
1871
  }
1930
1872
 
1873
+ // Bare-browse drain deadline: best-effort only. A browse call ("show me
1874
+ // recent context") must never block on LLM chunking for minutes the way a
1875
+ // query recall is allowed to — the raw-row merge already covers unchunked
1876
+ // content, so the drain here is a nice-to-have upgrade to chunked/scored
1877
+ // rows, not a correctness requirement. Failures/timeouts are silently
1878
+ // tolerated; the raw fallback in the caller applies regardless.
1879
+ const RECALL_BROWSE_DRAIN_DEADLINE_MS = 1800
1880
+
1931
1881
  async function handleSearch(args, signal) {
1932
1882
  // Cooperative abort check: throw early if the caller already aborted
1933
1883
  // (IPC cancel handler signals the AbortController before re-entry).
1934
1884
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1935
1885
  // Drain this session's unchunked backlog to zero BEFORE searching, so a
1936
- // query recall never returns raw/unclassified transcript tail instead of
1937
- // the chunked summary. Only runs when both a session and a query are
1938
- // present; a bare recent-browsing call (no query) skips this entirely.
1886
+ // recall never returns raw/unclassified transcript tail instead of the
1887
+ // chunked summary. Runs whenever a session is scoped, query or not — a
1888
+ // bare "show recent" browse hits raw unchunked rows just as often as a
1889
+ // query recall does. Query path keeps the unbounded (deadlineMs:0) drain
1890
+ // since chunked accuracy matters more than latency there; bare browse
1891
+ // gets a short best-effort deadline — it must never block on LLM
1892
+ // chunking for minutes, the raw-row merge covers the fallback either way.
1939
1893
  {
1940
1894
  const _drainSessionId = String(args?.sessionId || args?.session_id || '').trim()
1941
1895
  const _drainHasQuery = Array.isArray(args?.query)
1942
1896
  ? args.query.some((v) => String(v || '').trim())
1943
1897
  : String(args?.query ?? '').trim() !== ''
1944
- if (_drainSessionId && _drainHasQuery) {
1945
- await _drainSessionForRecallQuery(_drainSessionId, signal)
1898
+ if (_drainSessionId) {
1899
+ await _drainSessionForRecallQuery(
1900
+ _drainSessionId,
1901
+ signal,
1902
+ _drainHasQuery ? 0 : RECALL_BROWSE_DRAIN_DEADLINE_MS,
1903
+ )
1946
1904
  }
1947
1905
  }
1906
+ // #id lookup normalization: search_memories and memory action:'search'
1907
+ // callers pass a single `id` (or an id array under that same key), not
1908
+ // the `ids` array below. Normalize once here so every dispatch path gets
1909
+ // exact-id lookup, not just callers who already knew to use `ids`.
1910
+ if (!Array.isArray(args.ids) && args.id != null) {
1911
+ args = { ...args, ids: Array.isArray(args.id) ? args.id : [args.id] }
1912
+ }
1948
1913
  if (args?.currentSession === true || args?.sessionId || args?.session_id) {
1949
1914
  return await recallSessionRows(args)
1950
1915
  }
@@ -1956,7 +1921,7 @@ async function handleSearch(args, signal) {
1956
1921
  if (Array.isArray(args.ids) && args.ids.length > 0) {
1957
1922
  const ids = args.ids
1958
1923
  .map(v => Number(v))
1959
- .filter(v => Number.isFinite(v) && v > 0)
1924
+ .filter(v => Number.isInteger(v) && v > 0)
1960
1925
  if (ids.length === 0) return { text: '(no valid ids)' }
1961
1926
  const includeArchived = args.includeArchived !== false
1962
1927
  const category = args.category
@@ -2096,7 +2061,15 @@ async function handleSearch(args, signal) {
2096
2061
  offset = Math.min(RECALL_OFFSET_CAP, offset)
2097
2062
  }
2098
2063
  const recallCapPrefix = recallCapNotes.length ? `${recallCapNotes.join('; ')}\n` : ''
2099
- const sort = args.sort != null ? String(args.sort) : 'importance'
2064
+ // Recent-browsing default: a query-less recall is a "show me the latest
2065
+ // messages" browse, not a relevance search — chronological order is the
2066
+ // only ordering that makes sense there, so sort defaults to 'date' when
2067
+ // no query is present (explicit args.sort still wins). Query recalls keep
2068
+ // the importance default.
2069
+ const hasQueryForSort = Array.isArray(args.query)
2070
+ ? args.query.some((v) => String(v || '').trim())
2071
+ : String(args.query ?? '').trim() !== ''
2072
+ const sort = args.sort != null ? String(args.sort) : (hasQueryForSort ? 'importance' : 'date')
2100
2073
  // Chunk content is the primary recall output. Members default to true so
2101
2074
  // callers receive the raw chunk leaves (the cycle1-produced semantic
2102
2075
  // chunks) rather than just the root's cycle2-compressed summary line.
@@ -2260,7 +2233,16 @@ async function handleSearch(args, signal) {
2260
2233
 
2261
2234
  const filters = { limit: limit + offset }
2262
2235
  if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
2263
- if (temporal?.mode === 'last' && _bootTimestamp) {
2236
+ // period='last' used to hard-cap ts_to at _bootTimestamp-1 unconditionally,
2237
+ // which silently hid the CURRENT session's own content from a query-less
2238
+ // recent browse (the whole point of "what did we just talk about"). Keep
2239
+ // the boot cap only for session-less global browsing, where 'last' means
2240
+ // "the previously completed session" and there's no session-scoped drain
2241
+ // above to have already surfaced the in-flight rows. When a sessionId is
2242
+ // present the drain above already pulled that session's raw backlog to
2243
+ // zero, so the current session's rows are safe (and wanted) to include.
2244
+ const _hasScopedSession = Boolean(String(args?.sessionId || args?.session_id || '').trim())
2245
+ if (temporal?.mode === 'last' && _bootTimestamp && !_hasScopedSession) {
2264
2246
  filters.ts_to = _bootTimestamp - 1
2265
2247
  }
2266
2248
  filters.projectScope = projectScope
@@ -2269,55 +2251,33 @@ async function handleSearch(args, signal) {
2269
2251
  if (!includeArchived) filters.excludeStatuses = ['archived']
2270
2252
  if (includeMembers) filters.includeMembers = true
2271
2253
  const rows = await retrieveEntries(db, filters)
2272
- const sliced = rows.slice(offset, offset + limit)
2273
- return { text: recallCapPrefix + renderEntryLines(sliced) }
2274
- }
2275
-
2276
- function renderEntryLines(rows) {
2277
- if (!rows || rows.length === 0) return '(no results)'
2278
- const lines = []
2279
- // Bound total emitted lines (roots x members) so a many-member recall can't
2280
- // inject unbounded output. Per-line content is already capped at 1000 chars;
2281
- // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
2282
- const RECALL_LINE_CAP = 200
2283
- let _capped = false
2284
- outer:
2285
- for (const r of rows) {
2286
- const hasMembers = Array.isArray(r.members) && r.members.length > 0
2287
- if (hasMembers) {
2288
- // Chunks present: emit each member as its own line. Root row is a
2289
- // grouping artifact for retrieval the caller wants the chunk
2290
- // content (cycle1 raw), not the cycle2-compressed summary.
2291
- for (const m of r.members) {
2292
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
2293
- const mTs = formatTs(m.ts)
2294
- const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
2295
- const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
2296
- lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
2297
- }
2298
- } else {
2299
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
2300
- // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
2301
- // the row itself in the same shape. element/summary fall back to
2302
- // raw content when both are absent.
2303
- const ts = formatTs(r.ts)
2304
- const element = r.element ?? ''
2305
- const summary = r.summary ?? ''
2306
- // Standalone leaf rows (is_root=0, no parent chunks_root resolved
2307
- // into a `members` list) carry their u/a role just like inline
2308
- // chunk members — surface it so the format stays consistent across
2309
- // the two emission paths.
2310
- const rolePrefix = r.is_root === 0 && r.role
2311
- ? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
2312
- : ''
2313
- const body = element || summary
2314
- ? `${element}${summary ? ' — ' + summary : ''}`
2315
- : cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
2316
- lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)} #${r.id}`)
2254
+ // Recent-browsing raw merge: a query-less recall must show the freshest
2255
+ // turns even when cycle1 hasn't chunked them yet. Roots lag ingest by up
2256
+ // to a cycle interval, so on sort=date pull the raw (unchunked) window
2257
+ // too and merge chronologically — original text first, no summaries.
2258
+ // Query-less + includeRaw:false callers keep the roots-only view.
2259
+ let merged = rows
2260
+ if (sort === 'date' && args.includeRaw !== false) {
2261
+ const rawRows = await readRawRowsInWindow(
2262
+ db,
2263
+ temporal?.startMs ?? null,
2264
+ temporal?.endMs ?? (filters.ts_to ?? Date.now()),
2265
+ Math.min(500, Math.max(20, limit + offset)),
2266
+ { projectScope },
2267
+ )
2268
+ const seenIds = new Set(rows.map(r => Number(r.id)))
2269
+ // Drop raw leaves already inlined as some returned root's member.
2270
+ for (const r of rows) {
2271
+ if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
2272
+ }
2273
+ const newRaw = rawRows.filter(r => !seenIds.has(Number(r.id)))
2274
+ if (newRaw.length > 0) {
2275
+ merged = [...rows, ...newRaw]
2276
+ merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
2317
2277
  }
2318
2278
  }
2319
- if (_capped) lines.push(`[recall truncated showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
2320
- return lines.join('\n')
2279
+ const sliced = merged.slice(offset, offset + limit)
2280
+ return { text: recallCapPrefix + renderEntryLines(sliced) }
2321
2281
  }
2322
2282
 
2323
2283
  async function dumpSessionRootChunks(args = {}) {
@@ -2951,11 +2911,57 @@ async function handleMemoryAction(args, signal) {
2951
2911
 
2952
2912
  if (action === 'core') {
2953
2913
  const op = String(args.op ?? '').trim().toLowerCase()
2954
- if (!['add', 'edit', 'delete', 'list'].includes(op)) {
2955
- return { text: 'core requires op: "add" | "edit" | "delete" | "list"', isError: true }
2914
+ if (!['add', 'edit', 'delete', 'list', 'candidates', 'promote', 'dismiss'].includes(op)) {
2915
+ return { text: 'core requires op: "add" | "edit" | "delete" | "list" | "candidates" | "promote" | "dismiss"', isError: true }
2956
2916
  }
2957
2917
  const dataDir = (typeof DATA_DIR === 'string' ? DATA_DIR : resolvePluginData())
2958
2918
  if (!dataDir) return { text: 'core: memory data dir is not initialized', isError: true }
2919
+ // Core-candidate promotion pipeline (proposal mode). The candidate flag
2920
+ // lives on generated `entries`, which carry a project_id, so these ops MUST
2921
+ // be project-scoped just like add/edit/delete — an unscoped listing/promote
2922
+ // would leak candidates across projects. project_id resolution mirrors the
2923
+ // block below: 'common'/null → COMMON (project_id NULL), '*' → all pools
2924
+ // (candidates op only, same escape hatch as op:'list'). UI calls exactly
2925
+ // these op names.
2926
+ if (op === 'candidates' || op === 'promote' || op === 'dismiss') {
2927
+ const hasPid = Object.prototype.hasOwnProperty.call(args, 'project_id')
2928
+ const scope = (() => {
2929
+ if (!hasPid || args.project_id == null) return null
2930
+ const s = String(args.project_id).trim()
2931
+ if (s === '' || s.toLowerCase() === 'common') return null
2932
+ if (s === '*') return '*'
2933
+ return s
2934
+ })()
2935
+ try {
2936
+ if (op === 'candidates') {
2937
+ const list = await listCoreCandidates(dataDir, scope)
2938
+ if (list.length === 0) return { text: 'core candidates: none' }
2939
+ return {
2940
+ text: list.map(c =>
2941
+ // project=<pool> lets the UI thread project_id into the follow-up
2942
+ // promote/dismiss call — matters under project_id:'*' listing where
2943
+ // rows span pools. Uses the same COMMON/slug convention as op:'list'.
2944
+ `id=${c.id} project=${c.project_id == null ? 'COMMON' : c.project_id} [${c.category}] score=${c.score == null ? '-' : c.score.toFixed(2)} ${c.element} — ${String(c.summary || '').slice(0, 200)} (${c.reason})`,
2945
+ ).join('\n'),
2946
+ }
2947
+ }
2948
+ // promote/dismiss operate on a single id but are scope-guarded: the
2949
+ // candidate must belong to the resolved scope (or COMMON), never '*'.
2950
+ if (scope === '*') {
2951
+ return { text: `core ${op}: project_id "*" only valid for op="candidates"`, isError: true }
2952
+ }
2953
+ if (op === 'promote') {
2954
+ const entry = await promoteCoreCandidate(dataDir, args.id, { ...args, scope })
2955
+ const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
2956
+ return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: [${entry.category}] ${entry.element}` }
2957
+ }
2958
+ // dismiss
2959
+ const removed = await dismissCoreCandidate(dataDir, args.id, { scope })
2960
+ return { text: `core candidate dismissed (id=${removed.id}): [${removed.category}] ${removed.element}` }
2961
+ } catch (e) {
2962
+ return { text: `core ${op} failed: ${e.message}`, isError: true }
2963
+ }
2964
+ }
2959
2965
  // Local trim helper — the manage-block trimOrNull at :1807 is scoped to
2960
2966
  // that branch and unreachable from here.
2961
2967
  // Normalize project_id: 'common' (case-insensitive) or null → null (COMMON pool); non-empty string → slug.
@@ -3222,37 +3228,6 @@ function createHttpMcpServer() {
3222
3228
  return s
3223
3229
  }
3224
3230
 
3225
- function readBody(req) {
3226
- return new Promise((resolve, reject) => {
3227
- const chunks = []
3228
- req.on('data', c => chunks.push(c))
3229
- req.on('end', () => {
3230
- const raw = Buffer.concat(chunks).toString('utf8').trim()
3231
- if (!raw) { resolve({}); return }
3232
- try { resolve(JSON.parse(raw)) }
3233
- catch (error) {
3234
- const e = new Error(`invalid JSON body: ${error.message}`)
3235
- e.statusCode = 400
3236
- reject(e)
3237
- }
3238
- })
3239
- req.on('error', reject)
3240
- })
3241
- }
3242
-
3243
- function sendJson(res, data, status = 200) {
3244
- const body = JSON.stringify(data)
3245
- res.writeHead(status, {
3246
- 'Content-Type': 'application/json; charset=utf-8',
3247
- 'Content-Length': Buffer.byteLength(body),
3248
- })
3249
- res.end(body)
3250
- }
3251
-
3252
- function sendError(res, msg, status = 500) {
3253
- sendJson(res, { error: msg }, status)
3254
- }
3255
-
3256
3231
  async function awaitRuntimeReadyForHttp(res) {
3257
3232
  if (_initialized) return true
3258
3233
  if (!_initPromise) {
@@ -3268,29 +3243,6 @@ async function awaitRuntimeReadyForHttp(res) {
3268
3243
  }
3269
3244
  }
3270
3245
 
3271
- // Origin/Referer guard for /admin/* mutation routes. Memory-service binds
3272
- // 127.0.0.1, but browser DNS-rebinding or a stray cross-origin fetch could
3273
- // still reach destructive endpoints (purge, backfill, entry mutations).
3274
- // Server-to-server callers (setup-server, hooks) issue raw http.request
3275
- // without a browser Origin/Referer, so absent headers pass; any non-loopback
3276
- // Origin/Referer is rejected. Mirrors setup-server.mjs isAllowedOrigin.
3277
- function isLocalOrigin(req) {
3278
- const LOOP = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i
3279
- const origin = req.headers.origin || ''
3280
- const referer = req.headers.referer || ''
3281
- if (origin && !LOOP.test(origin)) return false
3282
- if (referer && !LOOP.test(referer)) return false
3283
- return true
3284
- }
3285
-
3286
- function normalizeCoreProjectId(value, { allowStar = false } = {}) {
3287
- if (value == null) return null
3288
- const s = String(value).trim()
3289
- if (!s || s.toLowerCase() === 'common') return null
3290
- if (allowStar && s === '*') return '*'
3291
- return s
3292
- }
3293
-
3294
3246
  async function buildSessionCoreMemoryPayload(cwd) {
3295
3247
  const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
3296
3248
  const generatedScopeClause = projectId !== null
@@ -3373,6 +3325,9 @@ const httpServer = http.createServer(async (req, res) => {
3373
3325
  active_core_summaries: stats.active_core_summaries,
3374
3326
  active_core_summary_missing: stats.active_core_summary_missing,
3375
3327
  mv_hot_active_populated: stats.mv_hot_active_populated,
3328
+ cycle_running: _cycleRunning,
3329
+ cycle_health: _cycleHealth,
3330
+ cycle_backlog: _cycleBacklogSnapshot,
3376
3331
  })
3377
3332
  } catch (e) { sendError(res, e.message) }
3378
3333
  return