mixdog 0.9.36 → 0.9.38
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 +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +101 -14
- package/scripts/explore-prompt-policy-test.mjs +31 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +40 -14
- package/src/rules/lead/01-general.md +4 -0
- package/src/rules/shared/01-tool.md +7 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +56 -10
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +482 -151
- package/src/tui/engine/agent-job-feed.mjs +36 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +93 -29
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +238 -157
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
|
@@ -175,7 +175,7 @@ export function analyze(text) {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
// Content-morpheme stem forms for a Korean phrase. null when not ready.
|
|
178
|
-
//
|
|
178
|
+
// Example: an inflected Korean noun/verb phrase is reduced to content stems.
|
|
179
179
|
export function stems(text) {
|
|
180
180
|
const tokens = analyze(text)
|
|
181
181
|
if (!tokens) return null
|
|
@@ -22,6 +22,7 @@ function __mixdogMemoryLog(...args) {
|
|
|
22
22
|
// <id>|update|<element>|<summary>
|
|
23
23
|
// <id>|merge|<target_id>|<source_ids_csv>
|
|
24
24
|
// <id>|delete|<reason>
|
|
25
|
+
// <id>|reclassify|<project_slug|common>
|
|
25
26
|
|
|
26
27
|
import { existsSync, readFileSync } from 'fs'
|
|
27
28
|
import { join } from 'path'
|
|
@@ -29,6 +30,7 @@ import { fileURLToPath } from 'url'
|
|
|
29
30
|
import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
|
|
30
31
|
import { callAgentDispatch } from './agent-ipc.mjs'
|
|
31
32
|
import { listCore, editCore, deleteCore, archiveCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
|
|
33
|
+
import { reclassifyCore } from './core-memory-store.mjs'
|
|
32
34
|
import { loadCurrentRulesDigest } from './memory-cycle2.mjs'
|
|
33
35
|
import { embedText } from './embedding-provider.mjs'
|
|
34
36
|
import { searchRelevantHybrid } from './memory-recall-store.mjs'
|
|
@@ -264,6 +266,17 @@ function parseVerdicts(raw, idSet) {
|
|
|
264
266
|
} else if (verb === 'delete') {
|
|
265
267
|
const reason = (parts[2] ?? '').trim()
|
|
266
268
|
actions.push({ id, verb: 'delete', reason: reason || null })
|
|
269
|
+
} else if (verb === 'reclassify' || verb === 'reproject' || verb === 'rescope') {
|
|
270
|
+
// Project classification correction: move a mis-scoped entry to the right
|
|
271
|
+
// pool. `<id>|reclassify|<project_slug|common>`. A missing target scope
|
|
272
|
+
// has no destination → drop. 'common' (case-insensitive) → COMMON pool.
|
|
273
|
+
const rawTarget = (parts[2] ?? '').trim()
|
|
274
|
+
if (!rawTarget) {
|
|
275
|
+
__mixdogMemoryLog(`[cycle3] reclassify rejected: id=${id} missing target scope\n`)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
const projectId = /^common$/i.test(rawTarget) ? null : rawTarget
|
|
279
|
+
actions.push({ id, verb: 'reclassify', projectId })
|
|
267
280
|
} else if (verb === 'superseded' || verb === 'supersede') {
|
|
268
281
|
// Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
|
|
269
282
|
// newer active core id the supersession has no evidence → drop to keep.
|
|
@@ -288,6 +301,7 @@ function mergeCycle3Counts(a = {}, b = {}) {
|
|
|
288
301
|
updated: Number(a.updated || 0) + Number(b.updated || 0),
|
|
289
302
|
merged: Number(a.merged || 0) + Number(b.merged || 0),
|
|
290
303
|
deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
|
|
304
|
+
reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
|
|
291
305
|
}
|
|
292
306
|
}
|
|
293
307
|
|
|
@@ -302,11 +316,13 @@ function mergeCycle3Results(a, b) {
|
|
|
302
316
|
updated: Number(a.updated || 0) + Number(b.updated || 0),
|
|
303
317
|
merged: Number(a.merged || 0) + Number(b.merged || 0),
|
|
304
318
|
deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
|
|
319
|
+
reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
|
|
305
320
|
proposed: mergeCycle3Counts(a.proposed, b.proposed),
|
|
306
321
|
held: {
|
|
307
322
|
updated: Number(a?.held?.updated || 0) + Number(b?.held?.updated || 0),
|
|
308
323
|
merged: Number(a?.held?.merged || 0) + Number(b?.held?.merged || 0),
|
|
309
324
|
deleted: Number(a?.held?.deleted || 0) + Number(b?.held?.deleted || 0),
|
|
325
|
+
reclassified: Number(a?.held?.reclassified || 0) + Number(b?.held?.reclassified || 0),
|
|
310
326
|
},
|
|
311
327
|
details: [...(a.details || []), ...(b.details || [])],
|
|
312
328
|
skippedInFlight: false,
|
|
@@ -517,6 +533,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
517
533
|
|
|
518
534
|
const idSet = new Set(cores.map(c => Number(c.id)))
|
|
519
535
|
const coreById = new Map(cores.map(c => [Number(c.id), c]))
|
|
536
|
+
// Real project pools present in this review — reclassify may only move an
|
|
537
|
+
// entry INTO a pool we already know is real (COMMON is always valid), never
|
|
538
|
+
// invent a typo pool from an LLM-supplied slug.
|
|
539
|
+
const knownPools = new Set(cores.map(c => c.project_id).filter(p => p != null).map(String))
|
|
520
540
|
const parsed = parseVerdicts(raw, idSet)
|
|
521
541
|
if (!parsed) {
|
|
522
542
|
__mixdogMemoryLog(
|
|
@@ -605,9 +625,9 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
605
625
|
}
|
|
606
626
|
}
|
|
607
627
|
|
|
608
|
-
let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0
|
|
609
|
-
const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0 }
|
|
610
|
-
const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
|
|
628
|
+
let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0, reclassified = 0
|
|
629
|
+
const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
|
|
630
|
+
const held = { updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
|
|
611
631
|
const details = []
|
|
612
632
|
const touched = new Set() // ids already acted on this cycle — avoid double action
|
|
613
633
|
// Safety margin: even when every junk verdict is legitimate, a single
|
|
@@ -687,6 +707,44 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
687
707
|
}
|
|
688
708
|
continue
|
|
689
709
|
}
|
|
710
|
+
if (a.verb === 'reclassify') {
|
|
711
|
+
proposed.reclassified++
|
|
712
|
+
// Project classification correction: move a mis-scoped entry to the right
|
|
713
|
+
// pool. Non-destructive (project_id only, no delete, no re-embed) and
|
|
714
|
+
// reversible, so it applies in DEFAULT (conservative) mode. Guards: the
|
|
715
|
+
// move must actually change pools and the target must be a real pool from
|
|
716
|
+
// this review (or COMMON) — otherwise hold. Unique-collision / content
|
|
717
|
+
// drift are resolved inside reclassifyCore (returns skipped).
|
|
718
|
+
const core = coreById.get(a.id)
|
|
719
|
+
const curPid = core?.project_id ?? null
|
|
720
|
+
const targetPid = a.projectId ?? null
|
|
721
|
+
let holdReason = null
|
|
722
|
+
if ((curPid ?? null) === (targetPid ?? null)) holdReason = 'already in target pool'
|
|
723
|
+
else if (targetPid != null && !knownPools.has(String(targetPid))) holdReason = `unknown target pool "${targetPid}"`
|
|
724
|
+
if (!mutate) holdReason = holdReason || 'proposal mode'
|
|
725
|
+
if (holdReason) {
|
|
726
|
+
held.reclassified++
|
|
727
|
+
details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: holdReason })
|
|
728
|
+
touched.add(a.id)
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
const res = await reclassifyCore(dataDir, a.id, targetPid, core ? { element: core.element, summary: core.summary, sourceProjectId: curPid } : null)
|
|
733
|
+
if (res?.skipped) {
|
|
734
|
+
held.reclassified++
|
|
735
|
+
details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: res.reason })
|
|
736
|
+
} else {
|
|
737
|
+
reclassified++
|
|
738
|
+
details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: true })
|
|
739
|
+
}
|
|
740
|
+
touched.add(a.id)
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
743
|
+
__mixdogMemoryLog(`[cycle3] reclassify failed id=${a.id}: ${err.message}\n`)
|
|
744
|
+
details.push({ id: a.id, verb: 'reclassify', error: err.message })
|
|
745
|
+
}
|
|
746
|
+
continue
|
|
747
|
+
}
|
|
690
748
|
if (a.verb === 'superseded') {
|
|
691
749
|
proposed.superseded++
|
|
692
750
|
// Supersession archives (status flip), never physical DELETE, and applies
|
|
@@ -808,9 +866,11 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
808
866
|
` proposed_update=${proposed.updated} proposed_merge=${proposed.merged} proposed_delete=${proposed.deleted}` +
|
|
809
867
|
` applied_update=${updated} applied_merge=${merged} applied_delete=${deleted}` +
|
|
810
868
|
` applied_superseded=${superseded}` +
|
|
869
|
+
` applied_reclassify=${reclassified}` +
|
|
811
870
|
` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted} held_superseded=${held.superseded}` +
|
|
871
|
+
` held_reclassify=${held.reclassified}` +
|
|
812
872
|
` mode=${applyMode}\n`,
|
|
813
873
|
)
|
|
814
874
|
|
|
815
|
-
return { reviewed: cores.length, kept, updated, merged, deleted, superseded, proposed, held, applied: mutate, applyMode, details }
|
|
875
|
+
return { reviewed: cores.length, kept, updated, merged, deleted, superseded, reclassified, proposed, held, applied: mutate, applyMode, details }
|
|
816
876
|
}
|
|
@@ -10,7 +10,7 @@ const MEMORY_TOKEN_STOPWORDS = new Set([
|
|
|
10
10
|
'your', 'unless', 'with',
|
|
11
11
|
'user', 'assistant', 'requested', 'request', 'asked', 'ask', 'stated', 'state', 'reported', 'report',
|
|
12
12
|
'mentioned', 'mention', 'clarified', 'clarify', 'explicitly', 'currently',
|
|
13
|
-
'
|
|
13
|
+
'\uC0AC\uC6A9\uC790', '\uC720\uC800', '\uC694\uCCAD', '\uC9C8\uBB38', '\uB2F5\uBCC0', '\uC5B8\uAE09', '\uB9D0\uC500', '\uC124\uBA85', '\uBCF4\uACE0', '\uBB34\uC2A8', '\uBB50\uC57C', '\uD588\uC9C0', 'user', 'asks', 'asked', 'request', 'requested', 'question', 'answer', 'reply', 'said', 'mentioned', 'explained', 'reported', 'what', 'huh',
|
|
14
14
|
])
|
|
15
15
|
|
|
16
16
|
export function normalizeMemoryToken(token) {
|
|
@@ -20,9 +20,9 @@ export function normalizeMemoryToken(token) {
|
|
|
20
20
|
// Korean suffix stripping: basic particles + compound endings
|
|
21
21
|
if (/[\uAC00-\uD7AF]/.test(normalized) && normalized.length > 2) {
|
|
22
22
|
const stripped = normalized
|
|
23
|
-
.replace(/(
|
|
24
|
-
.replace(/(
|
|
25
|
-
.replace(/(
|
|
23
|
+
.replace(/(\uD588\uC5C8\uC9C0|\uD588\uB354\uB77C|\uB410\uC5C8\uB098|\uB410\uB358\uAC00|\uD588\uB294\uC9C0|\uC600\uB294\uC9C0|\uC778\uAC74\uAC00|\uD558\uB824\uBA74|\uC5D0\uC11C\uB294|\uC774\uB77C\uC11C|\uC600\uB354\uB77C|\uC5D0\uC11C\uB3C4|\uC774\uC5C8\uC9C0|\uC73C\uB85C\uB3C4|\uAC70\uC600\uC9C0|\uD55C\uAC74\uC9C0|\uC774\uC5C8\uB098)$/u, '')
|
|
24
|
+
.replace(/(\uD588\uB358|\uD588\uC9C0|\uB410\uB358|\uB410\uC9C0|\uD558\uAC8C|\uB418\uB358|\uC774\uB77C|\uC5D0\uC11C|\uC73C\uB85C|\uD558\uB294|\uC5C6\uB294|\uC788\uB294|\uC5C8\uB358|\uD558\uC790|\uC54A\uAC8C|\uD560\uB54C|\uC778\uC9C0|\uC778\uB370|\uC778\uAC74|\uC774\uACE0|\uBCF4\uB2E4|\uCC98\uB7FC|\uAE4C\uC9C0|\uBD80\uD130|\uB9C8\uB2E4|\uBC16\uC5D0|\uC5C6\uC774)$/u, '')
|
|
25
|
+
.replace(/(\uC740|\uB294|\uC774|\uAC00|\uC744|\uB97C|\uB791|\uACFC|\uC640|\uB3C4|\uC5D0|\uC758|\uB85C|\uB9CC|\uBA70|\uB098|\uACE0|\uC11C|\uC790|\uC694)$/u, '')
|
|
26
26
|
if (stripped.length >= 2) normalized = stripped
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -314,14 +314,14 @@ function collectGroupTs(groupRows) {
|
|
|
314
314
|
return all
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
// Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n
|
|
317
|
+
// Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n entries)`; the end keeps the
|
|
318
318
|
// MM-DD prefix only when it falls on a different calendar day than the start.
|
|
319
319
|
function spanHeaderSuffix(minTs, maxTs, n) {
|
|
320
320
|
const min = formatTs(minTs) // "YYYY-MM-DD HH:mm"
|
|
321
321
|
const max = formatTs(maxTs)
|
|
322
322
|
const startPart = min.slice(5) // "MM-DD HH:mm"
|
|
323
323
|
const endPart = min.slice(0, 10) === max.slice(0, 10) ? max.slice(11) : max.slice(5)
|
|
324
|
-
return `(${startPart} ~ ${endPart}, ${n}
|
|
324
|
+
return `(${startPart} ~ ${endPart}, ${n} entries)`
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
// Session-grouped rendering for the GLOBAL query-less browse ("what did we
|
|
@@ -359,7 +359,7 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
|
|
|
359
359
|
const tsAll = collectGroupTs(groupRows)
|
|
360
360
|
const suffix = tsAll.length
|
|
361
361
|
? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
|
|
362
|
-
: ` (${groupRows.length}
|
|
362
|
+
: ` (${groupRows.length} entries)`
|
|
363
363
|
parts.push(`## ${label}${mark}${suffix}`)
|
|
364
364
|
const bodyStr = renderEntryLines(groupRows, { recencyOrder })
|
|
365
365
|
const bodyLines = bodyStr === '(no results)'
|
|
@@ -120,6 +120,21 @@ function _abortError() {
|
|
|
120
120
|
return e;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort capacity probe for NON-COMPETING prewarm/warmup work. Returns
|
|
125
|
+
* true only when a slot could be taken right now without queuing — i.e. below
|
|
126
|
+
* the cap AND with no waiter already queued. Speculative warmers (explore's
|
|
127
|
+
* code_graph / find prewarm) consult this to skip/defer when the daemon is
|
|
128
|
+
* busy, so a fire-and-forget warm never pushes a real tool query into the
|
|
129
|
+
* queue (the "non-competing under fanout" guarantee). This is a probe, NOT a
|
|
130
|
+
* reservation: the answer can go stale under a race, which is acceptable for
|
|
131
|
+
* best-effort work. Real queries must use acquire()/withGate() and never gate
|
|
132
|
+
* themselves on this.
|
|
133
|
+
*/
|
|
134
|
+
export function hasSpareCapacity() {
|
|
135
|
+
return _inflight < MAX_INFLIGHT && _queue.length === 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
/**
|
|
124
139
|
* Run `fn` while holding one child-spawn slot. Release is guaranteed in a
|
|
125
140
|
* finally so a throw/return from `fn` cannot leak a slot or deadlock the gate.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Unified config reader/writer.
|
|
3
3
|
* Single file: mixdog-config.json with sections such as channels, agent, and memory.
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, mkdirSync, existsSync } from 'fs'
|
|
5
|
+
import { readFileSync, statSync, mkdirSync, existsSync } from 'fs'
|
|
6
6
|
import { join, dirname } from 'path'
|
|
7
7
|
import { createRequire } from 'module'
|
|
8
8
|
import { resolvePluginData } from './plugin-paths.mjs'
|
|
@@ -24,6 +24,78 @@ const CONFIG_PATH = join(DATA_DIR, 'mixdog-config.json')
|
|
|
24
24
|
|
|
25
25
|
const GENERATED_KEY = '_generated'
|
|
26
26
|
|
|
27
|
+
// Process-wide short-TTL cache of the RAW utf8 string of CONFIG_PATH (never
|
|
28
|
+
// the parsed object). Parallel agent spawns each hit the non-RMW read path
|
|
29
|
+
// (readAll → readSection/readConfig/readCapabilities); without this every
|
|
30
|
+
// spawn pays a synchronous readFileSync(CONFIG_PATH) on the event loop,
|
|
31
|
+
// serializing the whole fanout behind redundant disk I/O. Caching the raw
|
|
32
|
+
// string — not the parsed object — lets each caller re-JSON.parse for an
|
|
33
|
+
// isolated, freely-mutable object (no shared-reference poisoning). Only the
|
|
34
|
+
// non-RMW readAll() consults this; the RMW path (readAllForRmW) always reads
|
|
35
|
+
// disk under the lock. Read errors and malformed JSON are never cached.
|
|
36
|
+
let _configReadCache = null // { raw, mtimeMs, size, atMs } | null
|
|
37
|
+
// Optional coarse fast-path window (ms). Default 0 => the cached raw string is
|
|
38
|
+
// validated against the file's mtime+size on EVERY read via a cheap statSync
|
|
39
|
+
// (no content read, no whole-file JSON.parse), so a cross-process edit is
|
|
40
|
+
// observed immediately with ZERO time-based staleness while the expensive
|
|
41
|
+
// full read + parse are still skipped on an unchanged file. Set >0 to also
|
|
42
|
+
// skip the statSync within the window, trading a bounded staleness for fewer
|
|
43
|
+
// syscalls under extreme spawn fanout.
|
|
44
|
+
const CONFIG_READ_TTL_MS = (() => {
|
|
45
|
+
const n = Number(process.env.MIXDOG_CONFIG_READ_TTL_MS)
|
|
46
|
+
return Number.isFinite(n) && n >= 0 ? n : 0
|
|
47
|
+
})()
|
|
48
|
+
|
|
49
|
+
export function invalidateConfigReadCache() {
|
|
50
|
+
_configReadCache = null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readConfigRawCached() {
|
|
54
|
+
const now = Date.now()
|
|
55
|
+
if (_configReadCache) {
|
|
56
|
+
// Fast path (only when a TTL window is configured): serve without any
|
|
57
|
+
// syscall until the window lapses.
|
|
58
|
+
if (CONFIG_READ_TTL_MS > 0 && now - _configReadCache.atMs < CONFIG_READ_TTL_MS) {
|
|
59
|
+
return _configReadCache.raw
|
|
60
|
+
}
|
|
61
|
+
// Freshness gate: metadata-only stat. Unchanged mtime+size => the cached
|
|
62
|
+
// raw is still current, cross-process writes included — atomic writers land
|
|
63
|
+
// a new inode with a fresh mtime, so this reliably detects them.
|
|
64
|
+
try {
|
|
65
|
+
const st = statSync(CONFIG_PATH)
|
|
66
|
+
if (st.mtimeMs === _configReadCache.mtimeMs && st.size === _configReadCache.size) {
|
|
67
|
+
_configReadCache.atMs = now
|
|
68
|
+
return _configReadCache.raw
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// stat failed (e.g. transient ENOENT mid-rename); fall through to a fresh
|
|
72
|
+
// read which re-applies the ENOENT/backup-restore semantics below.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Stat BEFORE read: if a write lands between the two we cache newer content
|
|
76
|
+
// against the older mtime, so the very next call re-reads instead of getting
|
|
77
|
+
// stuck on stale bytes — the cache always converges toward fresh.
|
|
78
|
+
let st = null
|
|
79
|
+
try {
|
|
80
|
+
st = statSync(CONFIG_PATH)
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err.code === 'ENOENT') { _configReadCache = null; return null }
|
|
83
|
+
process.stderr.write(`[config] readConfigRawCached: unexpected stat error for ${CONFIG_PATH}: ${err.message}\n`)
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
let raw
|
|
87
|
+
try {
|
|
88
|
+
raw = readFileSync(CONFIG_PATH, 'utf8')
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code === 'ENOENT') { _configReadCache = null; return null }
|
|
91
|
+
// Fail closed on unknown read errors (EACCES, EIO, …); do NOT cache.
|
|
92
|
+
process.stderr.write(`[config] readJsonFile: unexpected read error for ${CONFIG_PATH}: ${err.message}\n`)
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
_configReadCache = { raw, mtimeMs: st.mtimeMs, size: st.size, atMs: now }
|
|
96
|
+
return raw
|
|
97
|
+
}
|
|
98
|
+
|
|
27
99
|
function isPlainObject(value) {
|
|
28
100
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
29
101
|
}
|
|
@@ -36,24 +108,32 @@ export function stripGeneratedMarker(data) {
|
|
|
36
108
|
|
|
37
109
|
function readJsonFile(path) {
|
|
38
110
|
let raw
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
111
|
+
if (path === CONFIG_PATH) {
|
|
112
|
+
// Non-RMW read path: served from the short-TTL raw-string cache. Returning
|
|
113
|
+
// {} on a read error here would let a subsequent writeSection() serialize
|
|
114
|
+
// an empty object over an existing-but-temporarily-unreadable config and
|
|
115
|
+
// erase every other section, so read errors surface (throw) uncached.
|
|
116
|
+
raw = readConfigRawCached()
|
|
117
|
+
} else {
|
|
118
|
+
try {
|
|
119
|
+
raw = readFileSync(path, 'utf8')
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.code === 'ENOENT') return null
|
|
122
|
+
process.stderr.write(`[config] readJsonFile: unexpected read error for ${path}: ${err.message}\n`)
|
|
123
|
+
throw err
|
|
124
|
+
}
|
|
50
125
|
}
|
|
126
|
+
if (raw == null) return null
|
|
51
127
|
try {
|
|
52
128
|
return JSON.parse(raw)
|
|
53
129
|
} catch (err) {
|
|
54
130
|
// Quarantine a malformed mixdog-config.json so the next boot starts fresh
|
|
55
131
|
// instead of looping on a broken file.
|
|
56
132
|
if (path === CONFIG_PATH) {
|
|
133
|
+
// A cached raw string that fails to parse must not be re-served; drop it
|
|
134
|
+
// so the post-quarantine/restore read hits disk fresh (malformed = never
|
|
135
|
+
// cached).
|
|
136
|
+
invalidateConfigReadCache()
|
|
57
137
|
const corrupt = `${path}.corrupt-${Date.now()}`
|
|
58
138
|
try { renameWithRetrySync(path, corrupt) } catch {}
|
|
59
139
|
process.stderr.write(`[config] mixdog-config.json is malformed (${err.message}). Renamed to ${corrupt}. Restore it or delete to start fresh.\n`)
|
|
@@ -81,6 +161,9 @@ function writeJsonFile(path, data) {
|
|
|
81
161
|
}
|
|
82
162
|
writeJsonAtomicSync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
|
|
83
163
|
if (path === CONFIG_PATH) {
|
|
164
|
+
// Our own write just changed CONFIG_PATH on disk; drop the stale raw cache
|
|
165
|
+
// synchronously so the next in-process readAll() reflects it immediately.
|
|
166
|
+
invalidateConfigReadCache()
|
|
84
167
|
try { markUserDataInitialized(DATA_DIR) } catch {}
|
|
85
168
|
try { backupUserData(DATA_DIR, 'post-config-write') } catch {}
|
|
86
169
|
}
|
|
@@ -226,6 +309,9 @@ async function writeJsonFileAsync(path, data) {
|
|
|
226
309
|
}
|
|
227
310
|
await writeJsonAtomicAsync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
|
|
228
311
|
if (path === CONFIG_PATH) {
|
|
312
|
+
// Parity with writeJsonFile: invalidate synchronously after the async
|
|
313
|
+
// write resolves so this process never serves a stale cached config.
|
|
314
|
+
invalidateConfigReadCache()
|
|
229
315
|
try { markUserDataInitialized(DATA_DIR) } catch {}
|
|
230
316
|
try { await backupUserDataAsync(DATA_DIR, 'post-config-write') } catch {}
|
|
231
317
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Many independent singletons self-register process-level drains (exit,
|
|
2
|
+
// beforeExit, SIGTERM, …). Standalone scripts that import runtime modules
|
|
3
|
+
// directly bypass src/app.mjs, so raise the cap from a shared helper too.
|
|
4
|
+
export function ensureProcessListenerHeadroom(min = 64) {
|
|
5
|
+
try {
|
|
6
|
+
if (typeof process.getMaxListeners !== 'function' || typeof process.setMaxListeners !== 'function') return;
|
|
7
|
+
const current = process.getMaxListeners();
|
|
8
|
+
if (current === 0 || current >= min) return;
|
|
9
|
+
process.setMaxListeners(min);
|
|
10
|
+
} catch { /* ignore */ }
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -234,9 +234,12 @@ export function createCwdPlugins({
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
async function loadCoreMemoryContext() {
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
237
|
+
// User-curated core memory injects into new sessions by default.
|
|
238
|
+
// Explicit opt-out (MIXDOG_BOOT_CORE_MEMORY=0/false/no/off) skips the
|
|
239
|
+
// memory/PG startup cost; recall and memory tools still initialize the
|
|
240
|
+
// memory service on first use.
|
|
241
|
+
const bootFlag = String(process.env.MIXDOG_BOOT_CORE_MEMORY ?? '').trim().toLowerCase();
|
|
242
|
+
if (bootFlag === '0' || bootFlag === 'false' || bootFlag === 'no' || bootFlag === 'off') {
|
|
240
243
|
bootProfile('core-memory:skipped');
|
|
241
244
|
return '';
|
|
242
245
|
}
|
|
@@ -17,6 +17,21 @@ import {
|
|
|
17
17
|
SEARCH_DEFAULT_MODEL,
|
|
18
18
|
} from './workflow.mjs';
|
|
19
19
|
import { writeStatuslineRoute } from './statusline-route.mjs';
|
|
20
|
+
import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
|
|
21
|
+
import {
|
|
22
|
+
hasUserConversationMessage,
|
|
23
|
+
} from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
|
|
24
|
+
|
|
25
|
+
function isSummaryAnchorMessage(message) {
|
|
26
|
+
return message?.role === 'user'
|
|
27
|
+
&& typeof message.content === 'string'
|
|
28
|
+
&& message.content.startsWith(SUMMARY_PREFIX);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasRouteHistoryMessage(messages) {
|
|
32
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
33
|
+
return hasUserConversationMessage(list) || list.some(isSummaryAnchorMessage);
|
|
34
|
+
}
|
|
20
35
|
|
|
21
36
|
// Model/route/search-route selection + mutation surface. Extracted verbatim from
|
|
22
37
|
// the runtime API object; stateless helpers are imported directly and the
|
|
@@ -33,6 +48,8 @@ export function createModelRouteApi(deps) {
|
|
|
33
48
|
persistLeadRoute, refreshRouteEffort,
|
|
34
49
|
refreshStatuslineUsageSnapshot, scheduleStatuslineUsageRefresh,
|
|
35
50
|
invalidateContextStatusCache, invalidateProviderCaches,
|
|
51
|
+
createCurrentSession, invalidatePreSessionToolSurface,
|
|
52
|
+
pushTranscriptRebind,
|
|
36
53
|
collectSearchProviderModels,
|
|
37
54
|
} = deps;
|
|
38
55
|
return {
|
|
@@ -125,10 +142,41 @@ export function createModelRouteApi(deps) {
|
|
|
125
142
|
await refreshRouteEffort(modelMeta);
|
|
126
143
|
refreshStatuslineUsageSnapshot(getRoute());
|
|
127
144
|
scheduleStatuslineUsageRefresh();
|
|
128
|
-
|
|
145
|
+
const session = getSession();
|
|
146
|
+
// Model/provider changes are next-session-only for a session the user
|
|
147
|
+
// has already talked in or compacted (provider-keyed prompt cache). But
|
|
148
|
+
// an EMPTY current session — no committed route history and no in-flight
|
|
149
|
+
// first-turn prompt — has no cache to protect, so /model before the first
|
|
150
|
+
// chat takes effect live: route + statusline update immediately.
|
|
151
|
+
const currentSessionEmpty = !!session
|
|
152
|
+
&& !hasRouteHistoryMessage(session.messages)
|
|
153
|
+
&& !hasRouteHistoryMessage(session.liveTurnMessages);
|
|
154
|
+
const applyLive = applyToCurrentSession || currentSessionEmpty;
|
|
155
|
+
if (!applyLive) {
|
|
156
|
+
return getRoute();
|
|
157
|
+
}
|
|
158
|
+
if (currentSessionEmpty && session?.id && typeof createCurrentSession === 'function') {
|
|
159
|
+
// If the boot create is still finishing SessionStart/deferred-surface
|
|
160
|
+
// work, drain that promise first. Otherwise createCurrentSession()
|
|
161
|
+
// would return the old in-flight promise after we tombstone/null the
|
|
162
|
+
// session, racing the intended rebuild for the new provider.
|
|
163
|
+
await createCurrentSession('model-switch-empty-drain');
|
|
164
|
+
const emptySession = getSession();
|
|
165
|
+
if (!emptySession?.id
|
|
166
|
+
|| hasRouteHistoryMessage(emptySession.messages)
|
|
167
|
+
|| hasRouteHistoryMessage(emptySession.liveTurnMessages)) {
|
|
168
|
+
invalidateContextStatusCache();
|
|
169
|
+
return getRoute();
|
|
170
|
+
}
|
|
171
|
+
statusRoutes?.clearGatewaySessionRoute?.(emptySession.id);
|
|
172
|
+
mgr.closeSession?.(emptySession.id, 'cli-model-switch-empty', { tombstone: true });
|
|
173
|
+
setSession(null);
|
|
174
|
+
invalidatePreSessionToolSurface?.();
|
|
175
|
+
await createCurrentSession('model-switch-empty');
|
|
176
|
+
pushTranscriptRebind?.();
|
|
177
|
+
invalidateContextStatusCache();
|
|
129
178
|
return getRoute();
|
|
130
179
|
}
|
|
131
|
-
const session = getSession();
|
|
132
180
|
if (session) {
|
|
133
181
|
const route = getRoute();
|
|
134
182
|
const updated = mgr.updateSessionRoute?.(session.id, {
|
|
@@ -31,7 +31,14 @@ export function createProviderAuthApi({
|
|
|
31
31
|
}) {
|
|
32
32
|
function refreshProviderCatalogsSoon() {
|
|
33
33
|
if (typeof refreshProviderCatalogs !== 'function') return;
|
|
34
|
-
try {
|
|
34
|
+
try {
|
|
35
|
+
void Promise.resolve(refreshProviderCatalogs())
|
|
36
|
+
.then(() => {
|
|
37
|
+
invalidateProviderCaches();
|
|
38
|
+
warmProviderModelCache();
|
|
39
|
+
})
|
|
40
|
+
.catch(() => {});
|
|
41
|
+
} catch { /* best-effort */ }
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
return {
|
|
@@ -277,13 +277,13 @@ export function createProviderModels({
|
|
|
277
277
|
return providerModelsFromCacheRows(await caches.providerModelsPromise);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
function warmProviderModelCache() {
|
|
280
|
+
function warmProviderModelCache({ loadSecrets = false } = {}) {
|
|
281
281
|
if (Array.isArray(caches.providerModelsCache.models) || caches.providerModelsPromise) return caches.providerModelsPromise;
|
|
282
282
|
profile('warm:start');
|
|
283
283
|
const seq = ++caches.providerModelsLoadSeq;
|
|
284
|
-
caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets
|
|
284
|
+
caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets })
|
|
285
285
|
.then((models) => {
|
|
286
|
-
if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets
|
|
286
|
+
if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets })) {
|
|
287
287
|
caches.providerModelsCache = { models, at: Date.now() };
|
|
288
288
|
}
|
|
289
289
|
bootProfile('provider-models:warm-ready', { count: models.length });
|
|
@@ -80,6 +80,24 @@ export function createResourceApi(deps) {
|
|
|
80
80
|
}
|
|
81
81
|
return chain.running;
|
|
82
82
|
}
|
|
83
|
+
function configuredProfileIdentityLine() {
|
|
84
|
+
try {
|
|
85
|
+
const config = getConfig();
|
|
86
|
+
const stored = config?.profile ?? config?.agent?.profile;
|
|
87
|
+
const profile = cfgMod.normalizeProfileConfig(stored);
|
|
88
|
+
const title = clean(profile?.title);
|
|
89
|
+
if (!title) return '';
|
|
90
|
+
return `[profile] Current configured user name/identity: ${title}. This profile value is authoritative; ignore stale memory rows that say the user's identity is unknown.`;
|
|
91
|
+
} catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function isIdentityRecallQuery(query) {
|
|
96
|
+
const q = clean(query).toLowerCase().replace(/\s+/g, '');
|
|
97
|
+
if (!q) return false;
|
|
98
|
+
return /(?:\uB0B4\uAC00|\uB098\uB294|\uB098|\uC0AC\uC6A9\uC790|\uC720\uC800|user|my|me).*(?:\uB204\uAD6C|\uB204\uAD70|\uC815\uCCB4|\uC774\uB984|name|identity)|(?:whoami|whoami\?|whoami?)|who(?:am)?i|whoami/.test(q)
|
|
99
|
+
|| /^(?:\uB098\uB204\uAD6C\uB0D0|\uB098\uB294\uB204\uAD6C\uB0D0|\uB0B4\uAC00\uB204\uAD6C\uB0D0|\uB0B4\uC774\uB984\uBB50|\uB0B4\uC774\uB984\uBB50\uC57C|whoami)$/i.test(q);
|
|
100
|
+
}
|
|
83
101
|
return {
|
|
84
102
|
mcpStatus() {
|
|
85
103
|
return mcpStatus();
|
|
@@ -300,6 +318,10 @@ export function createResourceApi(deps) {
|
|
|
300
318
|
const session = getSession();
|
|
301
319
|
const currentCwd = getCurrentCwd();
|
|
302
320
|
const baseQuery = query || args?.query || '';
|
|
321
|
+
if (isIdentityRecallQuery(baseQuery)) {
|
|
322
|
+
const profileLine = configuredProfileIdentityLine();
|
|
323
|
+
if (profileLine) return profileLine;
|
|
324
|
+
}
|
|
303
325
|
if (args?.currentSession !== false && session?.id) {
|
|
304
326
|
const currentText = currentSessionRecallRows(session, baseQuery, { limit: args?.limit });
|
|
305
327
|
if (!isEmptyRecallText(currentText)) return currentText;
|