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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
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.
|
|
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
|
|
376
|
-
*
|
|
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,
|
|
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.
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
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
|
-
/**
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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"
|
|
272
|
-
//
|
|
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
|
-
|
|
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,
|
|
612
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|