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.
- package/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- 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
|
+
}
|