mixdog 0.9.4 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +112 -38
  2. package/package.json +4 -1
  3. package/scripts/bench/r5-orchestrated-task.json +7 -0
  4. package/scripts/bench/round-r13-clientmeta.json +464 -0
  5. package/scripts/bench/round-r14-betafeatures.json +466 -0
  6. package/scripts/bench/round-r15-fulldefault.json +462 -0
  7. package/scripts/bench/round-r16-sessionid.json +466 -0
  8. package/scripts/bench/round-r17-wirebytes.json +456 -0
  9. package/scripts/bench/round-r18-prewarm.json +468 -0
  10. package/scripts/bench/round-r19-clean.json +472 -0
  11. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  12. package/scripts/bench/round-r21-delta-retry.json +473 -0
  13. package/scripts/bench/round-r22-full-probe.json +693 -0
  14. package/scripts/bench/round-r23-itemprobe.json +701 -0
  15. package/scripts/bench/round-r24-shapefix.json +677 -0
  16. package/scripts/bench/round-r25-serial.json +464 -0
  17. package/scripts/bench/round-r26-parallel3.json +671 -0
  18. package/scripts/bench/round-r27-parallel10.json +894 -0
  19. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  20. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  21. package/scripts/bench/round-r30-instid.json +253 -0
  22. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  23. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  24. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  25. package/scripts/bench/round-r34-orchestrated.json +120 -0
  26. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  27. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  28. package/scripts/bench-run.mjs +40 -7
  29. package/scripts/internal-comms-bench.mjs +2 -4
  30. package/scripts/model-catalog-audit.mjs +209 -0
  31. package/scripts/model-list-sanitize-test.mjs +37 -0
  32. package/scripts/recall-bench.mjs +76 -13
  33. package/scripts/recall-quality-cases.json +12 -0
  34. package/scripts/tool-smoke.mjs +2 -2
  35. package/src/agents/heavy-worker/AGENT.md +7 -7
  36. package/src/agents/scheduler-task/AGENT.md +2 -3
  37. package/src/agents/webhook-handler/AGENT.md +2 -3
  38. package/src/agents/worker/AGENT.md +9 -10
  39. package/src/app.mjs +12 -1
  40. package/src/headless-role.mjs +7 -1
  41. package/src/mixdog-session-runtime.mjs +15 -14
  42. package/src/output-styles/default.md +14 -18
  43. package/src/output-styles/minimal.md +4 -5
  44. package/src/output-styles/simple.md +7 -8
  45. package/src/rules/agent/00-common.md +6 -5
  46. package/src/rules/agent/30-explorer.md +16 -5
  47. package/src/rules/lead/01-general.md +5 -5
  48. package/src/rules/lead/lead-brief.md +6 -3
  49. package/src/rules/lead/lead-tool.md +6 -4
  50. package/src/rules/shared/01-tool.md +17 -21
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  52. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +19 -0
  53. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +9 -1
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +53 -9
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +23 -10
  57. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +33 -4
  59. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  60. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +93 -0
  61. package/src/runtime/agent/orchestrator/session/loop.mjs +27 -5
  62. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  66. package/src/runtime/channels/backends/discord-gateway.mjs +2 -2
  67. package/src/runtime/channels/backends/discord.mjs +15 -26
  68. package/src/runtime/channels/backends/telegram.mjs +8 -12
  69. package/src/runtime/channels/index.mjs +23 -43
  70. package/src/runtime/channels/lib/backend-dispatch.mjs +5 -3
  71. package/src/runtime/channels/lib/config.mjs +37 -149
  72. package/src/runtime/channels/lib/event-pipeline.mjs +4 -4
  73. package/src/runtime/channels/lib/event-queue.mjs +15 -9
  74. package/src/runtime/channels/lib/inbound-routing.mjs +2 -39
  75. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  76. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  77. package/src/runtime/channels/lib/webhook/deliveries.mjs +3 -2
  78. package/src/runtime/channels/lib/webhook.mjs +8 -47
  79. package/src/runtime/memory/index.mjs +9 -0
  80. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  81. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  82. package/src/runtime/memory/lib/memory-recall-store.mjs +47 -10
  83. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  84. package/src/runtime/memory/lib/query-handlers.mjs +27 -5
  85. package/src/runtime/memory/tool-defs.mjs +2 -2
  86. package/src/runtime/shared/config.mjs +14 -4
  87. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  88. package/src/runtime/shared/schedules-store.mjs +13 -3
  89. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  90. package/src/session-runtime/config-helpers.mjs +45 -10
  91. package/src/session-runtime/quick-model-rows.mjs +46 -11
  92. package/src/session-runtime/quick-search-models.mjs +28 -27
  93. package/src/session-runtime/settings-api.mjs +34 -1
  94. package/src/session-runtime/tool-defs.mjs +1 -1
  95. package/src/session-runtime/workflow.mjs +1 -1
  96. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  97. package/src/standalone/agent-tool.mjs +30 -0
  98. package/src/standalone/channel-admin.mjs +102 -90
  99. package/src/standalone/explore-tool.mjs +39 -10
  100. package/src/tui/App.jsx +126 -28
  101. package/src/tui/app/app-format.mjs +11 -4
  102. package/src/tui/app/channel-pickers.mjs +2 -4
  103. package/src/tui/app/maintenance-pickers.mjs +60 -28
  104. package/src/tui/app/model-options.mjs +5 -1
  105. package/src/tui/app/settings-picker.mjs +0 -1
  106. package/src/tui/app/transcript-window.mjs +8 -2
  107. package/src/tui/app/use-transcript-scroll.mjs +3 -1
  108. package/src/tui/app/use-transcript-window.mjs +19 -1
  109. package/src/tui/components/ConfirmBar.jsx +10 -7
  110. package/src/tui/components/Picker.jsx +32 -11
  111. package/src/tui/components/PromptInput.jsx +10 -1
  112. package/src/tui/components/StatusLine.jsx +6 -3
  113. package/src/tui/dist/index.mjs +440 -293
  114. package/src/tui/engine.mjs +48 -12
  115. package/src/tui/index.jsx +11 -52
  116. package/src/ui/statusline.mjs +10 -0
  117. package/src/workflows/default/WORKFLOW.md +15 -17
  118. package/src/workflows/sequential/WORKFLOW.md +15 -17
  119. package/vendor/ink/build/ink.js +9 -1
  120. package/scripts/recall-usecase-probe.json +0 -6
  121. package/src/runtime/channels/lib/holidays.mjs +0 -138
@@ -4,7 +4,7 @@ function __mixdogMemoryLog(...args) {
4
4
  return __mixdogMemoryStderrWrite(...args);
5
5
  }
6
6
 
7
- import { buildFtsQuery } from './memory-text-utils.mjs'
7
+ import { buildFtsQuery, buildFtsPrefixQuery } from './memory-text-utils.mjs'
8
8
  import { VALID_CATEGORY, embeddingToSql } from './memory.mjs'
9
9
  import { freshnessFactor } from './memory-score.mjs'
10
10
  import { buildRecallScopeFilter } from './memory-recall-scope-filter.mjs'
@@ -28,6 +28,21 @@ const SEMANTIC_TOP_RANK_MAX = 3
28
28
  // the cross-language rescue while rejecting pure-noise top ranks.
29
29
  const SEMANTIC_TOP_RANK_MIN_SIM = 0.70
30
30
  const SHORT_QUERY_TOKEN_MAX = 2
31
+ // Dense-similarity weighting for retrievalScore. Lexical RRF sums 1/(K+rank)
32
+ // per matching leg (K=60), so a row hitting 2 lexical legs at rank 1 scores
33
+ // rrf ≈ 2/(60+1) = 0.0328 — enough to bury a single semantically-close dense
34
+ // hit that never shares lexical terms. We add a normalized dense-similarity
35
+ // term so a strong dense-only match can outrank stale multi-leg lexical hits:
36
+ // denseTerm = W_DENSE * max(0, sim - SIM_FLOOR) / (1 - SIM_FLOOR) * freshness
37
+ // At sim=0.85: (0.85-0.60)/(1-0.60) = 0.625, so denseTerm = 0.04 * 0.625 = 0.025
38
+ // with freshness=1. That alone doesn't beat 0.0328, but combined with the
39
+ // row's own rrf contribution (a dense-only row still has 1/(60+rank) from its
40
+ // dense rank, e.g. rank 1 → 0.0164) the total ≈ 0.0164 + 0.025 = 0.0414,
41
+ // which clears the 0.0328 two-leg lexical baseline. W_DENSE=0.04 was chosen
42
+ // as the smallest round value satisfying this at sim=0.85; SIM_FLOOR=0.60
43
+ // matches the noise floor used elsewhere for MiniLM-class embeddings.
44
+ const SIM_FLOOR = 0.60
45
+ const W_DENSE = 0.04
31
46
 
32
47
  function buildExactTerms(query) {
33
48
  const clean = String(query ?? '').replace(/\s+/g, ' ').trim()
@@ -230,7 +245,13 @@ export async function searchRelevantHybrid(db, query, options = {}) {
230
245
  ? embeddingToSql(options.queryVector)
231
246
  : null
232
247
 
233
- const ftsQuery = clean.length >= 3 ? (buildFtsQuery(clean) ?? null) : null
248
+ // Prefer the Kiwi morph prefix-form query (to_tsquery, ':*' prefix match on
249
+ // content-morpheme stems) when kiwi is ready; else fall back to the plain
250
+ // websearch_to_tsquery path. ftsPrefixMode drives which tsquery ctor the
251
+ // sparse CTE uses (to_tsquery vs websearch_to_tsquery).
252
+ const ftsPrefix = clean.length >= 3 ? buildFtsPrefixQuery(clean) : null
253
+ const ftsQuery = ftsPrefix ? ftsPrefix.query : (clean.length >= 3 ? (buildFtsQuery(clean) ?? null) : null)
254
+ const ftsPrefixMode = Boolean(ftsPrefix)
234
255
  const exactTerms = buildExactTerms(clean)
235
256
  const queryTokenCount = countQueryTokens(clean)
236
257
  const minExactHits = exactTerms.length >= 8 ? 3 : exactTerms.length >= 4 ? 2 : 1
@@ -329,23 +350,29 @@ dense AS (SELECT NULL::bigint AS id, NULL::float8 AS sim, NULL::bigint AS dense_
329
350
 
330
351
  // sparse CTE: active only when ftsQuery is non-null.
331
352
  // useHotActive → queries mv_hot_active GIN index (mv_hot_active_tsv).
353
+ // tsqExpr: to_tsquery for the Kiwi morph prefix-form query ('stem:* & ...'),
354
+ // else websearch_to_tsquery for the plain fallback token string. Both parse
355
+ // $2 under the 'simple' config to match search_tsv's simple-config lexemes.
356
+ const tsqExpr = ftsPrefixMode
357
+ ? `to_tsquery('simple', $2)`
358
+ : `websearch_to_tsquery('simple', $2)`
332
359
  const sparseCte = ftsQuery ? (useHotActive ? `
333
360
  sparse AS (
334
361
  SELECT id,
335
- ts_rank_cd(search_tsv, websearch_to_tsquery('simple', $2)) AS lex,
336
- ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_tsv, websearch_to_tsquery('simple', $2)) DESC) AS sparse_rank
362
+ ts_rank_cd(search_tsv, ${tsqExpr}) AS lex,
363
+ ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_tsv, ${tsqExpr}) DESC) AS sparse_rank
337
364
  FROM mv_hot_active
338
- WHERE search_tsv @@ websearch_to_tsquery('simple', $2)
365
+ WHERE search_tsv @@ ${tsqExpr}
339
366
  ${mvFilterClause}
340
367
  ORDER BY lex DESC
341
368
  LIMIT $4
342
369
  ),` : `
343
370
  sparse AS (
344
371
  SELECT id,
345
- ts_rank_cd(search_tsv, websearch_to_tsquery('simple', $2)) AS lex,
346
- ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_tsv, websearch_to_tsquery('simple', $2)) DESC) AS sparse_rank
372
+ ts_rank_cd(search_tsv, ${tsqExpr}) AS lex,
373
+ ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_tsv, ${tsqExpr}) DESC) AS sparse_rank
347
374
  FROM entries
348
- WHERE search_tsv @@ websearch_to_tsquery('simple', $2)
375
+ WHERE search_tsv @@ ${tsqExpr}
349
376
  ${filterClause}
350
377
  ORDER BY lex DESC
351
378
  LIMIT $4
@@ -489,7 +516,11 @@ LEFT JOIN exact x ON x.id = c.id`
489
516
  + (exactRank ? 1 / (K + exactRank) : 0)
490
517
  const freshness = applyFreshness ? freshnessFactor(row.ts, nowMs) : 1.0
491
518
  const boost = exactTextBoost(clean, row, row.exact_hits)
492
- return { id, row, rrf, freshness, retrievalScore: (rrf * freshness) + boost }
519
+ const sim = Number(row.dense_sim)
520
+ const denseTerm = Number.isFinite(sim)
521
+ ? W_DENSE * Math.max(0, sim - SIM_FLOOR) / (1 - SIM_FLOOR) * freshness
522
+ : 0
523
+ return { id, row, rrf, freshness, retrievalScore: (rrf * freshness) + boost + denseTerm }
493
524
  })
494
525
  let semanticOnlyDropped = 0
495
526
  let weakTextDropped = 0
@@ -515,7 +546,13 @@ LEFT JOIN exact x ON x.id = c.id`
515
546
  if (hasFullPhrase) return true
516
547
  if (queryTokenCount <= SHORT_QUERY_TOKEN_MAX) return true
517
548
  if (row.sparse_rank != null) return true
518
- if (hasSemanticSupport && hasQueryTokenCoverage(row, queryTokenCount)) return true
549
+ // Semantic support as a second signal only applies when the dense leg
550
+ // actually ran. With a cold embedding model (no queryVector) semantic
551
+ // support is unattainable, and requiring it silently zeroed every
552
+ // multi-token lexical recall until warmup finished. Token coverage alone
553
+ // carries the filter in that degraded mode.
554
+ const semanticLegActive = vecSql != null
555
+ if ((!semanticLegActive || hasSemanticSupport) && hasQueryTokenCoverage(row, queryTokenCount)) return true
519
556
  weakTextDropped += 1
520
557
  return false
521
558
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  cleanMemoryText,
3
3
  } from './memory-extraction.mjs'
4
+ import { isReady as koMorphReady, stems as koMorphStems } from './ko-morph.mjs'
4
5
 
5
6
  const MEMORY_TOKEN_STOPWORDS = new Set([
6
7
  'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'did', 'do', 'does', 'for', 'from',
@@ -55,4 +56,49 @@ export function buildFtsQuery(text) {
55
56
  return ftsTokens.map(t => t.replace(/["']/g, '')).filter(t => t.length > 0).join(' ')
56
57
  }
57
58
 
59
+ const HANGUL_RE = /[\uAC00-\uD7AF]/
60
+
61
+ // Sanitize a single lexeme for embedding inside a to_tsquery string. Strips the
62
+ // tsquery operator characters so a raw token can never inject syntax.
63
+ function sanitizeLexeme(t) {
64
+ return String(t ?? '').replace(/[&|!():*'"\\\s]+/g, '')
65
+ }
66
+
67
+ // Prefix-form (to_tsquery) builder. Returns null when kiwi morph is not ready,
68
+ // signalling the caller to keep the websearch_to_tsquery fallback path. When
69
+ // ready: Korean tokens → Kiwi content-morpheme stems (NNG/NNP/VV/VA/XR/SL),
70
+ // non-Korean tokens kept as-is; every lexeme gets a ':*' prefix match and the
71
+ // set is '&'-joined to preserve the current AND semantics.
72
+ //
73
+ // Returns { query, prefix:true } on success, or null.
74
+ export function buildFtsPrefixQuery(text) {
75
+ if (!koMorphReady()) return null
76
+ const tokens = tokenizeMemoryText(text)
77
+ if (tokens.length === 0) return null
78
+ const ftsTokens = [...new Set(tokens)].filter(t => t.length >= 3 || (t.length === 2 && HANGUL_RE.test(t)))
79
+ if (ftsTokens.length === 0) return null
80
+
81
+ const lexemes = []
82
+ const seen = new Set()
83
+ for (const tok of ftsTokens) {
84
+ if (HANGUL_RE.test(tok)) {
85
+ // Analyze against the ORIGINAL token (pre suffix-strip) so Kiwi sees the
86
+ // real inflection; normalizeMemoryToken already ran, so also feed the
87
+ // stripped form — union of both keeps recall high.
88
+ const st = koMorphStems(tok)
89
+ const forms = (st && st.length > 0) ? st : [tok]
90
+ for (const f of forms) {
91
+ const lex = sanitizeLexeme(f)
92
+ if (lex.length >= 1 && !seen.has(lex)) { seen.add(lex); lexemes.push(lex) }
93
+ }
94
+ } else {
95
+ const lex = sanitizeLexeme(tok)
96
+ if (lex.length >= 1 && !seen.has(lex)) { seen.add(lex); lexemes.push(lex) }
97
+ }
98
+ }
99
+ if (lexemes.length === 0) return null
100
+ const query = lexemes.map(l => `${l}:*`).join(' & ')
101
+ return { query, prefix: true }
102
+ }
103
+
58
104
 
@@ -479,17 +479,39 @@ export function createQueryHandlers({
479
479
  let queryVector = null
480
480
  if (isEmbeddingModelReady()) {
481
481
  queryVector = await embedText(query)
482
+ } else if (embeddingWarmupCanStart()) {
483
+ // Cold model: WAIT for warmup (bounded) instead of silently degrading
484
+ // to lexical-only. Raw rows are already embedded at ingest time, so
485
+ // the query vector is the only missing piece of the dense leg — a few
486
+ // seconds of warmup buys full hybrid quality on the first recall.
487
+ // On timeout the warmup keeps running in the background and this
488
+ // recall falls back to lexical (same as before).
489
+ const COLD_RECALL_WARMUP_WAIT_MS = Math.max(0, Number(process.env.MIXDOG_RECALL_WARMUP_WAIT_MS) || 8_000)
490
+ let timer
491
+ try {
492
+ queryVector = await Promise.race([
493
+ warmupEmbeddingProvider().then(() => embedText(query)),
494
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), COLD_RECALL_WARMUP_WAIT_MS) }),
495
+ ])
496
+ } catch (err) {
497
+ log(`[memory-service] embedding warmup during cold recall failed: ${err?.message || err}\n`)
498
+ queryVector = null
499
+ } finally {
500
+ clearTimeout(timer)
501
+ }
502
+ if (!Array.isArray(queryVector)) {
503
+ const now = Date.now()
504
+ if (now - _embeddingColdRecallLogAt > 10_000) {
505
+ _embeddingColdRecallLogAt = now
506
+ log(`[recall] embedding still cold after ${COLD_RECALL_WARMUP_WAIT_MS}ms wait; returning lexical results while background warmup continues\n`)
507
+ }
508
+ }
482
509
  } else {
483
510
  const now = Date.now()
484
511
  if (now - _embeddingColdRecallLogAt > 10_000) {
485
512
  _embeddingColdRecallLogAt = now
486
513
  log('[recall] embedding model cold; returning lexical results while background warmup continues\n')
487
514
  }
488
- if (embeddingWarmupCanStart()) {
489
- void warmupEmbeddingProvider().catch((err) => {
490
- log(`[memory-service] embedding warmup after cold recall failed: ${err?.message || err}\n`)
491
- })
492
- }
493
515
  }
494
516
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
495
517
  const _t1 = Date.now()
@@ -8,7 +8,7 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Core-memory mutation and status. Use recall for retrieval. When the user explicitly asks to remember something (e.g. "remember this", a stated preference, or a decision), persist it immediately with action:"core" op:"add" do not wait for a cycle.',
11
+ description: 'Core-memory mutation and status. Use recall for retrieval. Persist with action:"core" op:"add" when: user corrects/confirms your approach, states a durable preference, or says "remember". Only what code/git/docs cannot derive; include the why. Never transient task state. No prior recall dedup needed — the cycle dedups.',
12
12
  inputSchema: {
13
13
  type: 'object',
14
14
  properties: {
@@ -30,7 +30,7 @@ export const TOOL_DEFS = [
30
30
  name: 'recall',
31
31
  title: 'Recall',
32
32
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
33
- description: 'Retrieve stored memory/session history; when in doubt, recall first. Query is topic/semantic search (hybrid embedding+keyword), not regex. Common patterns — previous conversation: period:"last" (all history newest-first, grouped per session); current session: sessionId with no query; recent window: period:"3h"/"30m"/"24h"; specific timeframe: period:"2026-07-03" / "2026-07-01~2026-07-03" / "12:00~14:00" (today) / "2026-07-03 12:00~14:00"; topic: query alone or query+period; exact follow-up: id from a prior result.',
33
+ description: 'Retrieve stored memory/session history. Use only to check prior conversation or past events: resumes, prior work, earlier decisions or messages. Do not call as a reflexive pre-step, to verify a just-made decision, or before storing memory. Query is topic/semantic search, not regex. Patterns: period:"last" for previous conversation; sessionId without query for current session; period:"3h"/"30m"/"24h" for recent; YYYY-MM-DD, date ranges, or HH:MM~HH:MM for time windows; id for exact follow-up.',
34
34
  inputSchema: {
35
35
  type: 'object',
36
36
  properties: {
@@ -263,10 +263,20 @@ export function diagnoseDiscordTokenValue(value, config = {}) {
263
263
  const discord = config?.discord && typeof config.discord === 'object' ? config.discord : {}
264
264
  const appId = String(discord.applicationId || '').trim()
265
265
  if (appId && token === appId) return 'Bot token field contains the Application ID, not the bot token.'
266
- const channels = config?.channelsConfig && typeof config.channelsConfig === 'object' ? config.channelsConfig : {}
267
- for (const ch of Object.values(channels)) {
268
- const channelId = String(ch?.channelId || '').trim()
269
- if (channelId && token === channelId) return 'Bot token field contains a Channel ID, not the bot token.'
266
+ // Single main channel: check the `channel` object. Legacy `channelsConfig`
267
+ // is still read here (read-only) so the diagnostic keeps working on configs
268
+ // that predate the single-channel collapse.
269
+ const candidateEntries = []
270
+ if (config?.channel && typeof config.channel === 'object') candidateEntries.push(config.channel)
271
+ if (config?.channelsConfig && typeof config.channelsConfig === 'object') {
272
+ candidateEntries.push(...Object.values(config.channelsConfig))
273
+ }
274
+ for (const ch of candidateEntries) {
275
+ if (!ch || typeof ch !== 'object') continue
276
+ for (const id of [ch.channelId, ch.discordChannelId, ch.telegramChatId]) {
277
+ const channelId = String(id || '').trim()
278
+ if (channelId && token === channelId) return 'Bot token field contains a Channel ID, not the bot token.'
279
+ }
270
280
  }
271
281
  if (isDiscordSnowflake(token)) return 'Bot token field contains a numeric Discord ID, not the bot token.'
272
282
  return null
@@ -44,6 +44,25 @@ export function readMarkdownDocument(markdown) {
44
44
  };
45
45
  }
46
46
 
47
+ // Serialize a single-file frontmatter markdown document:
48
+ // ---
49
+ // key: value
50
+ // ---
51
+ //
52
+ // <body>
53
+ // Values are stringified as-is (enabled:false -> "false"); the reader casts
54
+ // types back. Mirrors the WORKFLOW.md / SKILL.md single-file convention,
55
+ // where `name` and `description` lead the frontmatter.
56
+ export function serializeFrontmatterDoc(meta = {}, body = '') {
57
+ const lines = ['---'];
58
+ for (const [key, value] of Object.entries(meta)) {
59
+ if (value === undefined || value === null) continue;
60
+ lines.push(`${key}: ${value}`);
61
+ }
62
+ lines.push('---', '');
63
+ return `${lines.join('\n')}\n${String(body || '').trim()}\n`;
64
+ }
65
+
47
66
  export function normalizeAgentPermission(value) {
48
67
  const key = clean(value).toLowerCase();
49
68
  return AGENT_PERMISSION_ALIASES.get(key) || null;
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * `<Mixdog data dir>/schedules/<name>/` is the single source of truth.
5
5
  * Each schedule directory contains a single `SCHEDULE.md` — YAML-ish
6
- * frontmatter (metadata: time/timezone/days/channel/model/enabled) plus a
7
- * markdown body (the prompt). Both the setup UI (POST /schedules) and the
6
+ * frontmatter (name/description lead per the SKILL.md convention, then
7
+ * metadata: time/timezone/days/channel/model/enabled) plus a markdown
8
+ * body (the prompt). Both the setup UI (POST /schedules) and the
8
9
  * `schedule-add` skill write the same one file; every reader —
9
10
  * setup-server (GET /schedules), channels/lib/config.mjs (loadConfig),
10
11
  * status/aggregator.mjs — must go through listSchedules() so a single
@@ -35,9 +36,12 @@ function readTextFile(path) {
35
36
  *
36
37
  * Return shape per entry: `{ name, ...frontmatter, prompt }`.
37
38
  * - `name` is the directory name (slug).
39
+ * - `description` from frontmatter (display-only; defaults to '').
38
40
  * - Spread of `SCHEDULE.md` frontmatter keys (time/timezone/days/channel/
39
41
  * model/enabled). All frontmatter values arrive as strings; `enabled` is
40
42
  * cast to a boolean here so downstream `s.enabled !== false` filters work.
43
+ * Frontmatter `name` is display-only and never overrides the dir slug used
44
+ * for identity/routing.
41
45
  * - `prompt` carries the `SCHEDULE.md` body (empty string when the file is
42
46
  * missing — caller decides whether that is a hard error).
43
47
  *
@@ -66,7 +70,13 @@ export function listSchedules() {
66
70
  if (Object.prototype.hasOwnProperty.call(cfg, 'enabled')) {
67
71
  cfg.enabled = cfg.enabled !== 'false' && cfg.enabled !== false;
68
72
  }
69
- out.push({ name, ...cfg, prompt: body });
73
+ // Dir name is canonical identity; frontmatter `name` is display-only and
74
+ // must not override the slug. `description` defaults to '' (backward compat
75
+ // for old files with no such key).
76
+ const description = String(cfg.description || '');
77
+ delete cfg.name;
78
+ delete cfg.description;
79
+ out.push({ name, description, ...cfg, prompt: body });
70
80
  }
71
81
  return out;
72
82
  }
@@ -2,10 +2,10 @@ export const TOOL_SYNC_EXECUTION_CONTRACT =
2
2
  'Runs synchronously in this tool call.';
3
3
 
4
4
  export const TOOL_ASYNC_EXECUTION_CONTRACT =
5
- 'Async returns task_id; completion notification follows. status/read/wait are manual recovery or blocking only.';
5
+ 'Async returns task_id; completion notification follows. status/read/wait are recovery/blocking only.';
6
6
 
7
7
  export const TOOL_MANUAL_CONTROL_CONTRACT =
8
- 'Manual wait/read/status/cancel only for explicit blocking or recovery.';
8
+ 'wait/read/status/cancel are for explicit blocking or recovery only.';
9
9
 
10
10
  function clean(value) {
11
11
  return String(value ?? '').trim();
@@ -114,15 +114,18 @@ const AUTO_CLEAR_DEFAULT_IDLE_MS = 60 * 60 * 1000;
114
114
 
115
115
  // Provider-aware auto-clear idle-sweep defaults. Rationale mirrors the
116
116
  // provider cache TTLs documented in agent-runtime/cache-strategy.mjs
117
- // (explicit breakpoint / managed-cache windows): Anthropic, Gemini, xAI and
118
- // Mistral caches run ~1h, Groq's implicit cache persists ~2h, OpenAI/
119
- // DeepSeek key-prefix/implicit caches persist far longer (~24h), and OpenAI
120
- // OAuth's in-memory server cache is short-lived (~5-10min), so we round up
121
- // slightly to 10m to avoid sweeping a still-warm cache. Unknown/
122
- // unrecognized providers fall back to the 'default' 1h entry.
117
+ // (explicit breakpoint / managed-cache windows): Anthropic follows the BP4
118
+ // messages-tail TTL (5m default cache_control window; BP1~3 stay on the 1h
119
+ // extended TTL and are prefix-shared, so reaping at 5m only forfeits an
120
+ // already-cold tail see resolveLeadMessagesTtl). Gemini, xAI and Mistral
121
+ // caches run ~1h, Groq's implicit cache persists ~2h, OpenAI/DeepSeek
122
+ // key-prefix/implicit caches persist far longer (~24h), and OpenAI OAuth's
123
+ // in-memory server cache is short-lived (~5-10min), so we round up slightly
124
+ // to 10m to avoid sweeping a still-warm cache. Unknown/unrecognized
125
+ // providers fall back to the 'default' 1h entry.
123
126
  export const AUTO_CLEAR_PROVIDER_IDLE_MS = Object.freeze({
124
- 'anthropic': 60 * 60 * 1000,
125
- 'anthropic-oauth': 60 * 60 * 1000,
127
+ 'anthropic': 5 * 60 * 1000,
128
+ 'anthropic-oauth': 5 * 60 * 1000,
126
129
  'gemini': 60 * 60 * 1000,
127
130
  'groq': 2 * 60 * 60 * 1000,
128
131
  'openai': 24 * 60 * 60 * 1000,
@@ -132,15 +135,46 @@ export const AUTO_CLEAR_PROVIDER_IDLE_MS = Object.freeze({
132
135
  'mistral': 60 * 60 * 1000,
133
136
  'default': AUTO_CLEAR_DEFAULT_IDLE_MS,
134
137
  });
138
+ export const AUTO_CLEAR_PROVIDER_IDS = Object.freeze(Object.keys(AUTO_CLEAR_PROVIDER_IDLE_MS));
135
139
  export const AUTO_CLEAR_UNKNOWN_PROVIDER_IDLE_MS = AUTO_CLEAR_PROVIDER_IDLE_MS.default;
136
140
 
141
+ export function normalizeAutoClearProviderIdleMs(value = {}) {
142
+ const raw = value && typeof value === 'object' ? value : {};
143
+ const out = {};
144
+ for (const [key, ms] of Object.entries(raw)) {
145
+ const provider = clean(key).toLowerCase();
146
+ const idleMs = Number(ms);
147
+ if (!provider || !Number.isFinite(idleMs) || idleMs <= 0) continue;
148
+ out[provider] = Math.max(60_000, Math.round(idleMs));
149
+ }
150
+ return out;
151
+ }
152
+
137
153
  // Resolve the auto-clear idle-sweep threshold for a given provider id.
138
154
  // Unknown/missing providers fall back to the 'default' 1h entry (above).
139
- export function autoClearIdleMsForProvider(provider) {
155
+ export function autoClearIdleMsForProvider(provider, providerIdleMs = null) {
156
+ const overrides = normalizeAutoClearProviderIdleMs(providerIdleMs);
140
157
  const key = clean(provider).toLowerCase();
158
+ if (overrides[key]) return overrides[key];
159
+ if (key && AUTO_CLEAR_PROVIDER_IDLE_MS[key]) return AUTO_CLEAR_PROVIDER_IDLE_MS[key];
160
+ if (overrides.default) return overrides.default;
141
161
  return AUTO_CLEAR_PROVIDER_IDLE_MS[key] ?? AUTO_CLEAR_PROVIDER_IDLE_MS.default;
142
162
  }
143
163
 
164
+ export function autoClearProviderDefaults(providerIdleMs = null) {
165
+ const overrides = normalizeAutoClearProviderIdleMs(providerIdleMs);
166
+ return AUTO_CLEAR_PROVIDER_IDS.map((provider) => {
167
+ const builtInMs = AUTO_CLEAR_PROVIDER_IDLE_MS[provider] ?? AUTO_CLEAR_PROVIDER_IDLE_MS.default;
168
+ const idleMs = overrides[provider] ?? builtInMs;
169
+ return {
170
+ provider,
171
+ idleMs,
172
+ builtInMs,
173
+ custom: overrides[provider] != null,
174
+ };
175
+ });
176
+ }
177
+
144
178
  // Resolve the effective auto-clear idle window for a config + provider:
145
179
  // an explicit user-set idleMs (config.autoClear.idleMs) always wins; else
146
180
  // fall back to the provider's default; else the global 1h default.
@@ -148,7 +182,7 @@ export function resolveAutoClearIdleMs(config, provider) {
148
182
  const raw = config?.autoClear && typeof config.autoClear === 'object' ? config.autoClear : {};
149
183
  const idleMs = Number(raw.idleMs ?? raw.thresholdMs ?? raw.idleMillis);
150
184
  if (Number.isFinite(idleMs) && idleMs > 0) return Math.max(60_000, Math.round(idleMs));
151
- return autoClearIdleMsForProvider(provider);
185
+ return autoClearIdleMsForProvider(provider, raw.providerIdleMs ?? raw.providerDefaults ?? raw.providers);
152
186
  }
153
187
 
154
188
  export function normalizeSystemShellConfig(value = {}) {
@@ -186,6 +220,7 @@ export function normalizeAutoClearConfig(value = {}) {
186
220
  enabled: raw.enabled !== false,
187
221
  idleMs: hasExplicitIdle ? Math.max(60_000, Math.round(idleMs)) : null,
188
222
  custom: hasExplicitIdle,
223
+ providerIdleMs: normalizeAutoClearProviderIdleMs(raw.providerIdleMs ?? raw.providerDefaults ?? raw.providers),
189
224
  };
190
225
  }
191
226
 
@@ -4,6 +4,32 @@
4
4
  import { clean } from './session-text.mjs';
5
5
  import { QUICK_SEARCH_MODELS } from './quick-search-models.mjs';
6
6
  import { SEARCH_DEFAULT_PROVIDER, SEARCH_DEFAULT_MODEL } from './workflow.mjs';
7
+ import { getModelMetadataSync } from '../runtime/agent/orchestrator/providers/model-catalog.mjs';
8
+
9
+ function metadataFor(provider, modelId) {
10
+ try {
11
+ return getModelMetadataSync(modelId, provider) || {};
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+
17
+ function hydratedModel(provider, model = {}) {
18
+ const modelId = clean(model?.id || model);
19
+ const meta = metadataFor(provider, modelId);
20
+ const base = model && typeof model === 'object' ? model : { id: modelId };
21
+ return {
22
+ ...base,
23
+ contextWindow: meta.contextWindow || base?.contextWindow || null,
24
+ outputTokens: meta.outputTokens || base?.outputTokens || null,
25
+ supportsWebSearch: base?.supportsWebSearch === true || meta.supportsWebSearch === true,
26
+ supportsFunctionCalling: base?.supportsFunctionCalling === true || meta.supportsFunctionCalling === true,
27
+ supportsPromptCaching: base?.supportsPromptCaching === true || meta.supportsPromptCaching === true,
28
+ supportsReasoning: base?.supportsReasoning === true || meta.supportsReasoning === true,
29
+ reasoningOptions: Array.isArray(base?.reasoningOptions) && base.reasoningOptions.length ? base.reasoningOptions : (meta.reasoningOptions || []),
30
+ reasoningContentField: base?.reasoningContentField || meta.reasoningContentField || null,
31
+ };
32
+ }
7
33
 
8
34
  export function createQuickModelRows({
9
35
  getRoute,
@@ -32,12 +58,19 @@ export function createQuickModelRows({
32
58
  const key = `${provider}:${model}`;
33
59
  if (seen.has(key)) return;
34
60
  seen.add(key);
61
+ const meta = metadataFor(provider, model);
35
62
  const row = providerModelCacheRow(provider, {
36
63
  id: model,
37
64
  name: routeLike.modelDisplay || routeLike.display || model,
38
65
  display: routeLike.modelDisplay || routeLike.display || model,
66
+ contextWindow: meta.contextWindow || null,
67
+ outputTokens: meta.outputTokens || null,
39
68
  latest: routeLike.latest === true,
40
- supportsReasoning: !!routeLike.effort,
69
+ supportsReasoning: !!routeLike.effort || meta.supportsReasoning === true,
70
+ supportsFunctionCalling: meta.supportsFunctionCalling === true,
71
+ supportsPromptCaching: meta.supportsPromptCaching === true,
72
+ reasoningOptions: meta.reasoningOptions || [],
73
+ reasoningContentField: meta.reasoningContentField || null,
41
74
  mode: 'chat',
42
75
  });
43
76
  rows.push(row);
@@ -57,19 +90,21 @@ export function createQuickModelRows({
57
90
  if (!providerName || !modelId || !isSearchCapableProvider(providerName)) return;
58
91
  const key = `${providerName}:${modelId}`;
59
92
  if (seen.has(key)) return;
93
+ const resolved = hydratedModel(providerName, model);
60
94
  const row = providerModelCacheRow(providerName, {
61
95
  id: modelId,
62
- name: model?.name || model?.display || modelId,
63
- display: model?.display || model?.name || modelId,
64
- contextWindow: model?.contextWindow || null,
65
- outputTokens: model?.outputTokens || null,
66
- latest: model?.latest === true,
96
+ name: resolved?.name || resolved?.display || modelId,
97
+ display: resolved?.display || resolved?.name || modelId,
98
+ contextWindow: resolved?.contextWindow || null,
99
+ outputTokens: resolved?.outputTokens || null,
100
+ latest: resolved?.latest === true,
67
101
  supportsWebSearch: true,
68
- supportsFunctionCalling: model?.supportsFunctionCalling === true,
69
- supportsPromptCaching: model?.supportsPromptCaching === true,
70
- supportsReasoning: model?.supportsReasoning === true,
71
- reasoningLevels: Array.isArray(model?.reasoningLevels) ? model.reasoningLevels : undefined,
72
- reasoningOptions: Array.isArray(model?.reasoningOptions) ? model.reasoningOptions : [],
102
+ supportsFunctionCalling: resolved?.supportsFunctionCalling === true,
103
+ supportsPromptCaching: resolved?.supportsPromptCaching === true,
104
+ supportsReasoning: resolved?.supportsReasoning === true,
105
+ reasoningLevels: Array.isArray(resolved?.reasoningLevels) ? resolved.reasoningLevels : undefined,
106
+ reasoningOptions: Array.isArray(resolved?.reasoningOptions) ? resolved.reasoningOptions : [],
107
+ reasoningContentField: resolved?.reasoningContentField || null,
73
108
  mode: 'chat',
74
109
  });
75
110
  if (row.supportsWebSearch !== true) return;
@@ -1,46 +1,47 @@
1
- // Static quick-pick model catalog surfaced during onboarding / search setup.
2
- // Pure data extracted from mixdog-session-runtime.mjs.
1
+ // Static quick-pick model ids surfaced during onboarding / search setup.
2
+ // Limits are intentionally not hardcoded here: rows are hydrated from live
3
+ // provider caches and provider-scoped metadata during catalog construction.
3
4
 
4
5
  export const ONBOARDING_VERSION = 1;
5
6
 
6
7
  export const QUICK_SEARCH_MODELS = Object.freeze({
7
8
  'openai-oauth': [
8
- { id: 'gpt-5.5', display: 'GPT-5.5', latest: true, contextWindow: 1000000 },
9
- { id: 'gpt-5.4', display: 'GPT-5.4', latest: true, contextWindow: 1000000 },
10
- { id: 'gpt-5', display: 'GPT-5', contextWindow: 400000 },
11
- { id: 'gpt-4.1', display: 'GPT-4.1', contextWindow: 1000000 },
9
+ { id: 'gpt-5.5', display: 'GPT-5.5', latest: true },
10
+ { id: 'gpt-5.4', display: 'GPT-5.4', latest: true },
11
+ { id: 'gpt-5', display: 'GPT-5' },
12
+ { id: 'gpt-4.1', display: 'GPT-4.1' },
12
13
  ],
13
14
  openai: [
14
- { id: 'gpt-5.5', display: 'GPT-5.5', latest: true, contextWindow: 1000000 },
15
- { id: 'gpt-5.4', display: 'GPT-5.4', latest: true, contextWindow: 1000000 },
16
- { id: 'gpt-5', display: 'GPT-5', contextWindow: 400000 },
17
- { id: 'gpt-4.1', display: 'GPT-4.1', contextWindow: 1000000 },
18
- { id: 'gpt-4o', display: 'GPT-4o', contextWindow: 128000 },
15
+ { id: 'gpt-5.5', display: 'GPT-5.5', latest: true },
16
+ { id: 'gpt-5.4', display: 'GPT-5.4', latest: true },
17
+ { id: 'gpt-5', display: 'GPT-5' },
18
+ { id: 'gpt-4.1', display: 'GPT-4.1' },
19
+ { id: 'gpt-4o', display: 'GPT-4o' },
19
20
  ],
20
21
  'grok-oauth': [
21
- { id: 'grok-4.3', display: 'Grok 4.3', latest: true, contextWindow: 1000000 },
22
- { id: 'grok-4.20', display: 'Grok 4.20', contextWindow: 1000000 },
23
- { id: 'grok-4', display: 'Grok 4', contextWindow: 256000 },
22
+ { id: 'grok-4.3', display: 'Grok 4.3', latest: true },
23
+ { id: 'grok-4.20', display: 'Grok 4.20' },
24
+ { id: 'grok-4', display: 'Grok 4' },
24
25
  ],
25
26
  xai: [
26
- { id: 'grok-4.3', display: 'Grok 4.3', latest: true, contextWindow: 1000000 },
27
- { id: 'grok-4.20', display: 'Grok 4.20', contextWindow: 1000000 },
28
- { id: 'grok-4', display: 'Grok 4', contextWindow: 256000 },
27
+ { id: 'grok-4.3', display: 'Grok 4.3', latest: true },
28
+ { id: 'grok-4.20', display: 'Grok 4.20' },
29
+ { id: 'grok-4', display: 'Grok 4' },
29
30
  ],
30
31
  gemini: [
31
- { id: 'gemini-3-pro', display: 'Gemini 3 Pro', latest: true, contextWindow: 1000000 },
32
- { id: 'gemini-2.5-pro', display: 'Gemini 2.5 Pro', contextWindow: 1000000 },
33
- { id: 'gemini-2.5-flash', display: 'Gemini 2.5 Flash', contextWindow: 1000000 },
34
- { id: 'gemini-2.0-flash', display: 'Gemini 2.0 Flash', contextWindow: 1000000 },
32
+ { id: 'gemini-3-pro', display: 'Gemini 3 Pro', latest: true },
33
+ { id: 'gemini-2.5-pro', display: 'Gemini 2.5 Pro' },
34
+ { id: 'gemini-2.5-flash', display: 'Gemini 2.5 Flash' },
35
+ { id: 'gemini-2.0-flash', display: 'Gemini 2.0 Flash' },
35
36
  ],
36
37
  'anthropic-oauth': [
37
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
38
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
39
- { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
38
+ { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true },
39
+ { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true },
40
+ { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5' },
40
41
  ],
41
42
  anthropic: [
42
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
43
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
44
- { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
43
+ { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true },
44
+ { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true },
45
+ { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5' },
45
46
  ],
46
47
  });