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