mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,901 @@
1
+ // Memory action + tool-call handlers extracted from index.mjs.
2
+ //
3
+ // The write/maintenance action cluster: the per-action `_handleMem*` helpers,
4
+ // the `manage`/`core`/`purge`/`retro_eval_active` inline branches, and the
5
+ // `memory`/`search_memories`/`recall` tool dispatch. Pure cycle/store/score
6
+ // helpers are imported directly; live DB handle, config reader, cycle
7
+ // scheduler primitives, cycle-LLM adapters, query handlers, and the transcript
8
+ // ingest helpers are injected so the facade keeps ownership of `db`, the
9
+ // scheduler, and lifecycle state. The whole-action backfill mutex lives here
10
+ // (facade-local previously) since it only guards this module's backfill path.
11
+
12
+ import {
13
+ runCycle1,
14
+ runCycle2,
15
+ runCycle3,
16
+ runUnifiedGate,
17
+ syncRootEmbedding,
18
+ applySimpleStatus,
19
+ applyUpdate,
20
+ applyMerge,
21
+ CYCLE2_ACTIVE_TARGET_CAP,
22
+ } from './memory-cycle.mjs'
23
+ import { getInFlightCycle1 } from './memory-cycle1.mjs'
24
+ import { pruneOldEntries } from './memory-maintenance-store.mjs'
25
+ import { computeEntryScore } from './memory-score.mjs'
26
+ import { runFullBackfill } from './memory-ops-policy.mjs'
27
+ import {
28
+ listCore,
29
+ addCore,
30
+ editCore,
31
+ deleteCore,
32
+ listCoreCandidates,
33
+ promoteCoreCandidate,
34
+ dismissCoreCandidate,
35
+ CORE_SUMMARY_MAX,
36
+ } from './core-memory-store.mjs'
37
+ import { resolveProjectScope } from './project-id-resolver.mjs'
38
+ import { resolvePluginData } from '../../shared/plugin-paths.mjs'
39
+ import { getMetaValue, isBootstrapComplete } from './memory.mjs'
40
+
41
+ export function createMemoryActionHandlers({
42
+ getDb,
43
+ dataDir,
44
+ log,
45
+ readMainConfig,
46
+ getCycleLastRun,
47
+ ingestSessionMessages,
48
+ entryStats,
49
+ handleSearch,
50
+ dumpSessionRootChunks,
51
+ awaitCycle1Run,
52
+ startCycle1Run,
53
+ finalizeCycle2Run,
54
+ finalizeCycle3Run,
55
+ getSchedulerCycle1InFlight,
56
+ getCycle2CallLlm,
57
+ getCycle3CallLlm,
58
+ ingestTranscriptFile,
59
+ cwdFromTranscriptPath,
60
+ }) {
61
+ const DATA_DIR = dataDir
62
+
63
+ // Whole-action backfill mutex. memory-cycle1's _cycle1InFlight only protects
64
+ // cycle1; ingest workers (memory-ops-policy.mjs) and cycle2 can still overlap
65
+ // if a second backfill kicks in (e.g. setup-server timeout + retry). Track the
66
+ // in-flight promise here and reject overlaps with 409.
67
+ let _backfillInFlight = null
68
+
69
+ async function _handleMemCycle1(args, config, signal) {
70
+ const minBatchOverride = Number(args?.min_batch)
71
+ const sessionCapOverride = Number(args?.session_cap)
72
+ const batchSizeOverride = Number(args?.batch_size)
73
+ const windowSizeOverride = Number(args?.window_size ?? args?.windowSize)
74
+ const rowsPerSessionOverride = Number(args?.rows_per_session ?? args?.rowsPerSession ?? args?.max_rows_per_session ?? args?.maxRowsPerSession)
75
+ const concurrencyOverride = Number(args?.concurrency)
76
+ const sessionIdOverride = String(args?.sessionId ?? args?.session_id ?? '').trim()
77
+ const baseCycle1 = config?.cycle1 || {}
78
+ let cycle1Config = baseCycle1
79
+ // _runCycle1Impl reads `config?.min_batch ?? config?.cycle1?.min_batch ??
80
+ // default` — top-level wins, so pin the override at top-level only.
81
+ if (Number.isFinite(minBatchOverride) && minBatchOverride > 0) {
82
+ cycle1Config = { ...cycle1Config, min_batch: minBatchOverride }
83
+ }
84
+ if (Number.isFinite(sessionCapOverride) && sessionCapOverride > 0) {
85
+ cycle1Config = { ...cycle1Config, session_cap: sessionCapOverride }
86
+ }
87
+ if (Number.isFinite(batchSizeOverride) && batchSizeOverride > 0) {
88
+ cycle1Config = { ...cycle1Config, batch_size: batchSizeOverride }
89
+ }
90
+ if (Number.isFinite(windowSizeOverride) && windowSizeOverride > 0) {
91
+ cycle1Config = { ...cycle1Config, window_size: windowSizeOverride }
92
+ }
93
+ if (Number.isFinite(rowsPerSessionOverride) && rowsPerSessionOverride > 0) {
94
+ cycle1Config = { ...cycle1Config, rows_per_session: rowsPerSessionOverride }
95
+ }
96
+ if (sessionIdOverride) {
97
+ cycle1Config = { ...cycle1Config, session_id: sessionIdOverride }
98
+ }
99
+ if (Number.isFinite(concurrencyOverride) && concurrencyOverride > 0) {
100
+ cycle1Config = { ...cycle1Config, concurrency: Math.min(8, Math.floor(concurrencyOverride)) }
101
+ }
102
+ const callerDeadlineMs = Number(args?._callerDeadlineMs) || 0
103
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
104
+ const cycle1Options = callerDeadlineMs > 0 ? { callerDeadlineMs, signal } : { signal }
105
+ if (typeof args?._callLlm === 'function') {
106
+ cycle1Options.callLlm = args._callLlm
107
+ }
108
+ const result = await awaitCycle1Run(
109
+ cycle1Config,
110
+ cycle1Options,
111
+ )
112
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
113
+ const pendingStr = result?.pendingRows != null ? result.pendingRows : 0
114
+ const inFlightStr = result?.skippedInFlight === true ? 'true' : 'false'
115
+ const timedOutPart = result?.timedOutWaiting === true ? ' timedOut=true' : ''
116
+ const omitted = Array.isArray(result?.omitted_row_ids) ? result.omitted_row_ids.length : Number(result?.quality?.omitted_rows || 0)
117
+ const prefiltered = Array.isArray(result?.prefiltered_row_ids) ? result.prefiltered_row_ids.length : Number(result?.quality?.prefiltered_rows || 0)
118
+ const failedRows = Array.isArray(result?.failed_row_ids) ? result.failed_row_ids.length : Number(result?.quality?.failed_rows || 0)
119
+ const invalidChunks = Array.isArray(result?.invalid_chunks) ? result.invalid_chunks.length : Number(result?.quality?.invalid_chunks || 0)
120
+ return {
121
+ ...result,
122
+ text: `cycle1: chunks=${result.chunks} processed=${result.processed} skipped_chunks=${result.skipped}` +
123
+ ` omitted=${omitted} prefiltered=${prefiltered} failed_rows=${failedRows} invalid_chunks=${invalidChunks}` +
124
+ ` pending=${pendingStr} inFlight=${inFlightStr}${timedOutPart}`,
125
+ }
126
+ }
127
+
128
+ async function _handleMemCycle2(args, config, signal) {
129
+ const db = getDb()
130
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
131
+ let c2Options = { signal }
132
+ if (typeof c2Options?.callLlm !== 'function') {
133
+ c2Options = { ...c2Options, callLlm: getCycle2CallLlm() }
134
+ }
135
+ const result = await runCycle2(db, config?.cycle2 || {}, c2Options, DATA_DIR)
136
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
137
+ await finalizeCycle2Run(result)
138
+ const counts = {
139
+ promoted: result?.promoted || 0,
140
+ archived: result?.archived || 0,
141
+ merged: result?.merged || 0,
142
+ updated: result?.updated || 0,
143
+ kept: result?.kept || 0,
144
+ rejected_verb: result?.rejected_verb || 0,
145
+ merge_rejected: result?.merge_rejected || 0,
146
+ missing_core: result?.missing_core_summary || 0,
147
+ core_backfill: result?.core_embedding_backfill || 0,
148
+ cascade_drop: result?.cascade?.dropped || 0,
149
+ phase_merge: result?.phase_merge?.merged || 0,
150
+ core_overlap: result?.phase_merge?.core_overlap || 0,
151
+ }
152
+ const parts = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`)
153
+ if (parts.length) return { text: `cycle2 ${parts.join(' ')}` }
154
+ // No applied counts — disambiguate the "noop" so a broken gate is visible
155
+ // instead of looking like a clean, nothing-to-do run.
156
+ let cause = ''
157
+ if (result?.skippedInFlight) cause = ' (skipped: in-flight)'
158
+ else if (result?.ok === false) cause = ` (error: ${result.error || 'unknown'})`
159
+ else if (result?.gate_failed) cause = ' (gate_failed)'
160
+ return { text: `cycle2 noop${cause}` }
161
+ }
162
+
163
+ async function _handleMemCycle3(args, config, signal) {
164
+ const db = getDb()
165
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
166
+ const confirmed = args?.confirm === 'APPLY CYCLE3'
167
+ const requestedMode = typeof args?.cycle3Mode === 'string' ? args.cycle3Mode : null
168
+ const applyMode = confirmed
169
+ ? 'confirmed'
170
+ : (requestedMode === 'proposal' || requestedMode === 'dry-run' || requestedMode === 'dryrun')
171
+ ? 'proposal'
172
+ : 'conservative'
173
+ let c3Options = { signal, apply: confirmed ? true : undefined, applyMode }
174
+ if (typeof c3Options?.callLlm !== 'function') {
175
+ c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
176
+ }
177
+ const result = await runCycle3(db, config || {}, DATA_DIR, c3Options)
178
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
179
+ // Stamp cycle3 success: the MCP path bypasses the scheduler's coalesced
180
+ // onCoalescedSuccess, so without this last_success_at stayed 0 despite
181
+ // successful runs.
182
+ await finalizeCycle3Run(result)
183
+ const parts = ['reviewed', 'kept', 'updated', 'merged', 'deleted']
184
+ .map(k => `${k}=${result?.[k] || 0}`)
185
+ if (result?.proposed) {
186
+ parts.push(`proposal_update=${result.proposed.updated || 0}`)
187
+ parts.push(`proposal_merge=${result.proposed.merged || 0}`)
188
+ parts.push(`proposal_delete=${result.proposed.deleted || 0}`)
189
+ }
190
+ if (result?.held) {
191
+ parts.push(`held_update=${result.held.updated || 0}`)
192
+ parts.push(`held_merge=${result.held.merged || 0}`)
193
+ parts.push(`held_delete=${result.held.deleted || 0}`)
194
+ }
195
+ parts.push(`mode=${result?.applyMode || applyMode}`)
196
+ parts.push(`applied=${result?.applied === true ? 'true' : 'false'}`)
197
+ if (result?.skippedInFlight) parts.push('inFlight=true')
198
+ const errPart = result?.error ? ` error=${result.error}` : ''
199
+ return { text: `cycle3 ${parts.join(' ')}${errPart}` }
200
+ }
201
+
202
+ async function _handleMemFlush(args, config, signal) {
203
+ const db = getDb()
204
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
205
+ const r1 = await awaitCycle1Run(config?.cycle1 || {}, { signal })
206
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
207
+ let flushC2Options = { signal }
208
+ if (typeof flushC2Options?.callLlm !== 'function') {
209
+ flushC2Options = { ...flushC2Options, callLlm: getCycle2CallLlm() }
210
+ }
211
+ const r2 = await runCycle2(db, config?.cycle2 || {}, flushC2Options, DATA_DIR)
212
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
213
+ await finalizeCycle2Run(r2)
214
+ return { text: `flush: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
215
+ }
216
+
217
+ async function _handleMemStatus(args, config) {
218
+ const db = getDb()
219
+ const stats = await entryStats()
220
+ const last = await getCycleLastRun()
221
+ let dims = 0
222
+ let dimsErr = null
223
+ try {
224
+ const raw = await getMetaValue(db, 'embedding.current_dims', null)
225
+ if (raw != null) dims = Number(JSON.parse(raw))
226
+ if (!Number.isFinite(dims)) dims = 0
227
+ } catch (e) {
228
+ // Surface the error in the status line instead of masquerading a meta
229
+ // read failure as dims=0 (which is indistinguishable from a fresh,
230
+ // pre-bootstrap DB). Keep status callable so other lines still render.
231
+ dims = 0
232
+ dimsErr = e?.message || String(e)
233
+ }
234
+ const bootstrapComplete = await isBootstrapComplete(db)
235
+ const lastCycle1Ago = last.cycle1 ? `${Math.round((Date.now() - last.cycle1) / 60000)}m ago` : 'never'
236
+ const lastCycle2Ago = last.cycle2 ? `${Math.round((Date.now() - last.cycle2) / 60000)}m ago` : 'never'
237
+ const activeTargetCap = Number.isFinite(Number(config?.cycle2?.active_target_cap))
238
+ ? Number(config?.cycle2?.active_target_cap)
239
+ : CYCLE2_ACTIVE_TARGET_CAP
240
+ const mvState = stats.mv_hot_active_populated === null
241
+ ? 'missing'
242
+ : stats.mv_hot_active_populated ? 'populated' : 'unpopulated'
243
+ const lines = [
244
+ `entries: total=${stats.total} roots=${stats.roots} cycle1_raw=${stats.unchunked_leaves} (unchunked leaves) cycle2_pending=${stats.cycle2_pending_roots} (awaiting cycle2 review)`,
245
+ `status: ${stats.byStatus.map(r => `${r.status ?? '?'}:${r.c}`).join(', ') || 'empty'}`,
246
+ `categories(active): ${stats.byCategory.map(r => `${r.category ?? 'NULL'}:${r.c}`).join(', ') || 'empty'} active_target_cap=${activeTargetCap}`,
247
+ `core_memory: user=${stats.core_entries} embed_null=${stats.core_embed_null} active_core=${stats.active_core_summaries} active_missing_core=${stats.active_core_summary_missing}`,
248
+ `embedding_index: ready dims=${dims}${dimsErr ? ` (meta_read_error: ${dimsErr})` : ''}`,
249
+ `recall_index: mv_hot_active=${mvState}`,
250
+ `bootstrap: ${bootstrapComplete ? 'complete' : 'incomplete'}`,
251
+ `last_cycle1: ${lastCycle1Ago}`,
252
+ `last_cycle2: ${lastCycle2Ago}`,
253
+ ...(last.cycle2_last_error ? [`last_cycle2_error: ${last.cycle2_last_error}`] : []),
254
+ ]
255
+ return { text: lines.join('\n') }
256
+ }
257
+
258
+ async function _handleMemRebuild(args, config, signal) {
259
+ const db = getDb()
260
+ if (args.confirm !== 'REBUILD MEMORY') {
261
+ return { text: 'rebuild requires confirm: "REBUILD MEMORY" (truncates classification columns and re-runs cycles)', isError: true }
262
+ }
263
+ // Drain any pre-reset cycle1 BEFORE the destructive truncation so the
264
+ // post-reset run is not started concurrently against the same DB.
265
+ // _awaitCycle1Run() may release the outer handle on a caller deadline while
266
+ // the inner runCycle1 promise still owns the DB writes. Drain both layers,
267
+ // then loop once more if one layer exposed another promise while awaiting.
268
+ const drainedCycle1Promises = new Set()
269
+ for (;;) {
270
+ const pendingCycle1Promises = [getSchedulerCycle1InFlight(), getInFlightCycle1(db)]
271
+ .filter(p => p && !drainedCycle1Promises.has(p))
272
+ if (pendingCycle1Promises.length === 0) break
273
+ for (const pendingCycle1 of pendingCycle1Promises) {
274
+ drainedCycle1Promises.add(pendingCycle1)
275
+ try { await pendingCycle1 } catch {}
276
+ }
277
+ }
278
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
279
+ // Cleanup must run BEFORE demotion: the original order demoted normal
280
+ // roots (chunk_root = id) to is_root = 0 first, then ran the cleanup
281
+ // WHERE is_root = 1 — which missed exactly those demoted rows, leaving
282
+ // stale element/category/summary/score/embedding/summary_hash on rows that
283
+ // had just become raw leaves. Reorder so all roots get their classification
284
+ // columns cleared while is_root = 1 still selects them, then demote.
285
+ // Wrap the whole destructive sequence in one transaction so a mid-step
286
+ // failure rolls back rather than leaving a mixed state.
287
+ await db.transaction(async (tx) => {
288
+ await tx.query(`
289
+ UPDATE entries
290
+ SET element = NULL, category = NULL, summary = NULL,
291
+ status = 'pending', score = NULL, last_seen_at = NULL,
292
+ embedding = NULL, summary_hash = NULL,
293
+ core_summary = NULL, reviewed_at = NULL, promoted_at = NULL,
294
+ error_count = 0
295
+ WHERE is_root = 1
296
+ `)
297
+ await tx.query(`UPDATE entries SET chunk_root = NULL, is_root = 0 WHERE chunk_root = id`)
298
+ await tx.query(`UPDATE entries SET chunk_root = NULL WHERE is_root = 0`)
299
+ await tx.query(`
300
+ UPDATE entries
301
+ SET status = NULL,
302
+ element = NULL, category = NULL, summary = NULL,
303
+ score = NULL, last_seen_at = NULL,
304
+ embedding = NULL, summary_hash = NULL,
305
+ core_summary = NULL, reviewed_at = NULL, promoted_at = NULL,
306
+ error_count = 0
307
+ WHERE is_root = 0
308
+ `)
309
+ })
310
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
311
+ // Force a fresh post-reset cycle1: _cycle1InFlight is guaranteed null
312
+ // here (we drained above and have not awaited any cycle1-starting call
313
+ // since), so calling _startCycle1Run directly skips the coalesce branch
314
+ // inside _awaitCycle1Run and guarantees the newly demoted rows are read.
315
+ const r1 = await startCycle1Run(config?.cycle1 || {}, { signal })
316
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
317
+ let rebuildC2Options = { signal }
318
+ if (typeof rebuildC2Options?.callLlm !== 'function') {
319
+ rebuildC2Options = { ...rebuildC2Options, callLlm: getCycle2CallLlm() }
320
+ }
321
+ const r2 = await runCycle2(db, config?.cycle2 || {}, rebuildC2Options, DATA_DIR)
322
+ await finalizeCycle2Run(r2)
323
+ return { text: `rebuild: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
324
+ }
325
+
326
+ async function _handleMemPrune(args, _config) {
327
+ const db = getDb()
328
+ if (args.confirm !== 'PRUNE OLD ENTRIES') {
329
+ return { text: 'prune requires confirm: "PRUNE OLD ENTRIES" (permanently deletes unclassified entries older than maxDays)', isError: true }
330
+ }
331
+ const days = Math.max(1, Number(args.maxDays ?? 30))
332
+ const result = await pruneOldEntries(db, days)
333
+ return { text: `prune: deleted ${result.deleted} unclassified entries older than ${days} days` }
334
+ }
335
+
336
+ async function _handleMemBackfill(args, config, signal) {
337
+ const db = getDb()
338
+ // Whole-action mutex (transport-agnostic). _cycle1InFlight only protects
339
+ // cycle1; ingest workers + cycle2 can still overlap if a second backfill
340
+ // kicks in (timeout-retry, parallel callers, /api/tool vs /mcp vs
341
+ // /admin/backfill). Sentinel is set synchronously before any await so a
342
+ // burst of concurrent calls cannot all pass the check.
343
+ if (_backfillInFlight) {
344
+ return { text: 'backfill already in progress', isError: true }
345
+ }
346
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
347
+ const window = args.window != null ? String(args.window) : '7d'
348
+ const scope = args.scope != null ? String(args.scope) : 'all'
349
+ const limit = args.limit != null ? Math.max(1, Number(args.limit)) : null
350
+ // Capture the cycle2 envelope so we can route through _finalizeCycle2Run
351
+ // (which records cycle2_last_error and clears scheduler delay only on
352
+ // ok:true) rather than stamping cycle2 unconditionally afterward.
353
+ let _capturedCycle2
354
+ const promise = runFullBackfill(db, {
355
+ window,
356
+ scope,
357
+ limit,
358
+ config,
359
+ dataDir: DATA_DIR,
360
+ ingestTranscriptFile,
361
+ cwdFromTranscriptPath,
362
+ // Re-check the IPC cancel signal at every cycle1/cycle2 iteration the
363
+ // backfill driver dispatches. handleMemoryAction only checks once
364
+ // before dispatch; without per-iteration checkpoints a long-running
365
+ // backfill keeps spinning through ingest + cycle1 + cycle2 batches
366
+ // after the proxy has already responded "cancelled" to the caller.
367
+ runCycle1: (dbArg, cycle1Config = {}, options = {}, _dir) => {
368
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
369
+ return awaitCycle1Run(cycle1Config, { ...options, signal })
370
+ },
371
+ runCycle2: async (dbArg, c2Config, c2Options, c2DataDir) => {
372
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
373
+ let backfillC2Options = { ...c2Options, signal }
374
+ if (typeof backfillC2Options?.callLlm !== 'function') {
375
+ backfillC2Options = { ...backfillC2Options, callLlm: getCycle2CallLlm() }
376
+ }
377
+ const r2 = await runCycle2(dbArg, c2Config, backfillC2Options, c2DataDir)
378
+ _capturedCycle2 = r2
379
+ return r2
380
+ },
381
+ })
382
+ _backfillInFlight = promise
383
+ let result
384
+ try {
385
+ result = await promise
386
+ } finally {
387
+ if (_backfillInFlight === promise) _backfillInFlight = null
388
+ }
389
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
390
+ if (_capturedCycle2) {
391
+ await finalizeCycle2Run(_capturedCycle2)
392
+ }
393
+ return {
394
+ text: `backfill: window=${result.window} scope=${result.scope} files=${result.files} ingested=${result.ingested} cycle1_iters=${result.cycle1_iters} promoted=${result.promoted} unclassified=${result.unclassified}`,
395
+ }
396
+ }
397
+
398
+ async function handleMemoryAction(args, signal) {
399
+ const db = getDb()
400
+ // Cooperative abort check: surfaces caller-cancel (IPC cancel handler)
401
+ // before any long DB work begins on the worker side.
402
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
403
+ const action = String(args.action ?? '')
404
+ const config = readMainConfig()
405
+
406
+ if (action === 'status') {
407
+ return _handleMemStatus(args, config)
408
+ }
409
+
410
+ if (action === 'cycle1') {
411
+ return _handleMemCycle1(args, config, signal)
412
+ }
413
+
414
+ if (action === 'cycle2' || action === 'sleep') {
415
+ return _handleMemCycle2(args, config, signal)
416
+ }
417
+
418
+ if (action === 'cycle3') {
419
+ return _handleMemCycle3(args, config, signal)
420
+ }
421
+
422
+ // Direct semantic-search surface for callers that want raw ranked rows
423
+ // without going through the Lead-side recall synthesizer. The
424
+ // handleSearch executor is exposed through the public `memory` tool action
425
+ // `search` so callers can hit the hybrid CTE directly.
426
+ if (action === 'search') {
427
+ return handleSearch(args, signal)
428
+ }
429
+
430
+ if (action === 'flush') {
431
+ return _handleMemFlush(args, config, signal)
432
+ }
433
+
434
+ if (action === 'rebuild') {
435
+ return _handleMemRebuild(args, config, signal)
436
+ }
437
+
438
+ if (action === 'prune') {
439
+ return _handleMemPrune(args, config)
440
+ }
441
+
442
+ if (action === 'backfill') {
443
+ return _handleMemBackfill(args, config, signal)
444
+ }
445
+
446
+ if (action === 'ingest_session') {
447
+ return ingestSessionMessages(args)
448
+ }
449
+
450
+ if (action === 'dump_session_roots') {
451
+ return dumpSessionRootChunks(args)
452
+ }
453
+
454
+ if (action === 'manage') {
455
+ const op = String(args.op ?? '').trim().toLowerCase()
456
+ if (!['add', 'edit', 'delete'].includes(op)) {
457
+ return { text: 'manage requires op: "add" | "edit" | "delete"', isError: true }
458
+ }
459
+ const VALID_CAT = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
460
+ const VALID_STATUS = new Set(['pending', 'active', 'archived'])
461
+
462
+ if (op === 'add') {
463
+ const element = String(args.element ?? '').trim()
464
+ const summary = String(args.summary ?? args.element ?? '').trim()
465
+ const category = String(args.category ?? 'fact').trim().toLowerCase()
466
+ if (!element || !summary) {
467
+ return { text: 'manage add requires element and summary', isError: true }
468
+ }
469
+ if (!VALID_CAT.has(category)) {
470
+ return { text: `manage add: invalid category "${category}". Valid: ${[...VALID_CAT].join(', ')}`, isError: true }
471
+ }
472
+ const nowMs = Date.now()
473
+ const sourceRef = `manual:${nowMs}-${process.pid}`
474
+ const manageProjectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
475
+ try {
476
+ let newId
477
+ await db.transaction(async (tx) => {
478
+ const result = await tx.query(`
479
+ INSERT INTO entries(ts, role, content, source_ref, session_id, project_id)
480
+ VALUES ($1, 'system', $2, $3, NULL, $4)
481
+ RETURNING id
482
+ `, [nowMs, element + ' — ' + summary, sourceRef, manageProjectId])
483
+ newId = Number(result.rows[0].id)
484
+ const score = computeEntryScore(category, nowMs, nowMs)
485
+ await tx.query(`
486
+ UPDATE entries
487
+ SET chunk_root = $1, is_root = 1, element = $2, category = $3, summary = $4,
488
+ status = 'active', score = $5, last_seen_at = $6
489
+ WHERE id = $7
490
+ `, [newId, element, category, summary, score, nowMs, newId])
491
+ })
492
+ await syncRootEmbedding(db, newId)
493
+ return { text: `added (id=${newId}): [${category}] ${element} — ${summary.slice(0, 200)}` }
494
+ } catch (e) {
495
+ return { text: `manage add failed: ${e.message}`, isError: true }
496
+ }
497
+ }
498
+
499
+ if (op === 'edit') {
500
+ const id = Number(args.id)
501
+ if (!Number.isFinite(id) || id <= 0) {
502
+ return { text: 'manage edit requires numeric id', isError: true }
503
+ }
504
+ const existing = (await db.query(
505
+ `SELECT id, element, summary, category, status, ts, is_root FROM entries WHERE id = $1`,
506
+ [id]
507
+ )).rows[0]
508
+ if (!existing) return { text: `manage edit: no entry with id=${id}`, isError: true }
509
+ if (existing.is_root !== 1) return { text: `manage edit: id=${id} is not a root`, isError: true }
510
+
511
+ const trimOrNull = v => {
512
+ if (v == null) return null
513
+ const s = String(v).trim()
514
+ return s === '' ? null : s
515
+ }
516
+ const newElement = trimOrNull(args.element)
517
+ const newSummary = trimOrNull(args.summary)
518
+ const newCategory = trimOrNull(args.category)?.toLowerCase() ?? null
519
+ const newStatus = trimOrNull(args.status)?.toLowerCase() ?? null
520
+
521
+ if (!newElement && !newSummary && !newCategory && !newStatus) {
522
+ return { text: 'manage edit requires at least one field: element, summary, category, status', isError: true }
523
+ }
524
+ if (newCategory && !VALID_CAT.has(newCategory)) {
525
+ return { text: `manage edit: invalid category "${newCategory}". Valid: ${[...VALID_CAT].join(', ')}`, isError: true }
526
+ }
527
+ if (newStatus && !VALID_STATUS.has(newStatus)) {
528
+ return { text: `manage edit: invalid status "${newStatus}". Valid: ${[...VALID_STATUS].join(', ')}`, isError: true }
529
+ }
530
+
531
+ const finalElement = newElement ?? existing.element
532
+ const finalSummary = newSummary ?? existing.summary
533
+ const finalCategory = newCategory ?? existing.category
534
+ const finalStatus = newStatus ?? existing.status
535
+ const nowMs = Date.now()
536
+ const score = computeEntryScore(finalCategory, nowMs, nowMs)
537
+ const textChanged = newElement != null || newSummary != null
538
+ // Guard null element/summary: a category/status-only edit on a root
539
+ // whose element or summary is NULL would otherwise persist literal
540
+ // 'null — null' content and explode on finalSummary.slice() below.
541
+ // Use empty-string sentinels for the content composition + render so
542
+ // the row stays consistent with what's actually stored.
543
+ const elementStr = finalElement == null ? '' : String(finalElement)
544
+ const summaryStr = finalSummary == null ? '' : String(finalSummary)
545
+ const composedContent = elementStr || summaryStr
546
+ ? `${elementStr}${summaryStr ? ' — ' + summaryStr : ''}`
547
+ : ''
548
+
549
+ try {
550
+ await db.query(`
551
+ UPDATE entries
552
+ SET element = $1, summary = $2, category = $3, status = $4, score = $5,
553
+ last_seen_at = $6, content = $7
554
+ WHERE id = $8
555
+ `, [finalElement, finalSummary, finalCategory, finalStatus, score,
556
+ nowMs, composedContent, id])
557
+ } catch (e) {
558
+ return { text: `manage edit failed: ${e.message}`, isError: true }
559
+ }
560
+ if (textChanged) {
561
+ try { await syncRootEmbedding(db, id) } catch (e) {
562
+ log(`[memory.manage] embedding resync failed (id=${id}): ${e.message}\n`)
563
+ }
564
+ }
565
+ return { text: `edited (id=${id}): [${finalCategory}/${finalStatus}] ${elementStr}${summaryStr ? ' — ' + summaryStr.slice(0, 200) : ''}` }
566
+ }
567
+
568
+ if (op === 'delete') {
569
+ const id = Number(args.id)
570
+ if (!Number.isFinite(id) || id <= 0) {
571
+ return { text: 'manage delete requires numeric id', isError: true }
572
+ }
573
+ const info = (await db.query(
574
+ `SELECT id, category, element, is_root FROM entries WHERE id = $1`,
575
+ [id]
576
+ )).rows[0]
577
+ if (!info) return { text: `manage delete: no entry with id=${id}`, isError: true }
578
+ try {
579
+ const result = info.is_root === 1
580
+ ? await db.query(`DELETE FROM entries WHERE id = $1 OR chunk_root = $2`, [id, id])
581
+ : await db.query(`DELETE FROM entries WHERE id = $1`, [id])
582
+ return { text: `deleted (id=${id}, rows=${Number(result.rowCount ?? result.affectedRows ?? 0)}): [${info.category ?? '-'}] ${info.element ?? ''}` }
583
+ } catch (e) {
584
+ return { text: `manage delete failed: ${e.message}`, isError: true }
585
+ }
586
+ }
587
+
588
+ return { text: `manage: unhandled op "${op}"`, isError: true }
589
+ }
590
+
591
+ if (action === 'core') {
592
+ const op = String(args.op ?? '').trim().toLowerCase()
593
+ if (!['add', 'edit', 'delete', 'list', 'candidates', 'promote', 'dismiss'].includes(op)) {
594
+ return { text: 'core requires op: "add" | "edit" | "delete" | "list" | "candidates" | "promote" | "dismiss"', isError: true }
595
+ }
596
+ const coreDataDir = (typeof DATA_DIR === 'string' ? DATA_DIR : resolvePluginData())
597
+ if (!coreDataDir) return { text: 'core: memory data dir is not initialized', isError: true }
598
+ // Core-candidate promotion pipeline (proposal mode). The candidate flag
599
+ // lives on generated `entries`, which carry a project_id, so these ops MUST
600
+ // be project-scoped just like add/edit/delete — an unscoped listing/promote
601
+ // would leak candidates across projects. project_id resolution mirrors the
602
+ // block below: 'common'/null → COMMON (project_id NULL), '*' → all pools
603
+ // (candidates op only, same escape hatch as op:'list'). UI calls exactly
604
+ // these op names.
605
+ if (op === 'candidates' || op === 'promote' || op === 'dismiss') {
606
+ const hasPid = Object.prototype.hasOwnProperty.call(args, 'project_id')
607
+ const scope = (() => {
608
+ if (!hasPid || args.project_id == null) return null
609
+ const s = String(args.project_id).trim()
610
+ if (s === '' || s.toLowerCase() === 'common') return null
611
+ if (s === '*') return '*'
612
+ return s
613
+ })()
614
+ try {
615
+ if (op === 'candidates') {
616
+ const list = await listCoreCandidates(coreDataDir, scope)
617
+ if (list.length === 0) return { text: 'core candidates: none' }
618
+ return {
619
+ text: list.map(c =>
620
+ // project=<pool> lets the UI thread project_id into the follow-up
621
+ // promote/dismiss call — matters under project_id:'*' listing where
622
+ // rows span pools. Uses the same COMMON/slug convention as op:'list'.
623
+ `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})`,
624
+ ).join('\n'),
625
+ }
626
+ }
627
+ // promote/dismiss operate on a single id but are scope-guarded: the
628
+ // candidate must belong to the resolved scope (or COMMON), never '*'.
629
+ if (scope === '*') {
630
+ return { text: `core ${op}: project_id "*" only valid for op="candidates"`, isError: true }
631
+ }
632
+ if (op === 'promote') {
633
+ const entry = await promoteCoreCandidate(coreDataDir, args.id, { ...args, scope })
634
+ const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
635
+ return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: [${entry.category}] ${entry.element}` }
636
+ }
637
+ // dismiss
638
+ const removed = await dismissCoreCandidate(coreDataDir, args.id, { scope })
639
+ return { text: `core candidate dismissed (id=${removed.id}): [${removed.category}] ${removed.element}` }
640
+ } catch (e) {
641
+ return { text: `core ${op} failed: ${e.message}`, isError: true }
642
+ }
643
+ }
644
+ // Local trim helper — the manage-block trimOrNull at :1807 is scoped to
645
+ // that branch and unreachable from here.
646
+ // Normalize project_id: 'common' (case-insensitive) or null → null (COMMON pool); non-empty string → slug.
647
+ const hasProjectIdKey = Object.prototype.hasOwnProperty.call(args, 'project_id')
648
+ const projectId = (() => {
649
+ if (!hasProjectIdKey || args.project_id == null) return null
650
+ const s = String(args.project_id).trim()
651
+ if (s === '' || s.toLowerCase() === 'common') return null
652
+ if (s === '*') return '*'
653
+ return s
654
+ })()
655
+ try {
656
+ if (projectId === '*' && op !== 'list') {
657
+ return { text: `core ${op}: project_id "*" only valid for op="list"`, isError: true }
658
+ }
659
+ if (op === 'list') {
660
+ if (projectId !== '*') {
661
+ const entries = await listCore(coreDataDir, projectId)
662
+ if (entries.length === 0) return { text: 'core: empty' }
663
+ return { text: entries.map(e => `id=${e.id} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`).join('\n') }
664
+ }
665
+ // Cross-pool listing — group by project_id, COMMON first
666
+ const entries = await listCore(coreDataDir, '*')
667
+ if (entries.length === 0) return { text: 'core: empty' }
668
+ const groups = new Map()
669
+ for (const e of entries) {
670
+ const key = e.project_id ?? null
671
+ if (!groups.has(key)) groups.set(key, [])
672
+ groups.get(key).push(e)
673
+ }
674
+ const lines = []
675
+ for (const [key, rows] of groups) {
676
+ lines.push(`${key === null ? 'COMMON' : key}:`)
677
+ for (const e of rows) {
678
+ lines.push(` id=${e.id} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`)
679
+ }
680
+ }
681
+ return { text: lines.join('\n') }
682
+ }
683
+ if (op === 'add') {
684
+ if (!hasProjectIdKey) {
685
+ return { text: 'core add: project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool', isError: true }
686
+ }
687
+ const entry = await addCore(coreDataDir, args, projectId)
688
+ return { text: `core added (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
689
+ }
690
+ if (op === 'edit') {
691
+ const entry = await editCore(coreDataDir, args.id, args)
692
+ return { text: `core edited (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
693
+ }
694
+ if (op === 'delete') {
695
+ const removed = await deleteCore(coreDataDir, args.id)
696
+ return { text: `core deleted (id=${removed.id}): [${removed.category}] ${removed.element}` }
697
+ }
698
+ } catch (e) {
699
+ return { text: `core ${op} failed: ${e.message}`, isError: true }
700
+ }
701
+ return { text: `core: unhandled op "${op}"`, isError: true }
702
+ }
703
+
704
+ if (action === 'purge') {
705
+ if (args.confirm !== 'DELETE ALL MEMORY') {
706
+ return { text: 'purge requires confirm: "DELETE ALL MEMORY"', isError: true }
707
+ }
708
+ const preCount = (await db.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
709
+ const coreCount = (await db.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
710
+ try {
711
+ await db.query(`DELETE FROM entries`)
712
+ } catch (e) {
713
+ return { text: `purge failed: ${e.message}`, isError: true }
714
+ }
715
+ return { text: `purged generated memory entries (count=${preCount}); user core preserved (core_entries=${coreCount})` }
716
+ }
717
+
718
+ if (action === 'retro_eval_active') {
719
+ if (args.confirm !== 'REEVAL ACTIVE') {
720
+ return { text: 'retro_eval_active requires confirm: "REEVAL ACTIVE" (heavy LLM batch op — reviews all active roots through the unified gate)', isError: true }
721
+ }
722
+ const RETRO_BATCH = 50
723
+ const cycle2Config = config?.cycle2 || {}
724
+ const allActive = (await db.query(
725
+ `SELECT id, element, category, summary, score, last_seen_at, project_id, status, reviewed_at
726
+ FROM entries WHERE is_root = 1 AND status = 'active'
727
+ ORDER BY reviewed_at ASC, id ASC`
728
+ )).rows
729
+ const total = allActive.length
730
+ let archived = 0, kept = 0, updated = 0, merged = 0, errors = 0
731
+ const nowMs = Date.now()
732
+ for (let offset = 0; offset < total; offset += RETRO_BATCH) {
733
+ const batch = allActive.slice(offset, offset + RETRO_BATCH)
734
+ const batchIds = batch.map(r => Number(r.id))
735
+ const activeContext = (await db.query(
736
+ `SELECT id, element, category, summary, score, last_seen_at, project_id, status
737
+ FROM entries WHERE is_root = 1 AND status = 'active'
738
+ ORDER BY score DESC, last_seen_at DESC, id ASC LIMIT 200`
739
+ )).rows
740
+ let gateResult
741
+ try {
742
+ gateResult = await runUnifiedGate(db, batch, activeContext, cycle2Config, { activeCap: 200 })
743
+ } catch (err) {
744
+ log(`[retro_eval_active] runUnifiedGate failed (offset=${offset}): ${err.message}\n`)
745
+ errors += batch.length
746
+ continue
747
+ }
748
+ if (gateResult?.parseOk === false || gateResult?.actions === null) {
749
+ errors += batch.length
750
+ continue
751
+ }
752
+ const actions = gateResult?.actions ?? []
753
+ // Separate explicit `core` summary lines from primary verbs so an
754
+ // update/merge/active also refreshes the injected core_summary — mirrors
755
+ // the cycle2 apply path (memory-cycle2.mjs). Without this, retro could
756
+ // rewrite a root's summary while leaving its core_summary stale.
757
+ const coreSummaryById = new Map()
758
+ const primaryActions = []
759
+ for (const a of actions) {
760
+ if (a?.action === 'core') {
761
+ const cid = Number(a.entry_id)
762
+ const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
763
+ if (Number.isFinite(cid) && core) coreSummaryById.set(cid, core)
764
+ } else {
765
+ primaryActions.push(a)
766
+ }
767
+ }
768
+ const allowed = new Set(batchIds)
769
+ const rejected = gateResult?.rejected ?? new Set()
770
+ // Partial-apply contract: rows the gate never returned a verdict for
771
+ // (missingIds) must NOT be marked reviewed — they are left for a later
772
+ // run. Exclude both rejected and missing ids from the reviewed set.
773
+ const missing = new Set((gateResult?.missingIds ?? []).map(Number))
774
+ const successIds = new Set(batchIds.filter(id => !rejected.has(id) && !missing.has(id)))
775
+ for (const id of successIds) {
776
+ try { await db.query(`UPDATE entries SET reviewed_at = $1 WHERE id = $2`, [nowMs, id]) } catch {}
777
+ }
778
+ const setCoreSummary = async (entryId, core) => {
779
+ if (!core) return
780
+ try { await db.query(`UPDATE entries SET core_summary = $1 WHERE id = $2 AND is_root = 1`, [core, Number(entryId)]) }
781
+ catch (err) { log(`[retro_eval_active] core_summary update failed (id=${entryId}): ${err.message}\n`) }
782
+ }
783
+ if (!primaryActions.length) { kept += batch.filter(r => successIds.has(Number(r.id))).length; continue }
784
+ const acted = new Set()
785
+ const rowById = new Map(batch.map(r => [Number(r.id), r]))
786
+ for (const act of primaryActions) {
787
+ try {
788
+ const eid = Number(act?.entry_id)
789
+ if (!Number.isFinite(eid) || !allowed.has(eid)) continue
790
+ acted.add(eid)
791
+ // Optimistic guard: skip if the row moved since this batch was read.
792
+ const snap = rowById.get(eid)
793
+ const expected = snap ? { status: snap.status, reviewedAt: snap.reviewed_at } : null
794
+ if (act.action === 'archived') {
795
+ if (await applySimpleStatus(db, eid, 'archived', expected)) archived += 1
796
+ } else if (act.action === 'active') {
797
+ // active → active is a keep verdict from the gate.
798
+ kept += 1
799
+ await setCoreSummary(eid, coreSummaryById.get(eid))
800
+ } else if (act.action === 'update') {
801
+ if (await applyUpdate(db, eid, act.element, act.summary, { expected })) updated += 1
802
+ await setCoreSummary(eid, coreSummaryById.get(eid))
803
+ } else if (act.action === 'merge') {
804
+ const targetId = Number(act?.target_id)
805
+ const sourceIds = Array.isArray(act?.source_ids) ? act.source_ids : []
806
+ if (!Number.isFinite(targetId) || !allowed.has(targetId)) {
807
+ log(`[retro_eval_active] merge target outside batch (id=${targetId})\n`)
808
+ acted.delete(eid)
809
+ continue
810
+ }
811
+ const filteredSources = sourceIds.filter(s => allowed.has(Number(s)))
812
+ if (filteredSources.length !== sourceIds.length) {
813
+ log(
814
+ `[retro_eval_active] merge sources filtered: ${JSON.stringify(sourceIds)} -> ${JSON.stringify(filteredSources)}\n`,
815
+ )
816
+ }
817
+ acted.add(targetId)
818
+ filteredSources.forEach(s => acted.add(Number(s)))
819
+ const moved = await applyMerge(db, targetId, filteredSources)
820
+ if (moved > 0) {
821
+ merged += moved
822
+ if (typeof act.element === 'string' || typeof act.summary === 'string') {
823
+ try {
824
+ if (await applyUpdate(db, targetId, act.element, act.summary)) updated += 1
825
+ } catch (err) {
826
+ log(`[retro_eval_active] merge target update failed (target=${targetId}): ${err.message}\n`)
827
+ }
828
+ }
829
+ await setCoreSummary(targetId, coreSummaryById.get(targetId) || coreSummaryById.get(eid))
830
+ }
831
+ }
832
+ } catch (err) {
833
+ log(`[retro_eval_active] action error (id=${act?.entry_id}): ${err.message}\n`)
834
+ errors += 1
835
+ }
836
+ }
837
+ // Entries in successIds but not acted-upon (omit / no-op) are kept.
838
+ kept += batch.filter(r => successIds.has(Number(r.id)) && !acted.has(Number(r.id))).length
839
+ }
840
+ return { text: `retro_eval_active: total=${total} archived=${archived} kept=${kept} updated=${updated} merged=${merged} errors=${errors}` }
841
+ }
842
+
843
+ return { text: `unknown memory action: ${action}`, isError: true }
844
+ }
845
+
846
+ async function handleToolCall(name, args, signal) {
847
+ try {
848
+ if (name === 'search_memories') {
849
+ const result = await handleSearch(args || {}, signal)
850
+ return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
851
+ }
852
+ if (name === 'recall') {
853
+ // recall is aiWrapped in the unified build; in standalone mode map it to
854
+ // search_memories so the advertised tool name actually works. Forward
855
+ // every advertised arg so id/limit/offset/sort/includeArchived/
856
+ // includeMembers/includeRaw reach handleSearch instead of being dropped.
857
+ const a = args || {}
858
+ const hasQuery = Array.isArray(a.query)
859
+ ? a.query.some((value) => String(value || '').trim())
860
+ : String(a.query ?? '').trim() !== ''
861
+ const recallIds = hasQuery
862
+ ? []
863
+ : (Array.isArray(a.id) ? a.id : [a.id])
864
+ .map((value) => Number(value))
865
+ .filter((value) => Number.isInteger(value) && value > 0)
866
+ const searchArgs = {
867
+ ...(a.query !== undefined ? { query: a.query } : {}),
868
+ ...(recallIds.length > 0 ? { ids: recallIds } : {}),
869
+ ...(a.period ? { period: a.period } : {}),
870
+ ...(a.limit !== undefined ? { limit: a.limit } : {}),
871
+ ...(a.offset !== undefined ? { offset: a.offset } : {}),
872
+ ...(a.sort !== undefined ? { sort: a.sort } : {}),
873
+ ...(a.category !== undefined ? { category: a.category } : {}),
874
+ ...(a.includeArchived !== undefined ? { includeArchived: a.includeArchived } : {}),
875
+ ...(a.includeMembers !== undefined ? { includeMembers: a.includeMembers } : {}),
876
+ ...(a.includeRaw !== undefined ? { includeRaw: a.includeRaw } : {}),
877
+ ...(a.cwd ? { cwd: a.cwd } : {}),
878
+ ...(a.projectScope ? { projectScope: a.projectScope } : {}),
879
+ ...(a.sessionId ? { sessionId: a.sessionId } : {}),
880
+ ...(a.session_id ? { session_id: a.session_id } : {}),
881
+ ...(a.currentSession !== undefined ? { currentSession: a.currentSession } : {}),
882
+ // Hint only — never a filter. Marks the caller's own session as
883
+ // "(current)" in the multi-session grouped browse output.
884
+ ...(a.currentSessionId ? { currentSessionId: a.currentSessionId } : {}),
885
+ }
886
+ const result = await handleSearch(searchArgs, signal)
887
+ return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
888
+ }
889
+ if (name === 'memory') {
890
+ const result = await handleMemoryAction(args || {}, signal)
891
+ return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
892
+ }
893
+ return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true }
894
+ } catch (err) {
895
+ const msg = err instanceof Error ? err.message : String(err)
896
+ return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true }
897
+ }
898
+ }
899
+
900
+ return { handleMemoryAction, handleToolCall }
901
+ }