mixdog 0.9.4 → 0.9.6
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/README.md +112 -38
- package/package.json +4 -1
- package/scripts/bench/r5-orchestrated-task.json +7 -0
- package/scripts/bench/round-r13-clientmeta.json +464 -0
- package/scripts/bench/round-r14-betafeatures.json +466 -0
- package/scripts/bench/round-r15-fulldefault.json +462 -0
- package/scripts/bench/round-r16-sessionid.json +466 -0
- package/scripts/bench/round-r17-wirebytes.json +456 -0
- package/scripts/bench/round-r18-prewarm.json +468 -0
- package/scripts/bench/round-r19-clean.json +472 -0
- package/scripts/bench/round-r20-prewarm-clean.json +475 -0
- package/scripts/bench/round-r21-delta-retry.json +473 -0
- package/scripts/bench/round-r22-full-probe.json +693 -0
- package/scripts/bench/round-r23-itemprobe.json +701 -0
- package/scripts/bench/round-r24-shapefix.json +677 -0
- package/scripts/bench/round-r25-serial.json +464 -0
- package/scripts/bench/round-r26-parallel3.json +671 -0
- package/scripts/bench/round-r27-parallel10.json +894 -0
- package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
- package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
- package/scripts/bench/round-r30-instid.json +253 -0
- package/scripts/bench/round-r31-upgradeprobe.json +256 -0
- package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
- package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
- package/scripts/bench/round-r34-orchestrated.json +120 -0
- package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
- package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
- package/scripts/bench-run.mjs +40 -7
- package/scripts/internal-comms-bench.mjs +2 -4
- package/scripts/model-catalog-audit.mjs +209 -0
- package/scripts/model-list-sanitize-test.mjs +37 -0
- package/scripts/recall-bench.mjs +76 -13
- package/scripts/recall-quality-cases.json +12 -0
- package/scripts/tool-smoke.mjs +2 -2
- package/src/agents/heavy-worker/AGENT.md +7 -7
- package/src/agents/scheduler-task/AGENT.md +2 -3
- package/src/agents/webhook-handler/AGENT.md +2 -3
- package/src/agents/worker/AGENT.md +9 -10
- package/src/app.mjs +12 -1
- package/src/headless-role.mjs +7 -1
- package/src/mixdog-session-runtime.mjs +15 -14
- package/src/output-styles/default.md +14 -18
- package/src/output-styles/minimal.md +4 -5
- package/src/output-styles/simple.md +7 -8
- package/src/rules/agent/00-common.md +6 -5
- package/src/rules/agent/30-explorer.md +16 -5
- package/src/rules/lead/01-general.md +5 -5
- package/src/rules/lead/lead-brief.md +6 -3
- package/src/rules/lead/lead-tool.md +6 -4
- package/src/rules/shared/01-tool.md +17 -21
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +19 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +9 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +53 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +23 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -9
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +33 -4
- package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +93 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +27 -5
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +1 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/channels/backends/discord-gateway.mjs +2 -2
- package/src/runtime/channels/backends/discord.mjs +15 -26
- package/src/runtime/channels/backends/telegram.mjs +8 -12
- package/src/runtime/channels/index.mjs +23 -43
- package/src/runtime/channels/lib/backend-dispatch.mjs +5 -3
- package/src/runtime/channels/lib/config.mjs +37 -149
- package/src/runtime/channels/lib/event-pipeline.mjs +4 -4
- package/src/runtime/channels/lib/event-queue.mjs +15 -9
- package/src/runtime/channels/lib/inbound-routing.mjs +2 -39
- package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
- package/src/runtime/channels/lib/scheduler.mjs +27 -113
- package/src/runtime/channels/lib/webhook/deliveries.mjs +3 -2
- package/src/runtime/channels/lib/webhook.mjs +8 -47
- package/src/runtime/memory/index.mjs +9 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
- package/src/runtime/memory/lib/ko-morph.mjs +195 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +47 -10
- package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
- package/src/runtime/memory/lib/query-handlers.mjs +27 -5
- package/src/runtime/memory/tool-defs.mjs +2 -2
- package/src/runtime/shared/config.mjs +14 -4
- package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
- package/src/runtime/shared/schedules-store.mjs +13 -3
- package/src/runtime/shared/tool-execution-contract.mjs +2 -2
- package/src/session-runtime/config-helpers.mjs +45 -10
- package/src/session-runtime/quick-model-rows.mjs +46 -11
- package/src/session-runtime/quick-search-models.mjs +28 -27
- package/src/session-runtime/settings-api.mjs +34 -1
- package/src/session-runtime/tool-defs.mjs +1 -1
- package/src/session-runtime/workflow.mjs +1 -1
- package/src/standalone/agent-tool/tool-def.mjs +1 -1
- package/src/standalone/agent-tool.mjs +30 -0
- package/src/standalone/channel-admin.mjs +102 -90
- package/src/standalone/explore-tool.mjs +39 -10
- package/src/tui/App.jsx +131 -28
- package/src/tui/app/app-format.mjs +11 -4
- package/src/tui/app/channel-pickers.mjs +2 -4
- package/src/tui/app/maintenance-pickers.mjs +60 -28
- package/src/tui/app/model-options.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +0 -1
- package/src/tui/app/transcript-window.mjs +8 -2
- package/src/tui/app/use-transcript-scroll.mjs +3 -1
- package/src/tui/app/use-transcript-window.mjs +19 -1
- package/src/tui/components/ConfirmBar.jsx +10 -7
- package/src/tui/components/Picker.jsx +32 -11
- package/src/tui/components/PromptInput.jsx +10 -1
- package/src/tui/components/StatusLine.jsx +6 -3
- package/src/tui/dist/index.mjs +441 -293
- package/src/tui/engine.mjs +48 -12
- package/src/tui/index.jsx +11 -52
- package/src/ui/statusline.mjs +10 -0
- package/src/workflows/default/WORKFLOW.md +15 -17
- package/src/workflows/sequential/WORKFLOW.md +15 -17
- package/vendor/ink/build/ink.js +9 -1
- package/scripts/recall-usecase-probe.json +0 -6
- package/src/runtime/channels/lib/holidays.mjs +0 -138
|
@@ -24,6 +24,7 @@ export function createSettingsApi({
|
|
|
24
24
|
normalizeCompactTypeSetting,
|
|
25
25
|
normalizeSystemShellConfig,
|
|
26
26
|
normalizeSystemShellCommand,
|
|
27
|
+
autoClearProviderDefaults,
|
|
27
28
|
setConfiguredShell,
|
|
28
29
|
setRecapEnabledInConfig,
|
|
29
30
|
setModuleEnabledInConfig,
|
|
@@ -88,14 +89,29 @@ export function createSettingsApi({
|
|
|
88
89
|
const route = getRoute();
|
|
89
90
|
const normalized = normalizeAutoClearConfig(config.autoClear);
|
|
90
91
|
const provider = route?.provider || null;
|
|
91
|
-
const providerDefault = autoClearIdleMsForProvider(provider);
|
|
92
|
+
const providerDefault = autoClearIdleMsForProvider(provider, normalized.providerIdleMs);
|
|
92
93
|
const idleMs = normalized.custom ? normalized.idleMs : providerDefault;
|
|
94
|
+
// Advanced picker shows only providers the user actually has enabled
|
|
95
|
+
// (config.providers[*].enabled), plus the active route provider, any
|
|
96
|
+
// provider with a custom override, and the 'default' fallback row —
|
|
97
|
+
// not the full built-in table.
|
|
98
|
+
const enabledProviders = new Set(
|
|
99
|
+
Object.entries(config?.providers || {})
|
|
100
|
+
.filter(([, v]) => v && typeof v === 'object' && v.enabled !== false)
|
|
101
|
+
.map(([k]) => String(k).toLowerCase()),
|
|
102
|
+
);
|
|
103
|
+
if (provider) enabledProviders.add(String(provider).toLowerCase());
|
|
104
|
+
const providerDefaults = autoClearProviderDefaults(normalized.providerIdleMs)
|
|
105
|
+
.filter((entry) => entry.provider === 'default'
|
|
106
|
+
|| entry.custom === true
|
|
107
|
+
|| enabledProviders.has(entry.provider));
|
|
93
108
|
return {
|
|
94
109
|
enabled: normalized.enabled,
|
|
95
110
|
idleMs,
|
|
96
111
|
custom: normalized.custom,
|
|
97
112
|
providerDefault,
|
|
98
113
|
provider,
|
|
114
|
+
providerDefaults,
|
|
99
115
|
};
|
|
100
116
|
},
|
|
101
117
|
// --- User profile (/profile statusline command) ---------------------
|
|
@@ -225,6 +241,23 @@ export function createSettingsApi({
|
|
|
225
241
|
const current = normalizeAutoClearConfig(config.autoClear);
|
|
226
242
|
const next = { ...current };
|
|
227
243
|
if (hasOwn(input, 'enabled')) next.enabled = input.enabled !== false;
|
|
244
|
+
const providerKey = String(input.provider || '').trim().toLowerCase();
|
|
245
|
+
const editsProviderDefault = providerKey
|
|
246
|
+
&& (input.resetProvider === true || hasOwn(input, 'duration') || hasOwn(input, 'idleMs'));
|
|
247
|
+
if (editsProviderDefault) {
|
|
248
|
+
const providerIdleMs = { ...(next.providerIdleMs || {}) };
|
|
249
|
+
if (input.resetProvider === true || (hasOwn(input, 'idleMs') && input.idleMs == null)) {
|
|
250
|
+
delete providerIdleMs[providerKey];
|
|
251
|
+
} else {
|
|
252
|
+
const idleMs = hasOwn(input, 'duration') ? parseDurationMs(input.duration) : Number(input.idleMs);
|
|
253
|
+
if (!idleMs || !Number.isFinite(idleMs) || idleMs <= 0) throw new Error('usage: duration like 10m, 1h, or 24h');
|
|
254
|
+
providerIdleMs[providerKey] = Math.max(60_000, Math.round(idleMs));
|
|
255
|
+
}
|
|
256
|
+
next.providerIdleMs = providerIdleMs;
|
|
257
|
+
saveConfigAndAdopt({ ...config, autoClear: next });
|
|
258
|
+
const resolved = this.getAutoClear();
|
|
259
|
+
return { ...resolved, label: formatDurationMs(resolved.idleMs) };
|
|
260
|
+
}
|
|
228
261
|
if (input.reset === true || (hasOwn(input, 'idleMs') && input.idleMs == null)) {
|
|
229
262
|
next.idleMs = null;
|
|
230
263
|
} else if (hasOwn(input, 'idleMs')) {
|
|
@@ -36,7 +36,7 @@ export const CWD_TOOL = {
|
|
|
36
36
|
openWorldHint: false,
|
|
37
37
|
agentHidden: true,
|
|
38
38
|
},
|
|
39
|
-
description: 'Show or set the session work project for tool execution.',
|
|
39
|
+
description: 'Show or set the session work project for tool execution. Use only for explicit project path changes.',
|
|
40
40
|
inputSchema: {
|
|
41
41
|
type: 'object',
|
|
42
42
|
properties: {
|
|
@@ -18,7 +18,7 @@ export const FIXED_AGENT_SLOTS = Object.freeze([
|
|
|
18
18
|
{ id: 'debugger', label: 'Debugger', description: 'Root-cause analysis and failure tracing' },
|
|
19
19
|
]);
|
|
20
20
|
const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
|
|
21
|
-
const DEFAULT_WORKFLOW_ID = 'default';
|
|
21
|
+
export const DEFAULT_WORKFLOW_ID = 'default';
|
|
22
22
|
|
|
23
23
|
const SEARCH_CAPABLE_PROVIDERS = new Set([
|
|
24
24
|
'openai-oauth', 'openai', 'grok-oauth', 'xai', 'gemini', 'anthropic', 'anthropic-oauth',
|
|
@@ -35,7 +35,7 @@ export const AGENT_TOOL = {
|
|
|
35
35
|
openWorldHint: true,
|
|
36
36
|
agentHidden: true,
|
|
37
37
|
},
|
|
38
|
-
description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent scopes in parallel with distinct tags; for the same scope
|
|
38
|
+
description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent scopes in parallel with distinct tags; for the same scope, use send or spawn with the same tag to reuse the live session. Wait for the completion notification before dependent work. Do not call status/read after spawn; they are manual recovery only.',
|
|
39
39
|
inputSchema: {
|
|
40
40
|
type: 'object',
|
|
41
41
|
properties: {
|
|
@@ -92,6 +92,31 @@ function reapMsForProvider(provider) {
|
|
|
92
92
|
// cap and restore strictly-unbounded prep.
|
|
93
93
|
const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS', 120_000);
|
|
94
94
|
|
|
95
|
+
// Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
|
|
96
|
+
// first provider calls in the same instant, racing the server-side prompt-
|
|
97
|
+
// cache write/propagation window. Bench (parallel-10, identical-ms starts):
|
|
98
|
+
// 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%. 2000ms default
|
|
99
|
+
// picked as a low-cost middle ground. Chain (not a fixed lane count) so it
|
|
100
|
+
// scales to any N: each new spawn's start is pushed to at least STAGGER_MS
|
|
101
|
+
// after the previous spawn's start; sequential/non-overlapping spawns (now
|
|
102
|
+
// already past the window) pay zero added latency. MIXDOG_SPAWN_STAGGER_MS=0
|
|
103
|
+
// disables. Applied inside the deferred job body (see startDeferredSpawnJob)
|
|
104
|
+
// so the agent tool call itself still returns task_id immediately.
|
|
105
|
+
const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 2000);
|
|
106
|
+
let lastSpawnStartAt = 0;
|
|
107
|
+
async function waitForSpawnStagger() {
|
|
108
|
+
if (SPAWN_STAGGER_MS <= 0) return;
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const myStart = Math.max(now, lastSpawnStartAt + SPAWN_STAGGER_MS);
|
|
111
|
+
lastSpawnStartAt = myStart;
|
|
112
|
+
const delay = myStart - now;
|
|
113
|
+
if (delay <= 0) return;
|
|
114
|
+
await new Promise((resolve) => {
|
|
115
|
+
const t = setTimeout(resolve, delay);
|
|
116
|
+
t.unref?.();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd, onSubagentEvent }) {
|
|
96
121
|
// Optional bridge to the standard hook bus for SubagentStart / SubagentStop.
|
|
97
122
|
// Best-effort: a hook error must never affect worker spawn/finish.
|
|
@@ -881,6 +906,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
881
906
|
|
|
882
907
|
function startDeferredSpawnJob(args, callerCwd, context, notifyContext, extras = {}) {
|
|
883
908
|
return startJob('spawn', pendingSpawnMeta(args, extras), async (job) => {
|
|
909
|
+
// Stagger concurrent spawn starts before prep/model-call begins (see
|
|
910
|
+
// SPAWN_STAGGER_MS above). Runs inside the job body, so the tool call
|
|
911
|
+
// that queued this job already returned task_id before this awaits.
|
|
912
|
+
await waitForSpawnStagger();
|
|
913
|
+
if (job?.status === 'cancelled') return null;
|
|
884
914
|
// prepareSpawn (ensureProvider/prepareAgentSession) runs before runSpawn
|
|
885
915
|
// installs its progress watchdog, so guard prep with an internal env-
|
|
886
916
|
// backed cap rather than exposing per-call timeout knobs on the agent
|
|
@@ -22,20 +22,15 @@ import {
|
|
|
22
22
|
updateSection,
|
|
23
23
|
} from '../runtime/shared/config.mjs';
|
|
24
24
|
import { resolvePluginData } from '../runtime/shared/plugin-paths.mjs';
|
|
25
|
-
import { readMarkdownDocument } from '../runtime/shared/markdown-frontmatter.mjs';
|
|
25
|
+
import { readMarkdownDocument, serializeFrontmatterDoc } from '../runtime/shared/markdown-frontmatter.mjs';
|
|
26
26
|
|
|
27
27
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
28
28
|
const DEFAULT_CHANNELS = Object.freeze({
|
|
29
29
|
backend: 'discord',
|
|
30
30
|
discord: {},
|
|
31
31
|
access: { dmPolicy: 'allowlist', allowFrom: [], channels: {} },
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
main: { channelId: '', mode: 'interactive' },
|
|
35
|
-
},
|
|
36
|
-
quiet: { schedule: '23:00-09:00', holidays: false },
|
|
37
|
-
schedules: { respectQuiet: true },
|
|
38
|
-
webhook: { enabled: true, respectQuiet: false, port: 3333 },
|
|
32
|
+
channel: { channelId: '', discordChannelId: '', telegramChatId: '' },
|
|
33
|
+
webhook: { enabled: true, port: 3333 },
|
|
39
34
|
});
|
|
40
35
|
|
|
41
36
|
function dataDir() {
|
|
@@ -61,24 +56,6 @@ function writeTextAtomic(path, text) {
|
|
|
61
56
|
renameSync(tmp, path);
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
// Serialize a single-file frontmatter markdown document:
|
|
65
|
-
// ---
|
|
66
|
-
// key: value
|
|
67
|
-
// ---
|
|
68
|
-
//
|
|
69
|
-
// <body>
|
|
70
|
-
// Values are stringified as-is (enabled:false -> "false"); the reader casts
|
|
71
|
-
// types back. Mirrors the WORKFLOW.md / SKILL.md single-file convention.
|
|
72
|
-
function serializeFrontmatterDoc(meta = {}, body = '') {
|
|
73
|
-
const lines = ['---'];
|
|
74
|
-
for (const [key, value] of Object.entries(meta)) {
|
|
75
|
-
if (value === undefined || value === null) continue;
|
|
76
|
-
lines.push(`${key}: ${value}`);
|
|
77
|
-
}
|
|
78
|
-
lines.push('---', '');
|
|
79
|
-
return `${lines.join('\n')}\n${String(body || '').trim()}\n`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
59
|
function assertName(name, kind = 'name') {
|
|
83
60
|
const value = String(name || '').trim();
|
|
84
61
|
if (!NAME_RE.test(value) || value !== basename(value)) {
|
|
@@ -93,9 +70,7 @@ function normalizeChannelsConfig(raw = {}) {
|
|
|
93
70
|
...(raw && typeof raw === 'object' ? raw : {}),
|
|
94
71
|
discord: { ...DEFAULT_CHANNELS.discord, ...(raw?.discord || {}) },
|
|
95
72
|
access: { ...DEFAULT_CHANNELS.access, ...(raw?.access || {}) },
|
|
96
|
-
|
|
97
|
-
quiet: { ...DEFAULT_CHANNELS.quiet, ...(raw?.quiet || {}) },
|
|
98
|
-
schedules: { ...DEFAULT_CHANNELS.schedules, ...(raw?.schedules || {}) },
|
|
73
|
+
channel: { ...DEFAULT_CHANNELS.channel, ...(raw?.channel || {}) },
|
|
99
74
|
webhook: { ...DEFAULT_CHANNELS.webhook, ...(raw?.webhook || {}) },
|
|
100
75
|
};
|
|
101
76
|
}
|
|
@@ -116,11 +91,42 @@ function seedBackendChannelIds(entry = {}, backend = 'discord') {
|
|
|
116
91
|
return next;
|
|
117
92
|
}
|
|
118
93
|
|
|
94
|
+
// Resolve the single-channel entry from the config: prefer the new `channel`
|
|
95
|
+
// object; fall back (read-side only, no on-disk migration) to the legacy
|
|
96
|
+
// channelsConfig[mainChannel ?? 'main'] entry, then the first entry with an id.
|
|
97
|
+
function resolveChannelEntry(cfg = {}) {
|
|
98
|
+
if (cfg.channel && typeof cfg.channel === 'object'
|
|
99
|
+
&& (cfg.channel.channelId || cfg.channel.discordChannelId || cfg.channel.telegramChatId)) {
|
|
100
|
+
return cfg.channel;
|
|
101
|
+
}
|
|
102
|
+
const legacy = cfg.channelsConfig && typeof cfg.channelsConfig === 'object' ? cfg.channelsConfig : null;
|
|
103
|
+
if (legacy) {
|
|
104
|
+
const mainName = cfg.mainChannel ?? 'main';
|
|
105
|
+
const preferred = legacy[mainName];
|
|
106
|
+
if (preferred && typeof preferred === 'object'
|
|
107
|
+
&& (preferred.channelId || preferred.discordChannelId || preferred.telegramChatId)) {
|
|
108
|
+
return preferred;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of Object.values(legacy)) {
|
|
111
|
+
if (entry && typeof entry === 'object'
|
|
112
|
+
&& (entry.channelId || entry.discordChannelId || entry.telegramChatId)) {
|
|
113
|
+
return entry;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return cfg.channel && typeof cfg.channel === 'object' ? cfg.channel : {};
|
|
118
|
+
}
|
|
119
|
+
|
|
119
120
|
function updateChannelsSection(build) {
|
|
120
121
|
let next;
|
|
121
122
|
updateSection('channels', (current) => {
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
// Preserve any legacy channelsConfig/mainChannel that already live on disk
|
|
124
|
+
// for read-side compat, but never re-emit them from our writers: strip them
|
|
125
|
+
// out of the returned shape so writes converge on the single `channel`.
|
|
126
|
+
const normalized = normalizeChannelsConfig(current);
|
|
127
|
+
next = build(normalized);
|
|
128
|
+
const { channelsConfig: _lc, mainChannel: _lm, ...clean } = normalizeChannelsConfig(next);
|
|
129
|
+
return clean;
|
|
124
130
|
});
|
|
125
131
|
return next;
|
|
126
132
|
}
|
|
@@ -136,19 +142,17 @@ function listEntryDirs(dir) {
|
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
|
|
145
|
+
// Single-channel read: resolves `cfg.channel` (legacy channelsConfig fallback)
|
|
146
|
+
// into the flat shape the settings/TUI layer consumes.
|
|
147
|
+
export function getChannel(config = {}) {
|
|
140
148
|
const cfg = normalizeChannelsConfig(config);
|
|
141
149
|
const backend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
telegramChatId: String(value?.telegramChatId || ''),
|
|
149
|
-
mode: value?.mode || 'interactive',
|
|
150
|
-
main: cfg.mainChannel === name,
|
|
151
|
-
}));
|
|
150
|
+
const entry = resolveChannelEntry(cfg);
|
|
151
|
+
return {
|
|
152
|
+
channelId: channelIdForBackend(entry, backend),
|
|
153
|
+
discordChannelId: String(entry?.discordChannelId || ''),
|
|
154
|
+
telegramChatId: String(entry?.telegramChatId || ''),
|
|
155
|
+
};
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
export function saveDiscordToken(token) {
|
|
@@ -187,40 +191,24 @@ export function forgetWebhookAuthtoken() {
|
|
|
187
191
|
return { ok: true };
|
|
188
192
|
}
|
|
189
193
|
|
|
190
|
-
|
|
191
|
-
|
|
194
|
+
// Single-channel write: persists `cfg.channel`, keeping the per-backend id
|
|
195
|
+
// fields (discordChannelId/telegramChatId) so a backend switch retains both
|
|
196
|
+
// ids. `backend` selects which per-backend field the id updates; when omitted
|
|
197
|
+
// the id lands on the active backend's field.
|
|
198
|
+
export function setChannel({ channelId, backend = null } = {}) {
|
|
192
199
|
const id = String(channelId || '').trim();
|
|
193
200
|
if (!id) throw new Error('channelId is required');
|
|
194
|
-
const nextMode = mode === 'broadcast' ? 'broadcast' : 'interactive';
|
|
195
201
|
const targetBackend = backend === 'telegram' || backend === 'discord' ? backend : null;
|
|
196
202
|
return updateChannelsSection((cfg) => {
|
|
197
203
|
const activeBackend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
else nextEntry.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return {
|
|
207
|
-
...cfg,
|
|
208
|
-
mainChannel: main || !cfg.mainChannel ? label : cfg.mainChannel,
|
|
209
|
-
channelsConfig: {
|
|
210
|
-
...(cfg.channelsConfig || {}),
|
|
211
|
-
[label]: nextEntry,
|
|
212
|
-
},
|
|
213
|
-
};
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export function deleteChannel(name) {
|
|
218
|
-
const label = assertName(name, 'channel name');
|
|
219
|
-
return updateChannelsSection((cfg) => {
|
|
220
|
-
const channelsConfig = { ...(cfg.channelsConfig || {}) };
|
|
221
|
-
delete channelsConfig[label];
|
|
222
|
-
const nextMain = cfg.mainChannel === label ? Object.keys(channelsConfig)[0] || 'main' : cfg.mainChannel;
|
|
223
|
-
return { ...cfg, channelsConfig, mainChannel: nextMain };
|
|
204
|
+
const writeBackend = targetBackend || activeBackend;
|
|
205
|
+
const current = seedBackendChannelIds(resolveChannelEntry(cfg), activeBackend);
|
|
206
|
+
const nextEntry = { ...current };
|
|
207
|
+
if (writeBackend === 'telegram') nextEntry.telegramChatId = id;
|
|
208
|
+
else nextEntry.discordChannelId = id;
|
|
209
|
+
// `channelId` mirrors the active backend's id for legacy readers.
|
|
210
|
+
nextEntry.channelId = channelIdForBackend(nextEntry, activeBackend);
|
|
211
|
+
return { ...cfg, channel: nextEntry };
|
|
224
212
|
});
|
|
225
213
|
}
|
|
226
214
|
|
|
@@ -230,7 +218,6 @@ export function setWebhookConfig(patch = {}) {
|
|
|
230
218
|
webhook: {
|
|
231
219
|
...(cfg.webhook || {}),
|
|
232
220
|
...(Object.prototype.hasOwnProperty.call(patch, 'enabled') ? { enabled: patch.enabled === true } : {}),
|
|
233
|
-
...(Object.prototype.hasOwnProperty.call(patch, 'respectQuiet') ? { respectQuiet: patch.respectQuiet === true } : {}),
|
|
234
221
|
...(patch.port ? { port: Number(patch.port) || 3333 } : {}),
|
|
235
222
|
...(patch.domain ? { domain: String(patch.domain).trim() } : {}),
|
|
236
223
|
...(patch.ngrokDomain ? { ngrokDomain: String(patch.ngrokDomain).trim() } : {}),
|
|
@@ -245,14 +232,11 @@ export function setBackend(name) {
|
|
|
245
232
|
}
|
|
246
233
|
updateChannelsSection((cfg) => {
|
|
247
234
|
const activeBackend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
})(),
|
|
254
|
-
]));
|
|
255
|
-
return { ...cfg, channelsConfig, backend: value };
|
|
235
|
+
const seeded = seedBackendChannelIds(resolveChannelEntry(cfg), activeBackend);
|
|
236
|
+
// `channelId` now mirrors the newly-selected backend's id; both per-backend
|
|
237
|
+
// id fields are retained so switching back keeps the other id.
|
|
238
|
+
const channel = { ...seeded, channelId: channelIdForBackend(seeded, value) };
|
|
239
|
+
return { ...cfg, channel, backend: value };
|
|
256
240
|
});
|
|
257
241
|
return { ok: true, backend: value };
|
|
258
242
|
}
|
|
@@ -274,8 +258,15 @@ export function listSchedules() {
|
|
|
274
258
|
if (Object.prototype.hasOwnProperty.call(config, 'enabled')) {
|
|
275
259
|
config.enabled = config.enabled !== 'false' && config.enabled !== false;
|
|
276
260
|
}
|
|
261
|
+
// Dir name is canonical identity; frontmatter `name` is display-only and
|
|
262
|
+
// must never override the slug used for routing/deletion. `description`
|
|
263
|
+
// defaults to '' for old files with no such key (backward compat).
|
|
264
|
+
const description = String(config.description || '');
|
|
265
|
+
delete config.name;
|
|
266
|
+
delete config.description;
|
|
277
267
|
return {
|
|
278
268
|
name,
|
|
269
|
+
description,
|
|
279
270
|
...config,
|
|
280
271
|
instructions: body,
|
|
281
272
|
route: config.channel ? `channel:${config.channel}` : 'session',
|
|
@@ -285,6 +276,7 @@ export function listSchedules() {
|
|
|
285
276
|
|
|
286
277
|
export function saveSchedule({
|
|
287
278
|
name,
|
|
279
|
+
description = '',
|
|
288
280
|
time,
|
|
289
281
|
timezone,
|
|
290
282
|
days,
|
|
@@ -302,7 +294,9 @@ export function saveSchedule({
|
|
|
302
294
|
const dir = join(schedulesDir(), id);
|
|
303
295
|
if (existsSync(dir) && overwrite !== true) throw new Error(`schedule "${id}" already exists`);
|
|
304
296
|
mkdirSync(dir, { recursive: true });
|
|
305
|
-
|
|
297
|
+
// name (== dir slug) and description lead the frontmatter per the SKILL.md
|
|
298
|
+
// convention; operational keys follow.
|
|
299
|
+
const config = { name: id, description: String(description || '').trim(), time: cron };
|
|
306
300
|
if (timezone) config.timezone = String(timezone).trim();
|
|
307
301
|
if (days && days !== 'daily') config.days = String(days).trim();
|
|
308
302
|
if (channel) config.channel = String(channel).trim();
|
|
@@ -324,7 +318,13 @@ export function setScheduleEnabled(name, enabled) {
|
|
|
324
318
|
const mdPath = join(dir, 'SCHEDULE.md');
|
|
325
319
|
if (!existsSync(mdPath)) throw new Error(`schedule "${id}" does not exist`);
|
|
326
320
|
const { frontmatter, body } = readMarkdownDocument(readText(mdPath, ''));
|
|
327
|
-
|
|
321
|
+
// Re-spread with name/description first so the round-trip keeps SKILL.md
|
|
322
|
+
// key order; name stays the dir slug regardless of stale frontmatter.
|
|
323
|
+
const { name: _staleName, description = '', ...rest } = frontmatter;
|
|
324
|
+
writeTextAtomic(mdPath, serializeFrontmatterDoc(
|
|
325
|
+
{ name: id, description, ...rest, enabled: enabled !== false },
|
|
326
|
+
body,
|
|
327
|
+
));
|
|
328
328
|
return { name: id, enabled: enabled !== false };
|
|
329
329
|
}
|
|
330
330
|
|
|
@@ -336,12 +336,19 @@ export function listWebhooks() {
|
|
|
336
336
|
if (Object.prototype.hasOwnProperty.call(config, 'enabled')) {
|
|
337
337
|
config.enabled = config.enabled !== 'false' && config.enabled !== false;
|
|
338
338
|
}
|
|
339
|
+
// Dir name is canonical identity/URL routing key; frontmatter `name` is
|
|
340
|
+
// display-only and must not override the slug. `description` defaults to
|
|
341
|
+
// '' for old files with no such key (backward compat).
|
|
342
|
+
const description = String(config.description || '');
|
|
343
|
+
delete config.name;
|
|
344
|
+
delete config.description;
|
|
339
345
|
// Secret lives in a side file (<name>/secret), never in frontmatter, so
|
|
340
346
|
// an arbitrary user-supplied secret (quotes/colon/newline) round-trips
|
|
341
347
|
// losslessly and setWebhookEnabled cannot corrupt it.
|
|
342
348
|
const hasSecret = Boolean(readText(join(dir, 'secret'), '').trim());
|
|
343
349
|
return {
|
|
344
350
|
name,
|
|
351
|
+
description,
|
|
345
352
|
...config,
|
|
346
353
|
secretSet: hasSecret,
|
|
347
354
|
secret: undefined,
|
|
@@ -353,6 +360,7 @@ export function listWebhooks() {
|
|
|
353
360
|
|
|
354
361
|
export function saveWebhook({
|
|
355
362
|
name,
|
|
363
|
+
description = '',
|
|
356
364
|
parser = 'github',
|
|
357
365
|
secret,
|
|
358
366
|
channel,
|
|
@@ -373,7 +381,9 @@ export function saveWebhook({
|
|
|
373
381
|
if (existsSync(dir) && overwrite !== true) throw new Error(`webhook "${id}" already exists`);
|
|
374
382
|
mkdirSync(dir, { recursive: true });
|
|
375
383
|
const secretValue = String(secret || randomBytes(24).toString('hex')).trim();
|
|
376
|
-
|
|
384
|
+
// name (== dir slug) and description lead the frontmatter per the SKILL.md
|
|
385
|
+
// convention; operational keys (parser/channel/model/enabled) follow.
|
|
386
|
+
const config = { name: id, description: String(description || '').trim(), parser: nextParser };
|
|
377
387
|
if (channel) config.channel = String(channel).trim();
|
|
378
388
|
if (model) config.model = String(model).trim();
|
|
379
389
|
if (enabled === false) config.enabled = false;
|
|
@@ -397,7 +407,13 @@ export function setWebhookEnabled(name, enabled) {
|
|
|
397
407
|
const mdPath = join(dir, 'WEBHOOK.md');
|
|
398
408
|
if (!existsSync(mdPath)) throw new Error(`webhook "${id}" does not exist`);
|
|
399
409
|
const { frontmatter, body } = readMarkdownDocument(readText(mdPath, ''));
|
|
400
|
-
|
|
410
|
+
// Re-spread with name/description first so the round-trip keeps SKILL.md
|
|
411
|
+
// key order; name stays the dir slug regardless of stale frontmatter.
|
|
412
|
+
const { name: _staleName, description = '', ...rest } = frontmatter;
|
|
413
|
+
writeTextAtomic(mdPath, serializeFrontmatterDoc(
|
|
414
|
+
{ name: id, description, ...rest, enabled: enabled !== false },
|
|
415
|
+
body,
|
|
416
|
+
));
|
|
401
417
|
return { name: id, enabled: enabled !== false };
|
|
402
418
|
}
|
|
403
419
|
|
|
@@ -429,7 +445,7 @@ export function channelSetup(config = null) {
|
|
|
429
445
|
stored: hasStoredSecret(SECRET_ACCOUNTS.webhookAuth),
|
|
430
446
|
status: webhookAuth ? 'Set' : 'Off',
|
|
431
447
|
},
|
|
432
|
-
|
|
448
|
+
channel: getChannel(cfg),
|
|
433
449
|
schedules: listSchedules(),
|
|
434
450
|
webhooks: listWebhooks(),
|
|
435
451
|
};
|
|
@@ -440,11 +456,7 @@ export function renderChannelStatus(config = null) {
|
|
|
440
456
|
const lines = [];
|
|
441
457
|
lines.push(`discord ${setup.discord.status}${setup.discord.problem ? ` (${setup.discord.problem})` : ''}`);
|
|
442
458
|
lines.push(`webhook ${setup.webhook.enabled === false ? 'disabled' : 'enabled'} · auth ${setup.webhook.status} · port ${setup.webhook.port || 3333}`);
|
|
443
|
-
lines.push('
|
|
444
|
-
if (setup.channels.length === 0) lines.push(' (none)');
|
|
445
|
-
for (const ch of setup.channels) {
|
|
446
|
-
lines.push(` ${ch.name}${ch.main ? ' *' : ''} ${ch.channelId || '(unset)'} ${ch.mode}`);
|
|
447
|
-
}
|
|
459
|
+
lines.push(`channel ${setup.channel.channelId || '(unset)'}`);
|
|
448
460
|
lines.push('schedules');
|
|
449
461
|
if (setup.schedules.length === 0) lines.push(' (none)');
|
|
450
462
|
for (const item of setup.schedules) {
|
|
@@ -39,8 +39,28 @@ const EXPLORE_RESULT_CACHE_TTL_MS = (() => {
|
|
|
39
39
|
const raw = Number(process.env.MIXDOG_EXPLORE_RESULT_CACHE_TTL_MS);
|
|
40
40
|
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5 * 60_000;
|
|
41
41
|
})();
|
|
42
|
+
// Hard ceiling for one explorer compute. The dispatch-level watchdog is the
|
|
43
|
+
// primary abort; this race is the last line of defence so a wedged compute can
|
|
44
|
+
// never hang the awaiting tool call (and its cache key) forever.
|
|
45
|
+
export const EXPLORE_COMPUTE_HARD_TIMEOUT_MS = (() => {
|
|
46
|
+
const raw = Number(process.env.MIXDOG_EXPLORE_HARD_TIMEOUT_MS);
|
|
47
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 10 * 60_000;
|
|
48
|
+
})();
|
|
42
49
|
const EXPLORE_RESULT_CACHE = new Map(); // key -> { ts, value?, promise? }
|
|
43
50
|
|
|
51
|
+
function withExploreHardTimeout(promise) {
|
|
52
|
+
if (!(EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0)) return promise;
|
|
53
|
+
let timer = null;
|
|
54
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
55
|
+
timer = setTimeout(
|
|
56
|
+
() => reject(new Error(`explorer timed out after ${EXPLORE_COMPUTE_HARD_TIMEOUT_MS}ms`)),
|
|
57
|
+
EXPLORE_COMPUTE_HARD_TIMEOUT_MS,
|
|
58
|
+
);
|
|
59
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
60
|
+
});
|
|
61
|
+
return Promise.race([promise, timeout]).finally(() => { if (timer) clearTimeout(timer); });
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
function escapeXml(str) {
|
|
45
65
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
46
66
|
}
|
|
@@ -50,7 +70,7 @@ function escapeXml(str) {
|
|
|
50
70
|
// reminder. The full no-verdict contract lives at system level
|
|
51
71
|
// (rules/agent/30-explorer.md).
|
|
52
72
|
export function buildExplorerPrompt(query) {
|
|
53
|
-
return `<query>${escapeXml(query)}</query>\nReminder:
|
|
73
|
+
return `<query>${escapeXml(query)}</query>\nReminder: budget 4 tool turns — batch every candidate lookup per turn, never one tool at a time. The moment you have credible anchors, STOP and answer; spend remaining turns only while you have zero anchors. Output only anchor lines formatted as path:line — symbol/name — short reason, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
|
|
54
74
|
}
|
|
55
75
|
|
|
56
76
|
export function normalizeExploreQueries(rawQuery) {
|
|
@@ -209,11 +229,19 @@ function getCachedExploreResult(key) {
|
|
|
209
229
|
async function runExploreCached(key, compute) {
|
|
210
230
|
const cached = getCachedExploreResult(key);
|
|
211
231
|
if (cached !== null) return cached;
|
|
212
|
-
if (!exploreResultCacheEnabled()) return await compute();
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
232
|
+
if (!exploreResultCacheEnabled()) return await withExploreHardTimeout(compute());
|
|
233
|
+
const entry = EXPLORE_RESULT_CACHE.get(key);
|
|
234
|
+
if (entry?.promise) {
|
|
235
|
+
// Pending in-flight dedup — but never trust a pending entry forever: a
|
|
236
|
+
// compute that outlived the hard timeout is wedged (its own race should
|
|
237
|
+
// have rejected); drop the poisoned key and recompute fresh.
|
|
238
|
+
if (!(EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0) || Date.now() - entry.ts <= EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
|
|
239
|
+
return await entry.promise;
|
|
240
|
+
}
|
|
241
|
+
EXPLORE_RESULT_CACHE.delete(key);
|
|
242
|
+
}
|
|
215
243
|
const promise = Promise.resolve()
|
|
216
|
-
.then(() => compute())
|
|
244
|
+
.then(() => withExploreHardTimeout(compute()))
|
|
217
245
|
.then((value) => {
|
|
218
246
|
const text = typeof value === 'string' ? value : responseText(value);
|
|
219
247
|
const cleaned = cleanExplorerText(text);
|
|
@@ -327,15 +355,16 @@ async function runExploreSync(args = {}, ctx = {}) {
|
|
|
327
355
|
const presetName = (route && typeof route === 'object')
|
|
328
356
|
? `${clean(route.provider)}/${clean(route.model)}`
|
|
329
357
|
: (route || '');
|
|
330
|
-
// Explorer
|
|
331
|
-
// rules/agent/30-explorer.md
|
|
332
|
-
//
|
|
333
|
-
//
|
|
358
|
+
// Explorer self-manages its turn budget: the 4-turn budget lives ONLY in
|
|
359
|
+
// explorer-scoped surfaces (rules/agent/30-explorer.md + per-query prompt),
|
|
360
|
+
// never in shared loop policy. The cap here is a runaway guard, unchanged
|
|
361
|
+
// from the original value; B-round live tests showed hard targets
|
|
362
|
+
// legitimately need ~10.
|
|
334
363
|
const llm = makeAgentDispatch({
|
|
335
364
|
agent: 'explorer',
|
|
336
365
|
cwd: resolvedCwd,
|
|
337
366
|
brief: true,
|
|
338
|
-
maxLoopIterations:
|
|
367
|
+
maxLoopIterations: 12,
|
|
339
368
|
parentSessionId: ctx.callerSessionId || null,
|
|
340
369
|
clientHostPid: ctx.clientHostPid || null,
|
|
341
370
|
config,
|