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.
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 +131 -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 +441 -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
@@ -3,7 +3,7 @@ import { appendFile as _appendFile } from "fs";
3
3
  import { join, isAbsolute } from "path";
4
4
  import { tmpdir } from "os";
5
5
  import { randomUUID } from "crypto";
6
- import { DATA_DIR, DEFAULT_HOLIDAY_COUNTRY, isInQuietWindow } from "./config.mjs";
6
+ import { DATA_DIR } from "./config.mjs";
7
7
  import { runScript as execScript, ensureNopluginDir } from "./executor.mjs";
8
8
  import { withFileLockSync } from "../../shared/atomic-file.mjs";
9
9
  import { makeAgentDispatch } from '../../agent/orchestrator/agent-runtime/agent-dispatch.mjs';
@@ -39,7 +39,6 @@ function logSchedule(msg) {
39
39
  if (!_schedLogTimer) _schedLogTimer = setTimeout(_flushScheduleLog, 2000);
40
40
  }
41
41
 
42
- import { isHoliday } from "./holidays.mjs";
43
42
  import { tryRead } from "./settings.mjs";
44
43
  // node-cron is an optional runtime dep. If the module isn't installed
45
44
  // (e.g. a fresh v0.6.190 where node_modules predates the package.json
@@ -104,7 +103,7 @@ function tzSnapshot(now, tz) {
104
103
  class Scheduler {
105
104
  nonInteractive;
106
105
  interactive;
107
- channelsConfig;
106
+ channelId;
108
107
  promptsDir;
109
108
  tickTimer = null;
110
109
  lastFired = /* @__PURE__ */ new Map();
@@ -122,53 +121,16 @@ class Scheduler {
122
121
  // names skipped for today
123
122
  skippedTodayDate = "";
124
123
  // "YYYY-MM-DD" local date the skippedToday set belongs to
125
- holidayCountry = null;
126
- // ISO country code for holiday check
127
- holidayChecked = "";
128
- // "YYYY-MM-DD" last checked date
129
- todayIsHoliday = false;
130
- // cached result for today
131
- quietSchedule = null;
132
- // global quiet hours "HH:MM-HH:MM"
133
124
  cronJobs = /* @__PURE__ */ new Map();
134
125
  // name -> node-cron ScheduledTask for cron-expression entries
135
126
  //
136
- // 0.1.62 wiring:
137
- // `topConfig` is the normalized top-level channels config from
138
- // loadConfig()/applyDefaults() it carries `quiet`, `schedules`
139
- // at the top level. `channelsConfig` is still accepted separately
140
- // (channel metadata only, not quiet config) because resolveChannel()
141
- // needs the channel-label → platform-id map.
142
- constructor(nonInteractive, interactive, channelsConfig, topConfig) {
127
+ // `channelId` is the single resolved main-channel id used when a schedule's
128
+ // `channel` flag is set (post-to-channel); absent flag → inject into session.
129
+ constructor(nonInteractive, interactive, channelId) {
143
130
  this.nonInteractive = nonInteractive.filter((s) => s.enabled !== false);
144
131
  this.interactive = interactive.filter((s) => s.enabled !== false);
145
- this.channelsConfig = channelsConfig ?? null;
132
+ this.channelId = channelId ?? "";
146
133
  this.promptsDir = join(DATA_DIR, "prompts");
147
- this._applyQuietConfig(topConfig);
148
- }
149
- /** Resolve quiet/schedules flags from the top-level config
150
- * (0.1.62 shape: `topConfig.quiet`, `topConfig.schedules`). Falls
151
- * through silently to defaults (empty schedule, holidays off,
152
- * respect flag default true) when topConfig is missing or malformed
153
- * — defensive, not legacy. */
154
- _applyQuietConfig(topConfig) {
155
- const cfg = (topConfig && typeof topConfig === "object") ? topConfig : null;
156
- const quietSrc = cfg?.quiet ?? null;
157
- const schedulesSrc = cfg?.schedules ?? null;
158
- // Holidays: prefer an explicit ISO country code. Legacy boolean `true`
159
- // from older setup UI builds is normalized to the local default country.
160
- const hol = quietSrc?.holidays;
161
- if (hol === true) {
162
- this.holidayCountry = DEFAULT_HOLIDAY_COUNTRY;
163
- } else if (typeof hol === "string" && hol) {
164
- this.holidayCountry = hol.trim().toUpperCase();
165
- } else {
166
- this.holidayCountry = null;
167
- }
168
- // Quiet window string "HH:MM-HH:MM" from topConfig.quiet.schedule.
169
- this.quietSchedule = quietSrc?.schedule ?? null;
170
- // Opt-in flag: default true when unspecified, matching applyDefaults.
171
- this.respectQuietSchedules = schedulesSrc?.respectQuiet !== false;
172
134
  }
173
135
  setInjectHandler(fn) {
174
136
  this.injectFn = fn;
@@ -372,8 +334,8 @@ ${Scheduler.INSTANCE_UUID}`;
372
334
  }
373
335
  }
374
336
  }
375
- /** Fire path for a cron-triggered entry. Applies day/quiet/holiday
376
- * guards against the schedule's TZ (or local when absent). */
337
+ /** Fire path for a cron-triggered entry. Applies day guards against
338
+ * the schedule's TZ (or local when absent). */
377
339
  async onCronFire(schedule, type) {
378
340
  const now = /* @__PURE__ */ new Date();
379
341
  const tz = schedule.timezone || null;
@@ -381,16 +343,6 @@ ${Scheduler.INSTANCE_UUID}`;
381
343
  const isWeekend = snap.dow === 0 || snap.dow === 6;
382
344
  const days = schedule.days ?? "daily";
383
345
  if (!this.matchesDays(days, snap.dow, isWeekend)) return;
384
- if (this.holidayCountry) {
385
- try {
386
- const holiday = await isHoliday(this.tzDate(now, tz), this.holidayCountry);
387
- if (holiday && (schedule.skipHolidays || days === "weekday")) {
388
- logSchedule(`skipping "${schedule.name}" \u2014 public holiday\n`);
389
- return;
390
- }
391
- } catch {}
392
- }
393
- if ((schedule.dnd || this.respectQuietSchedules) && this.isQuietHours(now, tz)) return;
394
346
  if (this.shouldSkip(schedule.name)) return;
395
347
  // Record lastFired only when the fire actually proceeds past
396
348
  // fireTimed's running/precondition guards (it resolves truthy on a
@@ -401,13 +353,6 @@ ${Scheduler.INSTANCE_UUID}`;
401
353
  (err) => process.stderr.write(`mixdog scheduler: ${schedule.name} failed: ${err}\n`)
402
354
  );
403
355
  }
404
- /** Produce a Date whose calendar day matches the TZ-adjusted dateStr,
405
- * so holiday lookups by country work against the right day. */
406
- tzDate(now, tz) {
407
- if (!tz) return now;
408
- const snap = tzSnapshot(now, tz);
409
- return new Date(`${snap.dateStr}T12:00:00Z`);
410
- }
411
356
  stop() {
412
357
  if (this.tickTimer) {
413
358
  clearInterval(this.tickTimer);
@@ -447,14 +392,11 @@ ${Scheduler.INSTANCE_UUID}`;
447
392
  } catch {}
448
393
  this.start();
449
394
  }
450
- reloadConfig(nonInteractive, interactive, channelsConfig, topConfig, options = {}) {
395
+ reloadConfig(nonInteractive, interactive, channelId, options = {}) {
451
396
  this.nonInteractive = nonInteractive.filter((s) => s.enabled !== false);
452
397
  this.interactive = interactive.filter((s) => s.enabled !== false);
453
- this.channelsConfig = channelsConfig ?? null;
398
+ this.channelId = channelId ?? "";
454
399
  this.promptsDir = join(DATA_DIR, "prompts");
455
- this._applyQuietConfig(topConfig);
456
- this.holidayChecked = "";
457
- this.todayIsHoliday = false;
458
400
  if (this.deferred.size > 0 || this.skippedToday.size > 0) {
459
401
  process.stderr.write(`mixdog scheduler: reload clearing ${this.deferred.size} deferred, ${this.skippedToday.size} skipped
460
402
  `);
@@ -520,24 +462,9 @@ ${Scheduler.INSTANCE_UUID}`;
520
462
  );
521
463
  }
522
464
  async tickAsync() {
523
- const now = /* @__PURE__ */ new Date();
524
- const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
525
- // All timed schedules are now handled exclusively by node-cron (registerCronJobs).
526
- // tick() only drives the holiday cache refresh.
527
- if (this.holidayCountry && this.holidayChecked !== dateStr) {
528
- this.holidayChecked = dateStr;
529
- try {
530
- this.todayIsHoliday = await isHoliday(now, this.holidayCountry);
531
- if (this.todayIsHoliday) {
532
- process.stderr.write(`mixdog scheduler: today (${dateStr}) is a holiday \u2014 weekday schedules will be skipped
533
- `);
534
- }
535
- } catch (err) {
536
- process.stderr.write(`mixdog scheduler: holiday check failed: ${err}
537
- `);
538
- this.todayIsHoliday = false;
539
- }
540
- }
465
+ // All timed schedules are handled exclusively by node-cron
466
+ // (registerCronJobs). tick() no longer does any per-day work; the
467
+ // interval is kept for future cadence-driven work and lifecycle parity.
541
468
  }
542
469
  /** Day abbreviation → JS day number (0=Sun...6=Sat) */
543
470
  static DAY_ABBRS = {
@@ -557,18 +484,6 @@ ${Scheduler.INSTANCE_UUID}`;
557
484
  const dayList = days.split(",").map((d) => d.trim().toLowerCase());
558
485
  return dayList.some((d) => Scheduler.DAY_ABBRS[d] === dow);
559
486
  }
560
- /** Check if current time is within global quiet hours (quiet.schedule).
561
- * tz optional — when set, HH:MM is evaluated in the given IANA zone.
562
- *
563
- * Delegates to the shared, now TZ-aware isInQuietWindow(cfg, now, tz)
564
- * helper in lib/config.mjs. Holidays are passed as `false` here on
565
- * purpose: public-holiday skips for the scheduler are handled by the
566
- * separate holidayCountry / skipHolidays / days==="weekday" path in
567
- * onCronFire, so this quiet-window check stays schedule-window only —
568
- * identical to the prior local implementation. */
569
- isQuietHours(now, tz) {
570
- return isInQuietWindow({ schedule: this.quietSchedule, holidays: false }, now, tz);
571
- }
572
487
  // ── Fire timed schedule ─────────────────────────────────────────────
573
488
  async fireTimed(schedule, type) {
574
489
  const execMode = schedule.exec ?? "prompt";
@@ -680,21 +595,20 @@ ${scriptResult}
680
595
  });
681
596
  }
682
597
  // ── Helpers ─────────────────────────────────────────────────────────
683
- /** Resolve a channel label to its platform ID via channelsConfig, fallback to raw value */
684
- resolveChannel(label) {
685
- const entry = this.channelsConfig?.[label];
686
- if (entry?.channelId) return entry.channelId;
687
- // Misconfigured label: no channelsConfig entry, so we fall back to
688
- // using the raw label as the channel id. Warn once per label so the
689
- // misconfiguration is diagnosable without spamming stderr.
690
- if (label != null) {
691
- this._channelFallbackWarned ??= new Set();
692
- if (!this._channelFallbackWarned.has(label)) {
693
- this._channelFallbackWarned.add(label);
694
- process.stderr.write(`mixdog scheduler: channel label "${label}" not found in channelsConfig using it as a raw channel id\n`);
695
- }
696
- }
697
- return label;
598
+ /**
599
+ * Map a schedule's `channel` flag to a target channel id. The flag is
600
+ * boolean-ish: any non-empty value except "false" (including legacy labels
601
+ * like "main") means "post to the main channel"; absent/false → "" (inject
602
+ * into the Lead session). No label resolution. A pure-digit / snowflake
603
+ * value is honored verbatim as an explicit id override (matches
604
+ * resolveWebhookChannelId in index.mjs).
605
+ */
606
+ resolveChannel(flag) {
607
+ if (flag == null) return "";
608
+ const v = String(flag).trim();
609
+ if (v === "" || v.toLowerCase() === "false") return "";
610
+ if (/^-?\d+$/.test(v)) return v;
611
+ return this.channelId ?? "";
698
612
  }
699
613
  /** Resolve prompt: try file first, fall back to inline text */
700
614
  resolvePrompt(schedule) {
@@ -268,8 +268,9 @@ function appendDelivery(name, entry) {
268
268
  }
269
269
  // Dedup gate against a still-active claim or a successful prior delivery.
270
270
  // Only rows with status "received" (non-terminal claim) or "done"
271
- // (successful delivery) block a retry; terminal "failed" / "quiet-skip"
272
- // rows are NOT considered duplicates so a sender can legitimately
271
+ // (successful delivery) block a retry; terminal "failed" rows (and any
272
+ // other unknown/legacy terminal status) are NOT considered duplicates so
273
+ // a sender can legitimately
273
274
  // redeliver the same id after a recoverable failure. Without this
274
275
  // scoping, every prior row would permanently dedup the id and stop
275
276
  // legit redelivery.
@@ -3,7 +3,6 @@ import { join } from "path";
3
3
  import { spawn, spawnSync } from "child_process";
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { randomUUID } from "crypto";
6
- import { isInQuietWindow } from "./config.mjs";
7
6
  import { getWebhookAuthtoken } from "../../shared/config.mjs";
8
7
  import { readMarkdownDocument } from "../../shared/markdown-frontmatter.mjs";
9
8
  import { logWebhook } from "./webhook/log.mjs";
@@ -42,19 +41,8 @@ class WebhookServer {
42
41
  listenInFlight = false;
43
42
  noSecretWarned = false;
44
43
  ngrokProcess = null;
45
- quiet = null;
46
- // ctor accepts the TOP-LEVEL normalized config slice as the second arg:
47
- // new WebhookServer(config.webhook, { quiet: config.quiet })
48
- constructor(config, topLevel) {
44
+ constructor(config) {
49
45
  this.config = config;
50
- this._applyTopLevel(topLevel);
51
- }
52
- _applyTopLevel(src) {
53
- this.quiet = null;
54
- if (!src || typeof src !== "object") return;
55
- if (src.quiet && typeof src.quiet === "object") {
56
- this.quiet = src.quiet;
57
- }
58
46
  }
59
47
  setEventPipeline(pipeline) {
60
48
  this.eventPipeline = pipeline;
@@ -241,7 +229,6 @@ class WebhookServer {
241
229
  const parsed = body ? JSON.parse(body) : {};
242
230
  const eventType = headers["x-github-event"] || null;
243
231
  const eventAction = parsed?.action || null;
244
- if (this._maybeQuietSkip(name, eventType, eventAction, deliveryId, res)) return;
245
232
  // Invariant: skip self-generated GitHub issue_comment events. All
246
233
  // mixdog-authored issue comments are prefixed with "[mixdog "
247
234
  // (e.g. "[mixdog reviewer] ..."), so a comment.body starting with
@@ -341,32 +328,6 @@ class WebhookServer {
341
328
  }
342
329
  return true;
343
330
  }
344
- // Quiet-hours skip: drop (do not queue) when webhook opt-in is on
345
- // and current time falls inside the shared quiet window. Returns
346
- // true when the request was answered with a 202 quiet-skip.
347
- _maybeQuietSkip(name, eventType, eventAction, deliveryId, res) {
348
- const webhookRespect = this.config?.respectQuiet === true;
349
- const quietCfg = this.quiet
350
- ? { schedule: this.quiet.schedule ?? null, holidays: this.quiet.holidays ?? false }
351
- : null;
352
- if (webhookRespect && quietCfg && isInQuietWindow(quietCfg, new Date())) {
353
- logWebhook(`${name}: quiet-skip event=${eventType || "<none>"} action=${eventAction || "<none>"} (id=${deliveryId})`);
354
- // Terminal delivery row: the `received` claim at the top of
355
- // handleRequest must be resolved by a terminal status. Without
356
- // it, deliveryExists() keeps the non-terminal row visible and
357
- // dedupes every future retry of the same id forever.
358
- appendDelivery(name, {
359
- id: deliveryId,
360
- status: "quiet-skip",
361
- event: eventType,
362
- action: eventAction,
363
- });
364
- res.writeHead(202, { "Content-Type": "application/json" });
365
- res.end(JSON.stringify({ status: "quiet-skip", event: eventType, action: eventAction, id: deliveryId }));
366
- return true;
367
- }
368
- return false;
369
- }
370
331
  _listenWithRetry() {
371
332
  if (!this.server || this.listenInFlight) return;
372
333
  this.listenInFlight = true;
@@ -608,10 +569,8 @@ class WebhookServer {
608
569
  logWebhook("stopped (ngrok left running for reuse)");
609
570
  return closed;
610
571
  }
611
- // reloadConfig(webhookCfg, topLevel, options?)
612
- // `topLevel` mirrors the constructor: a top-level normalized config
613
- // slice (typically `{ quiet }`).
614
- async reloadConfig(config, topLevel, options = {}) {
572
+ // reloadConfig(webhookCfg, options?)
573
+ async reloadConfig(config, options = {}) {
615
574
  // Await server.close() before re-listen: server.close() is async and
616
575
  // releases the bound port only after the close callback fires. Calling
617
576
  // start() before that drains races the port and surfaces EADDRINUSE
@@ -619,7 +578,6 @@ class WebhookServer {
619
578
  // holds the port.
620
579
  await this.stop();
621
580
  this.config = config;
622
- this._applyTopLevel(topLevel);
623
581
  if (options.autoStart !== false && config.enabled) this.start();
624
582
  }
625
583
  // ── Webhook handler ───────────────────────────────────────────────
@@ -633,14 +591,17 @@ class WebhookServer {
633
591
  // The signature gate (below) fails closed on any WEBHOOK.md
634
592
  // endpoint lacking a secret, so dropping `mode` does not weaken auth.
635
593
  // `instructions` carries the markdown body (the prompt) so callers read
636
- // one file instead of a config.json + instructions.md pair.
637
- const handler = { channel: null, role: "webhook-handler", model: null, instructions: "" };
594
+ // one file instead of a config.json + instructions.md pair. Frontmatter
595
+ // `name`/`description` (SKILL.md convention) are display-only: dir name
596
+ // stays the canonical identity/URL routing key, so `name` is ignored here.
597
+ const handler = { channel: null, role: "webhook-handler", model: null, description: "", instructions: "" };
638
598
  if (existsSync(mdPath)) {
639
599
  try {
640
600
  const { frontmatter, body } = readMarkdownDocument(readFileSync(mdPath, "utf8"));
641
601
  if (frontmatter.channel) handler.channel = frontmatter.channel;
642
602
  if (typeof frontmatter.role === "string" && frontmatter.role) handler.role = frontmatter.role;
643
603
  if (typeof frontmatter.model === "string" && frontmatter.model) handler.model = frontmatter.model;
604
+ handler.description = String(frontmatter.description || "");
644
605
  handler.instructions = String(body || "").trim();
645
606
  } catch {
646
607
  }
@@ -78,6 +78,7 @@ import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId }
78
78
  import { scheduledCycle1Signature, scheduledCycle2Signature, scheduledCycle3Signature } from './lib/cycle-signatures.mjs'
79
79
  import { createTranscriptIngest } from './lib/transcript-ingest.mjs'
80
80
  import { createEmbeddingWarmup } from './lib/embedding-warmup.mjs'
81
+ import { init as initKoMorph } from './lib/ko-morph.mjs'
81
82
  import { createCycleLlmAdapters } from './lib/cycle-llm-adapters.mjs'
82
83
  import { createCycleScheduler } from './lib/cycle-scheduler.mjs'
83
84
  import { createQueryHandlers } from './lib/query-handlers.mjs'
@@ -621,6 +622,14 @@ async function _initRuntime() {
621
622
  // Fire-and-forget on the embedding worker thread; never awaited so it does
622
623
  // not delay init() returning or the memory-ready signal.
623
624
  _embeddingWarmup.fireDeferred()
625
+ // Boot-edge Korean morph warmup. Same fire-and-forget style: lazy async init
626
+ // downloads+caches the Kiwi model once under DATA_DIR/kiwi-model/<version>,
627
+ // then builds the WASM analyzer. On any failure ko-morph stays null and
628
+ // buildFtsQuery keeps the websearch_to_tsquery fallback (never throws here).
629
+ // Skipped in secondary mode (query path lives on the primary).
630
+ if (!memorySecondaryMode()) {
631
+ initKoMorph(DATA_DIR, __mixdogMemoryLog).catch(() => {})
632
+ }
624
633
  }
625
634
 
626
635
  function _beginRuntimeInit() {
@@ -30,7 +30,8 @@ const VALID_CAT = new Set([
30
30
  const SIM_RECALL = 0.65
31
31
  const CORE_DEDUP_TOP_K = 5
32
32
 
33
- export const CORE_SUMMARY_MAX = 120
33
+ export const CORE_SUMMARY_MAX = 100
34
+ export const CORE_ELEMENT_MAX = 40
34
35
 
35
36
  function trimOrNull(v) {
36
37
  if (v == null) return null
@@ -204,6 +205,9 @@ export async function addCore(dataDir, { element, summary, category }, projectId
204
205
  const el = trimOrNull(element)
205
206
  const sm = trimOrNull(summary) ?? el
206
207
  if (!el || !sm) throw new Error('add requires element and summary')
208
+ if (el.length > CORE_ELEMENT_MAX) {
209
+ throw new Error(`core element too long (${el.length} chars, max ${CORE_ELEMENT_MAX}) — element is a short key/title, not content.`)
210
+ }
207
211
  if (sm.length > CORE_SUMMARY_MAX) {
208
212
  throw new Error(`core summary too long (${sm.length} chars, max ${CORE_SUMMARY_MAX}) — core memory must be 1 fact in 1-2 sentences; procedures, multi-step, or code belong in recap or docs. Compress and retry.`)
209
213
  }
@@ -0,0 +1,195 @@
1
+ // Korean morphological analysis for the recall FTS query path.
2
+ //
3
+ // Wraps kiwi-nlp (WASM). The npm package ships ONLY the WASM binary + JS glue,
4
+ // NOT the language model — so the model archive is downloaded ONCE at boot from
5
+ // the bab2min/Kiwi GitHub Release and cached under a gitignored data dir
6
+ // (precedent: the embedding ONNX model is runtime-downloaded/cached the same
7
+ // way). No per-query network. If download or WASM init fails, analyze() returns
8
+ // null and the caller (buildFtsQuery) falls back to its prior websearch path.
9
+ //
10
+ // Lifecycle:
11
+ // init(dataDir) — lazy, async, idempotent, safe to call fire-and-forget at
12
+ // boot. Downloads+extracts model on first run, then builds
13
+ // the Kiwi instance. Never throws (logs once, stays null).
14
+ // ready() — true once the Kiwi instance is built.
15
+ // analyze(text) — sync; returns TokenInfo[] or null when not ready.
16
+ // stems(text) — sync; content-morpheme stems (NNG/NNP/VV/VA/XR/SL) or null.
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import zlib from 'node:zlib'
20
+
21
+ // Pinned model release. The WASM package version (kiwi-nlp in package.json) and
22
+ // this model version are independent; base model is format-stable across the
23
+ // 0.2x line. Bump deliberately, never floating.
24
+ // Model version MUST match the kiwi-nlp WASM version — a mismatched model
25
+ // aborts the WASM at build() with an opaque numeric error. Pinned pair:
26
+ // kiwi-nlp@0.21.0 (package.json) + model v0.21.0 KnLM. Deliberately NOT
27
+ // 0.23.0: its base model ships CoNg (72MB, ~940MB RSS after build) vs
28
+ // KnLM's 35MB download / ~560MB build peak, and recall stemming gains
29
+ // nothing from the CoNg accuracy delta.
30
+ export const KIWI_MODEL_VERSION = 'v0.21.0'
31
+ const KIWI_MODEL_ASSET = `kiwi_model_${KIWI_MODEL_VERSION}_base.tgz`
32
+ const KIWI_MODEL_URL = `https://github.com/bab2min/Kiwi/releases/download/${KIWI_MODEL_VERSION}/${KIWI_MODEL_ASSET}`
33
+
34
+ // Model files KiwiBuilder.build({ modelFiles }) needs from the extracted
35
+ // archive (v0.21.0 base layout: KnLM = sj.knlm + sj.morph).
36
+ const REQUIRED_MODEL_FILES = [
37
+ 'combiningRule.txt', 'default.dict', 'extract.mdl', 'sj.knlm', 'sj.morph',
38
+ ]
39
+ // Optional files loaded when present (loadMultiDict / loadTypoDict defaults).
40
+ const OPTIONAL_MODEL_FILES = ['multi.dict', 'skipbigram.mdl', 'typo.dict']
41
+
42
+ // Content-morpheme POS tags whose stems are worth indexing against search_tsv.
43
+ // NNG/NNP nouns, VV/VA predicate stems, XR root, SL foreign(latin) — matches
44
+ // the brief. Endings/particles/josa are intentionally dropped.
45
+ const CONTENT_TAGS = new Set(['NNG', 'NNP', 'VV', 'VA', 'XR', 'SL'])
46
+
47
+ let _state = 'idle' // idle | loading | ready | failed
48
+ let _initPromise = null
49
+ let _kiwi = null
50
+ let _log = () => {}
51
+ let _initMs = 0
52
+
53
+ export function isReady() { return _state === 'ready' && _kiwi != null }
54
+ export function state() { return _state }
55
+ export function initLatencyMs() { return _initMs }
56
+
57
+ function modelDir(dataDir) {
58
+ return path.join(dataDir, 'kiwi-model', KIWI_MODEL_VERSION)
59
+ }
60
+
61
+ function hasAllRequired(dir) {
62
+ try {
63
+ return REQUIRED_MODEL_FILES.every(f => fs.existsSync(path.join(dir, f)))
64
+ } catch { return false }
65
+ }
66
+
67
+ // Minimal POSIX/ustar tar reader over an already-gunzipped buffer. Avoids a
68
+ // node-tar dependency for a one-shot boot extraction. Handles regular files
69
+ // (typeflag '0'/'\0') and the GNU/pax long-name records well enough for the
70
+ // flat Kiwi model archive (all entries live under a single top-level dir).
71
+ function extractTar(buf, destDir) {
72
+ let offset = 0
73
+ const written = []
74
+ while (offset + 512 <= buf.length) {
75
+ const header = buf.subarray(offset, offset + 512)
76
+ // Two consecutive zero blocks mark end of archive.
77
+ if (header.every(b => b === 0)) break
78
+ let name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
79
+ const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()
80
+ const size = parseInt(sizeStr, 8) || 0
81
+ const typeflag = String.fromCharCode(header[156]) || '0'
82
+ // ustar prefix (name continuation) at 345..500.
83
+ const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/, '')
84
+ if (prefix) name = `${prefix}/${name}`
85
+ offset += 512
86
+ const body = buf.subarray(offset, offset + size)
87
+ offset += Math.ceil(size / 512) * 512
88
+ if (typeflag !== '0' && typeflag !== '\0' && typeflag !== '') continue
89
+ // Flatten: take basename only; the archive nests files under one dir.
90
+ const base = name.split('/').filter(Boolean).pop()
91
+ if (!base) continue
92
+ fs.writeFileSync(path.join(destDir, base), body)
93
+ written.push(base)
94
+ }
95
+ return written
96
+ }
97
+
98
+ async function downloadAndExtractModel(dataDir) {
99
+ const dir = modelDir(dataDir)
100
+ if (hasAllRequired(dir)) return dir
101
+ fs.mkdirSync(dir, { recursive: true })
102
+ _log(`[memory-service] kiwi model missing — downloading ${KIWI_MODEL_ASSET} (~35MB) once\n`)
103
+ const res = await fetch(KIWI_MODEL_URL, { redirect: 'follow' })
104
+ if (!res.ok) throw new Error(`kiwi model download HTTP ${res.status}`)
105
+ const gz = Buffer.from(await res.arrayBuffer())
106
+ const tar = zlib.gunzipSync(gz)
107
+ extractTar(tar, dir)
108
+ if (!hasAllRequired(dir)) {
109
+ throw new Error(`kiwi model extract incomplete under ${dir}`)
110
+ }
111
+ return dir
112
+ }
113
+
114
+ function readModelFiles(dir) {
115
+ const modelFiles = {}
116
+ for (const f of REQUIRED_MODEL_FILES) {
117
+ modelFiles[f] = new Uint8Array(fs.readFileSync(path.join(dir, f)))
118
+ }
119
+ for (const f of OPTIONAL_MODEL_FILES) {
120
+ const p = path.join(dir, f)
121
+ if (fs.existsSync(p)) modelFiles[f] = new Uint8Array(fs.readFileSync(p))
122
+ }
123
+ return modelFiles
124
+ }
125
+
126
+ // Lazy, idempotent, never-throwing init. Fire-and-forget at boot.
127
+ export async function init(dataDir, log = () => {}) {
128
+ if (_state === 'ready') return true
129
+ if (_initPromise) return _initPromise
130
+ _log = typeof log === 'function' ? log : (() => {})
131
+ _state = 'loading'
132
+ const t0 = Date.now()
133
+ _initPromise = (async () => {
134
+ // Resolve the WASM path from the installed package without hard-importing
135
+ // (keeps the whole feature optional if kiwi-nlp isn't installed).
136
+ // NOTE: must go through module resolution (import.meta.resolve) — a bare
137
+ // `new URL(spec, import.meta.url)` resolves relative to THIS file's
138
+ // directory, not node_modules.
139
+ const { KiwiBuilder } = await import('kiwi-nlp')
140
+ const wasmUrl = import.meta.resolve('kiwi-nlp/dist/kiwi-wasm.wasm')
141
+ const wasmPath = wasmUrl.startsWith('file:')
142
+ ? (await import('node:url')).fileURLToPath(wasmUrl)
143
+ : wasmUrl
144
+ const dir = await downloadAndExtractModel(dataDir)
145
+ const modelFiles = readModelFiles(dir)
146
+ const builder = await KiwiBuilder.create(wasmPath)
147
+ // modelType 'knlm' = fast KnLM (sj.knlm); loadMultiDict/loadTypoDict only
148
+ // engage if the optional files were present.
149
+ _kiwi = await builder.build({ modelFiles, modelType: 'knlm' })
150
+ _state = 'ready'
151
+ _initMs = Date.now() - t0
152
+ _log(`[memory-service] kiwi morph ready in ${_initMs}ms (model ${KIWI_MODEL_VERSION}, rss≈${Math.round(process.memoryUsage().rss / 1e6)}MB)\n`)
153
+ return true
154
+ })().catch(err => {
155
+ _state = 'failed'
156
+ _kiwi = null
157
+ _log(`[memory-service] kiwi morph init failed — recall stays lexical: ${err?.message || err}\n`)
158
+ return false
159
+ })
160
+ return _initPromise
161
+ }
162
+
163
+ // Sync morphological analysis. Returns TokenInfo[] or null when not ready.
164
+ export function analyze(text) {
165
+ if (!isReady()) return null
166
+ const s = String(text ?? '')
167
+ if (!s) return null
168
+ try {
169
+ const r = _kiwi.analyze(s)
170
+ return Array.isArray(r?.tokens) ? r.tokens : null
171
+ } catch {
172
+ return null
173
+ }
174
+ }
175
+
176
+ // Content-morpheme stem forms for a Korean phrase. null when not ready.
177
+ // "개선을" → ['개선']; "개선하고" → ['개선','하'] filtered to content tags.
178
+ export function stems(text) {
179
+ const tokens = analyze(text)
180
+ if (!tokens) return null
181
+ const out = []
182
+ for (const t of tokens) {
183
+ if (!t || !CONTENT_TAGS.has(t.tag)) continue
184
+ const form = String(t.str || '').trim()
185
+ if (form.length >= 1) out.push(form)
186
+ }
187
+ return out
188
+ }
189
+
190
+ export function reset() {
191
+ _state = 'idle'
192
+ _initPromise = null
193
+ _kiwi = null
194
+ _initMs = 0
195
+ }