mslxdff 0.1.43 → 0.1.54
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 +162 -162
- package/bin/mslxdff.js +265 -18
- package/docs/adr/0001-reasoning-content-injection.md +14 -0
- package/docs/adr/0002-models-free-filter.md +12 -0
- package/docs/adr/0003-zero-state-no-auth.md +10 -0
- package/docs/adr/0004-bearer-token.md +18 -0
- package/docs/adr/0005-peer-mesh.md +53 -0
- package/docs/adr/0006-broadband-member.md +103 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +30 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/plugins.md +185 -0
- package/package.json +3 -1
- package/plugins/README.md +14 -0
- package/plugins/prefer-model.mjs.example +14 -0
- package/src/auto.js +31 -7
- package/src/chooser.js +29 -0
- package/src/daemon.js +84 -84
- package/src/logs.js +41 -2
- package/src/models.js +127 -127
- package/src/plugins.js +107 -0
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +88 -0
- package/src/routes/chat/exhausted-handler.js +41 -0
- package/src/routes/chat/hedge-handler.js +142 -0
- package/src/routes/chat/index.js +178 -0
- package/src/routes/chat/local-handler.js +88 -0
- package/src/routes/chat/peer-handler.js +58 -0
- package/src/routes/chat.js +3 -278
- package/src/routes/hedge.js +251 -0
- package/src/routes/index.js +2 -2
- package/src/routes/models-route.js +15 -2
- package/src/routes/peers.js +58 -15
- package/src/server.js +57 -54
- package/src/state.js +272 -141
- package/src/sync-workbuddy.js +104 -0
- package/src/upstream.js +311 -192
package/bin/mslxdff.js
CHANGED
|
@@ -8,13 +8,17 @@ import { DEFAULT_PORT, defaultStateFile } from "../src/state.js";
|
|
|
8
8
|
import { createRouter } from "../src/routes.js";
|
|
9
9
|
import { createUpstreamClient } from "../src/upstream.js";
|
|
10
10
|
import { createModelsService } from "../src/models.js";
|
|
11
|
-
import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors } from "../src/state.js";
|
|
11
|
+
import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel, loadPreferredModel } from "../src/state.js";
|
|
12
|
+
import { getPreferredModel } from "../src/auto.js";
|
|
13
|
+
import { normalizeModel } from "../src/reasoning.js";
|
|
14
|
+
import { syncToWorkbuddy, workbuddyModelsPath } from "../src/sync-workbuddy.js";
|
|
12
15
|
import { startDaemon, stopDaemon, writePid, pidFile, logFile, readPid, readPidVersion, isPidAlive } from "../src/daemon.js";
|
|
13
16
|
import { createAutoSelector } from "../src/auto.js";
|
|
14
17
|
import { createPeersService } from "../src/peers.js";
|
|
15
18
|
import { createEventBus } from "../src/events.js";
|
|
16
19
|
import { createGroupsService, createBansService, refreshGroupMembers, syncPeersFromMembers } from "../src/groups.js";
|
|
17
20
|
import { logDir, recentCalls, lastError, appendCall, appendError, appendEvent, recentEvents, eventsFile, callsFile, errorsFile } from "../src/logs.js";
|
|
21
|
+
import { loadPlugins, runHook, pluginsDir, resolvePluginDirs } from "../src/plugins.js";
|
|
18
22
|
|
|
19
23
|
const logs = { appendCall, appendError, appendEvent };
|
|
20
24
|
|
|
@@ -108,6 +112,28 @@ if (args.includes("-log") || args.includes("--log") || args.includes("-logs") ||
|
|
|
108
112
|
process.exit(0);
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
// -plugins: list plugins in the plugins dirs (and their hooks) without starting the daemon
|
|
116
|
+
if (args.includes("-plugins") || args.includes("--plugins")) {
|
|
117
|
+
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
118
|
+
const dirs = resolvePluginDirs({ pkgRoot });
|
|
119
|
+
const labels = ["official (bundled)", "user"];
|
|
120
|
+
if (dirs.length === 1) labels[0] = "dir";
|
|
121
|
+
console.log(`plugin dirs:`);
|
|
122
|
+
dirs.forEach((d, i) => console.log(` [${labels[i] || `dir${i + 1}`}] ${d}${existsSync(d) ? "" : " (not created yet)"}`));
|
|
123
|
+
const { plugins, errors } = await loadPlugins({ dirs });
|
|
124
|
+
if (!plugins.length && !errors.length) {
|
|
125
|
+
console.log("(no plugins — drop *.mjs files into a dir above, see docs/plugins.md)");
|
|
126
|
+
}
|
|
127
|
+
for (const p of plugins) {
|
|
128
|
+
const hooks = Object.keys(p.hooks || {});
|
|
129
|
+
const src = p.file.startsWith(pkgRoot) ? "official" : "user";
|
|
130
|
+
console.log(` ${p.name}${p.version ? `@${p.version}` : ""} [${hooks.join(", ") || "no hooks"}] (${src})`);
|
|
131
|
+
if (p.description) console.log(` ${p.description}`);
|
|
132
|
+
}
|
|
133
|
+
for (const e of errors) console.log(` load error: ${e.file} — ${e.error}`);
|
|
134
|
+
process.exit(0);
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
if (args.includes("-status") || args.includes("--status") || args.includes("-s")) {
|
|
112
138
|
await printStatus();
|
|
113
139
|
process.exit(0);
|
|
@@ -156,30 +182,84 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
156
182
|
}
|
|
157
183
|
process.exit(0);
|
|
158
184
|
}
|
|
185
|
+
if (sub === "set" && args[idx + 2]) {
|
|
186
|
+
const id = args[idx + 2];
|
|
187
|
+
savePreferredModel(id);
|
|
188
|
+
console.log(`default model set to: ${id} (daemon hot-reloads on next request)`);
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
159
191
|
if (sub !== undefined && sub !== "list") {
|
|
160
|
-
console.error("usage: mslxdff -model list | mslxdff -model status | mslxdff -model refresh");
|
|
192
|
+
console.error("usage: mslxdff -models (interactive picker) | mslxdff -model list | mslxdff -model set <id> | mslxdff -model status | mslxdff -model refresh");
|
|
161
193
|
process.exit(1);
|
|
162
194
|
}
|
|
163
195
|
const cacheFile = join(logDir(), "models.json");
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const ids = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
168
|
-
const at = cached.cachedAt ? ` (cached ${new Date(cached.cachedAt).toISOString().slice(0, 16).replace("T", " ")})` : "";
|
|
169
|
-
console.log(`${ids.length} free model(s)${at}:`);
|
|
170
|
-
for (const id of ids) console.log(` ${id}`);
|
|
171
|
-
} else {
|
|
196
|
+
// 主动刷新:每次执行都尝试拉取上游最新列表(4s 超时),成功则更新缓存,失败则回退到 stale
|
|
197
|
+
async function tryRefreshModels() {
|
|
198
|
+
try {
|
|
172
199
|
const models = createModelsService({
|
|
173
200
|
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
174
201
|
headers: createUpstreamClient({}).headers,
|
|
175
202
|
refreshMs: 0,
|
|
176
203
|
cacheFile,
|
|
177
204
|
});
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
205
|
+
// 4s 超时,避免阻塞
|
|
206
|
+
const list = await Promise.race([
|
|
207
|
+
models.get(),
|
|
208
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
209
|
+
]);
|
|
210
|
+
return list;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
182
213
|
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
let ids = [];
|
|
217
|
+
let cachedAt = null;
|
|
218
|
+
let refreshed = null;
|
|
219
|
+
// 每次都主动尝试刷新
|
|
220
|
+
refreshed = await tryRefreshModels();
|
|
221
|
+
if (refreshed?.data) {
|
|
222
|
+
ids = (refreshed.data || []).map((m) => m.id).filter(Boolean);
|
|
223
|
+
cachedAt = refreshed.cachedAt || Date.now();
|
|
224
|
+
} else {
|
|
225
|
+
const cached = readModelsCache(cacheFile);
|
|
226
|
+
if (cached) {
|
|
227
|
+
ids = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
228
|
+
cachedAt = cached.cachedAt || null;
|
|
229
|
+
} else {
|
|
230
|
+
// 无缓存且刷新失败,尝试一次兜底 fetch(已在 tryRefreshModels 中尝试过,此处直接报错)
|
|
231
|
+
throw new Error("no cached models and refresh failed");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!ids.length) {
|
|
235
|
+
console.log("no models available — try: mslxdff -model refresh");
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
// TTY:交互式箭头选择默认模型;非 TTY(管道/脚本):保持纯列表
|
|
239
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
240
|
+
const statuses = loadModelErrors();
|
|
241
|
+
const current = getPreferredModel();
|
|
242
|
+
const items = ids.map((id) => {
|
|
243
|
+
const e = statuses[id];
|
|
244
|
+
return {
|
|
245
|
+
id,
|
|
246
|
+
status: typeof e === "number" ? "error" : e?.status || "normal",
|
|
247
|
+
current: id === current,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
const picked = await pickInteractive(items, Math.max(0, items.findIndex((x) => x.current)));
|
|
251
|
+
if (!picked) {
|
|
252
|
+
console.log("cancelled — default model unchanged");
|
|
253
|
+
process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
savePreferredModel(picked);
|
|
256
|
+
console.log(`default model set to: ${picked} (daemon hot-reloads on next request)`);
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
const at = cachedAt ? ` (cached ${new Date(cachedAt).toISOString().slice(0, 16).replace("T", " ")})` : "";
|
|
260
|
+
console.log(`${ids.length} free model(s)${at}:`);
|
|
261
|
+
for (const id of ids) console.log(` ${id}`);
|
|
262
|
+
console.log(`\ninteractive pick needs a TTY; set directly with: mslxdff -model set <id>`);
|
|
183
263
|
} catch (err) {
|
|
184
264
|
console.error(`could not fetch models: ${String(err?.message || err)}`);
|
|
185
265
|
process.exit(1);
|
|
@@ -187,6 +267,114 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
187
267
|
process.exit(0);
|
|
188
268
|
}
|
|
189
269
|
|
|
270
|
+
// -setto workbuddy [modelId]: set default model and sync to WorkBuddy models.json
|
|
271
|
+
if (args.includes("-setto") || args.includes("--setto")) {
|
|
272
|
+
const idx = args.findIndex((x) => x === "-setto" || x === "--setto");
|
|
273
|
+
const target = args[idx + 1];
|
|
274
|
+
if (target !== "workbuddy") {
|
|
275
|
+
console.error("usage: mslxdff -setto workbuddy [modelId]");
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
const raw = args[idx + 2] && !String(args[idx + 2]).startsWith("-") ? String(args[idx + 2]).trim() : null;
|
|
279
|
+
let id;
|
|
280
|
+
if (raw) {
|
|
281
|
+
if (raw === "auto" || !raw) {
|
|
282
|
+
console.error("modelId 不能为 auto 或空");
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
const norm = normalizeModel(raw);
|
|
286
|
+
if (!norm) {
|
|
287
|
+
console.error("modelId 不能为空");
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
savePreferredModel(norm);
|
|
291
|
+
console.log(`default model set to: ${norm} (daemon hot-reloads on next request)`);
|
|
292
|
+
id = norm;
|
|
293
|
+
} else {
|
|
294
|
+
id = loadPreferredModel() || getPreferredModel();
|
|
295
|
+
if (!id) {
|
|
296
|
+
console.error("no preferred model set; use: mslxdff -setto workbuddy <modelId>");
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// 主动刷新模型列表(每次 -setto 都尝试,保证 deepseek 等最新模型可见;失败不阻断同步)
|
|
301
|
+
try {
|
|
302
|
+
const cacheFile = join(logDir(), "models.json");
|
|
303
|
+
const models = createModelsService({
|
|
304
|
+
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
305
|
+
headers: createUpstreamClient({}).headers,
|
|
306
|
+
refreshMs: 0,
|
|
307
|
+
cacheFile,
|
|
308
|
+
});
|
|
309
|
+
const fresh = await Promise.race([
|
|
310
|
+
models.get(),
|
|
311
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
312
|
+
]);
|
|
313
|
+
if (fresh?.data?.length) {
|
|
314
|
+
const ids = fresh.data.map((m) => m.id);
|
|
315
|
+
if (!ids.includes(id)) {
|
|
316
|
+
console.log(`warn: "${id}" not in current free list (${ids.length} models), still syncing to WorkBuddy`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
// refresh failed, still proceed with sync using stale/cached list
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const { token } = await loadToken();
|
|
324
|
+
const persisted = getPort();
|
|
325
|
+
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
326
|
+
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
327
|
+
const file = workbuddyModelsPath();
|
|
328
|
+
const r = await syncToWorkbuddy({ id, token, port, file });
|
|
329
|
+
console.log(`synced to WorkBuddy: ${r.action} "${id}" @ ${file}`);
|
|
330
|
+
console.log(` url: http://127.0.0.1:${port}/v1/chat/completions`);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error(`failed to sync to WorkBuddy: ${String(err?.message || err)}`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 交互式选择器:↑/↓ 移动,Enter 确认,q/Esc 取消;ANSI 原地重绘
|
|
339
|
+
async function pickInteractive(items, startCursor = 0) {
|
|
340
|
+
const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
|
|
341
|
+
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
342
|
+
const draw = () => {
|
|
343
|
+
const lines = [...renderChooser(items, cursor), ...renderChooserHelp()];
|
|
344
|
+
process.stdout.write("\x1b[G\x1b[J" + lines.join("\n"));
|
|
345
|
+
};
|
|
346
|
+
draw();
|
|
347
|
+
return new Promise((resolve) => {
|
|
348
|
+
const wasRaw = process.stdin.isRaw;
|
|
349
|
+
process.stdin.setRawMode(true);
|
|
350
|
+
process.stdin.resume();
|
|
351
|
+
process.stdin.setEncoding("utf8");
|
|
352
|
+
const cleanup = () => {
|
|
353
|
+
process.stdin.removeListener("data", onData);
|
|
354
|
+
process.stdin.setRawMode(false);
|
|
355
|
+
process.stdin.pause();
|
|
356
|
+
process.stdout.write("\n");
|
|
357
|
+
};
|
|
358
|
+
const onData = (chunk) => {
|
|
359
|
+
const key = parseKey(String(chunk));
|
|
360
|
+
if (key === "up") {
|
|
361
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
362
|
+
draw();
|
|
363
|
+
} else if (key === "down") {
|
|
364
|
+
cursor = (cursor + 1) % items.length;
|
|
365
|
+
draw();
|
|
366
|
+
} else if (key === "enter") {
|
|
367
|
+
cleanup();
|
|
368
|
+
resolve(items[cursor].id);
|
|
369
|
+
} else if (key === "cancel") {
|
|
370
|
+
cleanup();
|
|
371
|
+
resolve(null);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
process.stdin.on("data", onData);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
190
378
|
// -debug: stop the background daemon and run the server in THIS terminal
|
|
191
379
|
// (foreground), printing every event to stdout in real time via the in-memory
|
|
192
380
|
// event bus — no filesystem polling. Ctrl+C / SIGTERM restarts the daemon in
|
|
@@ -648,7 +836,36 @@ if (!process.env.MSLXDFF_DAEMON) {
|
|
|
648
836
|
}
|
|
649
837
|
|
|
650
838
|
const { token, created } = await loadToken();
|
|
651
|
-
|
|
839
|
+
// 插件系统:加载(在 upstream 创建前,插件可整体替换上游实现)
|
|
840
|
+
// 双目录:<安装目录>/plugins/(官方插件,随包分发)+ ~/.config/mslxdff/plugins/(用户自定义,升级不丢)
|
|
841
|
+
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
842
|
+
const pluginDirs = resolvePluginDirs({ pkgRoot });
|
|
843
|
+
const { plugins: loadedPlugins, errors: pluginErrors } = await loadPlugins({ dirs: pluginDirs });
|
|
844
|
+
for (const e of pluginErrors) {
|
|
845
|
+
console.log(`plugin load failed: ${e.file} — ${e.error}`);
|
|
846
|
+
appendEvent({ ts: Date.now(), type: "plugin-load-error", file: e.file, error: e.error });
|
|
847
|
+
}
|
|
848
|
+
if (loadedPlugins.length) {
|
|
849
|
+
console.log(`plugins loaded (${loadedPlugins.length}): ${loadedPlugins.map((p) => `${p.name}${p.version ? `@${p.version}` : ""}`).join(", ")}`);
|
|
850
|
+
appendEvent({ ts: Date.now(), type: "plugins-loaded", plugins: loadedPlugins.map((p) => ({ name: p.name, version: p.version })) });
|
|
851
|
+
}
|
|
852
|
+
const upstreamHooks = loadedPlugins.length
|
|
853
|
+
? (name, ctx) => runHook(loadedPlugins, name, ctx)
|
|
854
|
+
: null;
|
|
855
|
+
// 插件可提供 createUpstream(ctx) 整体替换上游(接任意 provider);取第一个声明者
|
|
856
|
+
const providerPlugin = loadedPlugins.find((p) => typeof p.createUpstream === "function");
|
|
857
|
+
let upstream;
|
|
858
|
+
if (providerPlugin) {
|
|
859
|
+
try {
|
|
860
|
+
upstream = await providerPlugin.createUpstream({ baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai", authToken: process.env.UPSTREAM_AUTH_TOKEN || "public", env: process.env });
|
|
861
|
+
console.log(`upstream provider replaced by plugin: ${providerPlugin.name}`);
|
|
862
|
+
appendEvent({ ts: Date.now(), type: "plugin-upstream-active", plugin: providerPlugin.name });
|
|
863
|
+
} catch (err) {
|
|
864
|
+
console.log(`plugin upstream (${providerPlugin.name}) failed: ${errMsg(err)} — falling back to default`);
|
|
865
|
+
appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
|
|
652
869
|
const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
|
|
653
870
|
const models = createModelsService({
|
|
654
871
|
baseUrl,
|
|
@@ -673,9 +890,16 @@ const bans = createBansService({ windowMs: banWindowMs(), threshold: banThreshol
|
|
|
673
890
|
|
|
674
891
|
const isDebug = process.env.MSLXDFF_DEBUG === "1";
|
|
675
892
|
const bus = createEventBus();
|
|
676
|
-
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus });
|
|
893
|
+
const router = createRouter({ token, upstream, models, auto, logs, peers, maxHops: maxHopsValue(), groups, bans, bus, plugins: loadedPlugins });
|
|
677
894
|
const listenHost = effectiveHost();
|
|
678
|
-
const srv = startServer({
|
|
895
|
+
const srv = startServer({
|
|
896
|
+
router,
|
|
897
|
+
signals: !isDebug,
|
|
898
|
+
host: listenHost,
|
|
899
|
+
onBeforeClose: loadedPlugins.length
|
|
900
|
+
? () => runHook(loadedPlugins, "server:stop", { version: VERSION }).then(() => {})
|
|
901
|
+
: undefined,
|
|
902
|
+
});
|
|
679
903
|
|
|
680
904
|
// -debug: push every event straight to this terminal.
|
|
681
905
|
if (isDebug) {
|
|
@@ -703,6 +927,20 @@ if (isDebug) {
|
|
|
703
927
|
|
|
704
928
|
await srv.ready();
|
|
705
929
|
|
|
930
|
+
// 插件 hook:server:start — 服务就绪后触发(只观察)
|
|
931
|
+
if (loadedPlugins.length) {
|
|
932
|
+
runHook(loadedPlugins, "server:start", { port: srv.server.address()?.port, host: listenHost, version: VERSION }).catch(() => {});
|
|
933
|
+
// 插件 onEvent(evt) — 订阅全部事件流(fire-and-forget,错误隔离)
|
|
934
|
+
const eventPlugins = loadedPlugins.filter((p) => typeof p.onEvent === "function");
|
|
935
|
+
if (eventPlugins.length) {
|
|
936
|
+
bus.subscribe((e) => {
|
|
937
|
+
for (const p of eventPlugins) {
|
|
938
|
+
try { p.onEvent(e); } catch {}
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
706
944
|
// 上游 Keep-Alive 预热:首条 TCP+TLS 暖好,100ms 后异步触发,不阻塞 ready
|
|
707
945
|
setTimeout(() => {
|
|
708
946
|
upstream.preheat().then((r) => {
|
|
@@ -726,6 +964,11 @@ if (created) {
|
|
|
726
964
|
console.log(`auth token: ${token}`);
|
|
727
965
|
}
|
|
728
966
|
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
967
|
+
try {
|
|
968
|
+
const { hedgeDelayMs } = await import("../src/routes/hedge.js");
|
|
969
|
+
const hd = hedgeDelayMs();
|
|
970
|
+
console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
|
|
971
|
+
} catch {}
|
|
729
972
|
|
|
730
973
|
// Periodically pull the freshest member lists for every joined group so a
|
|
731
974
|
// new member becomes a failover peer on all nodes without manual re-joining.
|
|
@@ -1018,7 +1261,9 @@ Usage:
|
|
|
1018
1261
|
mslxdff -d start as a background daemon
|
|
1019
1262
|
mslxdff -status show current status (daemon, models, recent calls, last error)
|
|
1020
1263
|
mslxdff -log [N] show last N events (default 10, e.g. -log 100)
|
|
1021
|
-
mslxdff -model
|
|
1264
|
+
mslxdff -models interactive picker: ↑/↓ select a model, Enter sets it as the default (non-TTY: plain list)
|
|
1265
|
+
mslxdff -model list list the free models this proxy serves (cached)
|
|
1266
|
+
mslxdff -model set <id> set the default (preferred) model without the interactive picker
|
|
1022
1267
|
mslxdff -model status show per-model health status (normal/limit/error)
|
|
1023
1268
|
mslxdff -model refresh force-refresh the model cache from the upstream
|
|
1024
1269
|
mslxdff -debug live-follow the daemon event stream (requests, errors, peer forwards)
|
|
@@ -1028,6 +1273,7 @@ Usage:
|
|
|
1028
1273
|
mslxdff -update update mslxdff to the latest published version
|
|
1029
1274
|
mslxdff -showtoken print the current auth token
|
|
1030
1275
|
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
1276
|
+
mslxdff -setto workbuddy [modelId] set default model and sync to WorkBuddy models.json (insert or update 127.0.0.1/v1 entry)
|
|
1031
1277
|
mslxdff -creategroup <name> create a group on this node (the group name is the password)
|
|
1032
1278
|
mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
|
|
1033
1279
|
mslxdff -group sync pull the freshest member list for all joined groups
|
|
@@ -1054,6 +1300,7 @@ Environment:
|
|
|
1054
1300
|
MSLXDFF_MAX_HOPS max peer-forwarding depth (default 3)
|
|
1055
1301
|
MSLXDFF_BAN_THRESHOLD failed joins before an ip is banned (default 5)
|
|
1056
1302
|
MSLXDFF_BAN_WINDOW_MS ban duration after too many failures (default 48h)
|
|
1303
|
+
MSLXDFF_HEDGE_DELAY_MS hedge peer race when local stream first chunk slow (default 1000, 0/off to disable)
|
|
1057
1304
|
MSLXDFF_AUTO_UPDATE auto-update: hourly by default, 0/off/false to disable, 1/true or ms
|
|
1058
1305
|
MSLXDFF_AUTO_UPDATE_MS same as above, explicit ms (overrides AUTO_UPDATE)
|
|
1059
1306
|
`);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
|
|
2
|
+
|
|
3
|
+
The Zen upstream's thinking-mode models (deepseek-family at minimum) return
|
|
4
|
+
`400 "The reasoning_content in the thinking mode must be passed back"` when a
|
|
5
|
+
multi-turn request echoes an assistant message without its `reasoning_content`.
|
|
6
|
+
Clients speaking plain OpenAI format never send that field, so the proxy writes
|
|
7
|
+
a `" "` placeholder into assistant messages before forwarding. Scope is `all`
|
|
8
|
+
for deepseek-family models and `tool_calls` for kimi-family models; messages
|
|
9
|
+
that already carry non-empty `reasoning_content` are left untouched.
|
|
10
|
+
|
|
11
|
+
The alternative — telling clients to manage `reasoning_content` themselves —
|
|
12
|
+
would break standard OpenAI-compatible clients, so the proxy eats this
|
|
13
|
+
compatibility cost instead. Matches `/root/9router` v0.5.45
|
|
14
|
+
`open-sse/utils/reasoningContentInjector.js`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
|
|
2
|
+
|
|
3
|
+
The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
|
|
4
|
+
pollute clients with paid models this proxy can't serve for free. `/v1/models`
|
|
5
|
+
therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
|
|
6
|
+
`big-pickle`. The whitelist exists because `big-pickle` is a free model without
|
|
7
|
+
the `-free` suffix, and a suffix-only filter would silently drop it.
|
|
8
|
+
|
|
9
|
+
A plain `endsWith("-free")` filter was considered and rejected for exactly that
|
|
10
|
+
reason. Matches `/root/9router` v0.5.45
|
|
11
|
+
`src/app/api/providers/suggested-models/filters.js`
|
|
12
|
+
(`KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]`).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# ADR-0003: Zero-state, no-DB, no-account local proxy
|
|
2
|
+
|
|
3
|
+
Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
|
|
4
|
+
no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
|
|
5
|
+
stand.
|
|
6
|
+
|
|
7
|
+
By design this proxy holds no database, no token store, no account rotation,
|
|
8
|
+
and no cloud sync. A single static bearer token is the only credential, kept
|
|
9
|
+
in a 0600 state file (see ADR-0004); everything else is stateless per-process
|
|
10
|
+
memory at most.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# ADR-0004: Single static bearer token, persisted in a 0600 state file
|
|
2
|
+
|
|
3
|
+
The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
|
|
4
|
+
isn't an open relay. There is no account system: the token is a random
|
|
5
|
+
`crypto` 32-byte value (hex), generated once on first run, persisted to a
|
|
6
|
+
state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
|
|
7
|
+
via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
|
|
8
|
+
`mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
|
|
9
|
+
new token, and exits (does not start the server).
|
|
10
|
+
|
|
11
|
+
Auth is enforced with a constant-time string compare on
|
|
12
|
+
`Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
|
|
13
|
+
`/health` stays public (no token). Tokens never appear in logs.
|
|
14
|
+
|
|
15
|
+
Alternatives rejected: a fixed default token (same key on every install),
|
|
16
|
+
per-user accounts (needs a DB — that's the 9Router provisioning surface we
|
|
17
|
+
rejected in ADR-0003), and unauthenticated local-only binding (fragile;
|
|
18
|
+
a proxy relay deserves an explicit secret even on localhost).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ADR-0005: Peer mesh for same-model failover across machines
|
|
2
|
+
|
|
3
|
+
> **Update (group model):** peers are now configured through named groups, and the
|
|
4
|
+
> group name doubles as the join password (supersedes the random join key). CLI:
|
|
5
|
+
> `-creategroup <name>` on the leader (no address needed — the first joiner seeds
|
|
6
|
+
> the leader's own entry from the address it connects from), `-addtogroup <leader-host> <name>`
|
|
7
|
+
> elsewhere. Every member (leader included) re-registers with the leader on a timer
|
|
8
|
+
> (`MSLXDFF_GROUP_SYNC_MS`, default 60s) and rebuilds its local peer list from the
|
|
9
|
+
> freshest member map, so membership changes propagate to all nodes automatically.
|
|
10
|
+
> The `-peer` commands are removed; peers are an internal mechanism. Wrong group
|
|
11
|
+
> names/tokens are counted per source IP: `MSLXDFF_BAN_THRESHOLD` (default 5)
|
|
12
|
+
> failures ban the IP for `MSLXDFF_BAN_WINDOW_MS` (default 48h); `-resetban [ip]`
|
|
13
|
+
> clears bans.
|
|
14
|
+
|
|
15
|
+
A single mslxdff instance depends on one upstream quota; when that upstream
|
|
16
|
+
starts rate-limiting or failing for a model, the only local fallback is
|
|
17
|
+
switching to a *different* model. That changes the model out from under the
|
|
18
|
+
client. To keep the requested model working while the local path recovers,
|
|
19
|
+
multiple instances can be joined into a group: when the local upstream
|
|
20
|
+
fails for model X, the instance forwards the request (still model X) to a
|
|
21
|
+
group member that runs its own mslxdff and has its own upstream quota.
|
|
22
|
+
|
|
23
|
+
## Design
|
|
24
|
+
|
|
25
|
+
- **Peers are plain mslxdff instances.** Each peer is identified by
|
|
26
|
+
`{ url, token }` (its bearer token, per ADR-0004). Configured with
|
|
27
|
+
`mslxdff -peer add <token> <url> [name]`, removed with `-peer remove`,
|
|
28
|
+
listed with `-peer list`; persisted in the state file. No new protocol —
|
|
29
|
+
forwarding is a normal authenticated `POST /v1/chat/completions` to the
|
|
30
|
+
peer, reusing the existing API surface.
|
|
31
|
+
- **Local-first routing.** A request always tries the local upstream first.
|
|
32
|
+
Only on local failure (network error or HTTP ≥ 400) does it iterate peers
|
|
33
|
+
for the *same model*, round-robin over currently-available peers.
|
|
34
|
+
- **Model lock.** Forwarded requests carry `x-mslxdff-model-lock: <model>`
|
|
35
|
+
so the receiving peer uses exactly that model — it must not re-select or
|
|
36
|
+
fall back to another model, keeping "same model, different machine" true.
|
|
37
|
+
- **Hop bound.** Forwarded requests carry `x-mslxdff-hops` (incremented each
|
|
38
|
+
hop). A peer receiving hops ≥ `maxHops` (default 3, `MSLXDFF_MAX_HOPS`)
|
|
39
|
+
stops forwarding further, bounding mesh depth and preventing loops.
|
|
40
|
+
- **Peer cooldown.** A peer that fails a request enters a cooldown window
|
|
41
|
+
(default 30s, `MSLXDFF_PEER_COOLDOWN_MS`), during which it is skipped by
|
|
42
|
+
the round-robin, so the mesh rotates to healthy peers instead of
|
|
43
|
+
hammering a down one. Mirrors the model cooldown in ADR-0001.
|
|
44
|
+
|
|
45
|
+
## Why not alternatives
|
|
46
|
+
|
|
47
|
+
- **Point the whole proxy at another machine** (client-side failover): the
|
|
48
|
+
client can't detect per-model upstream failure, and every request pays the
|
|
49
|
+
cross-machine latency even when local is healthy.
|
|
50
|
+
- **Different model per machine**: violates the requirement of keeping the
|
|
51
|
+
same model, and hides the model-change from the client.
|
|
52
|
+
- **Central coordinator / service discovery**: overkill for a handful of
|
|
53
|
+
private instances; static peer config is zero-config and debuggable.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# ADR-0006: 宽带动态IP成员(broadband)经 Leader 中继共享配额
|
|
2
|
+
|
|
3
|
+
> **状态**:规划完成,待实现(基于 v0.1.33)。用户确认:`--broadband` 为唯一语义,不设 `--relay` 别名;默认 `127.0.0.1` 监听。
|
|
4
|
+
|
|
5
|
+
## 1. 背景与问题
|
|
6
|
+
|
|
7
|
+
现有组模型(ADR-0005)假设组员均为公网 VPS,`A->D` 直连 `http://D公网IP:8989/v1/chat/completions` 用 `D` 的出口 IP 打 `opencode.ai`,实现 `IP级免费池` 分散。家庭宽带 `D` 有公网 IP 但:
|
|
8
|
+
|
|
9
|
+
1. **入站不可达**:无端口映射/CGNAT,`probeHealth GET http://D:8989/health` 恒 `fail`,`peer-race` 直接跳过,`D` 的配额永不被用。
|
|
10
|
+
2. **IP 动态**:`refreshGroupMembers` 透传旧 `myUrl`,IP 变更后 60s 同步期内全组仍用旧 IP。
|
|
11
|
+
3. **语义缺失**:组内无法区分 `static(VPS直连)` 与 `broadband(家庭中继)`,`-group list` 无标识。
|
|
12
|
+
|
|
13
|
+
需求:家庭 `D` 以 `broadband` 类型加入,无需公网入站,经 `Leader` 中继仍用 `D` 的家庭出口打上游,且全程可观测。
|
|
14
|
+
|
|
15
|
+
## 2. 决策
|
|
16
|
+
|
|
17
|
+
新增成员类型 `kind: "broadband"`(默认 `kind: "static"` 兼容老数据),`broadband` 隐含 `relay` 中继:
|
|
18
|
+
|
|
19
|
+
* **加入**:`mslxdff -addtogroup <leader-host> <group> --broadband`(唯一旗标,不设 `--relay`)。
|
|
20
|
+
* **监听**:`--broadband` 下默认 `listen 127.0.0.1:8989`,仅本机 `WorkBuddy` 可用;不加该旗标的为 `static` 走 `0.0.0.0`。
|
|
21
|
+
* **共享**:`D --WS--> Leader` 常驻出站,`A(429) -> Leader -> D -> opencode.ai -> D -> Leader -> A`,上游仍见 `D` 家庭 IP。
|
|
22
|
+
* **展示**:`-help` 新增用法行,`-group list / -status / -log` 标注 `[broadband] via leader Xs ago ip=...`。
|
|
23
|
+
|
|
24
|
+
## 3. 设计
|
|
25
|
+
|
|
26
|
+
### 3.1 成员模型
|
|
27
|
+
|
|
28
|
+
`state.json groups[name].members[id]` 扩展:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
"home-D": {
|
|
32
|
+
"url": "relay://home-D",
|
|
33
|
+
"token": "...",
|
|
34
|
+
"kind": "broadband",
|
|
35
|
+
"publicIp": "183.14.22.78",
|
|
36
|
+
"lastSeen": 1724212345678,
|
|
37
|
+
"status": {"upstreamOk": true, "latencyMs": 4200}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
* `static`:`url: http://IP:8989`,参与 `probeHealth`。
|
|
42
|
+
* `broadband`:`url: relay://id`,不探活,只看 `lastSeen`(`>90s` 标 `cooling`)+ `status`,`rankModels` 同 `slow` 5m 冷却共用。
|
|
43
|
+
|
|
44
|
+
### 3.2 连接与心跳
|
|
45
|
+
|
|
46
|
+
**D 端(家庭)**
|
|
47
|
+
* 解析 `--broadband`,建 `WS wss://Leader/v1/groups/relay/connect?group=my@mslxd`,`Authorization: Bearer <token>`。
|
|
48
|
+
* `hello {group, token, kind:"broadband", version}` → Leader 回 `welcome {memberId}`。
|
|
49
|
+
* `heartbeat 30s {status:{upstreamOk, models, load}}`,`publicIp` 由 Leader 的 `clientIp(req)` 填,不靠 `ifconfig.me`。
|
|
50
|
+
* 断线指数退避 `1s/2s/4s...` 重连,IP 变更导致 `TCP RST` 自动重建即完成 IP 更新。
|
|
51
|
+
|
|
52
|
+
**Leader 端(VPS)**
|
|
53
|
+
* `GET /v1/groups/relay/connect` 升级 WS,鉴权 `membersForToken`,存 `relayConns[group][id]=ws`,`ws.remoteIp=clientIp`。
|
|
54
|
+
* 每条 `heartbeat` 对比 `remoteIp` vs `members[id].publicIp`,变则 `saveGroups` + `evt relay-ip-change` 入 `events.log`。
|
|
55
|
+
* `lastSeen >90s` 或 `WS断开` 立即标 `cooling`,`A` 下次 `candidatesFor` 自动避开。
|
|
56
|
+
|
|
57
|
+
### 3.3 转发路径
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
用户 -> A: POST /v1/chat/completions {model: deepseek, stream:true}
|
|
61
|
+
A -> opencode.ai (用A IP) 429
|
|
62
|
+
A选 candidatesFor -> [B(static), home-D(broadband via Leader), ...] 按 latency EMA
|
|
63
|
+
A -> Leader: POST /v1/groups/relay/forward {target: home-D, body, hops:1, reqId}
|
|
64
|
+
Leader -> D (WS): {reqId, body:{model,messages}}
|
|
65
|
+
D -> opencode.ai (用D家庭IP) 200 SSE chunk*
|
|
66
|
+
D --WS {reqId, data:chunk}--> Leader --HTTP chunk--> A --SSE--> 用户
|
|
67
|
+
A断开 -> Leader --WS {abort reqId}--> D controller.abort()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
* `hops` 每跳 +1,`MAX_HOPS=3` 防环;`broadband` 节点自身 `429` 时可直接 `fetch` 到 VPS `static` 节点(出站直连,无需中继)。
|
|
71
|
+
* 多请求复用一条 WS,`reqId` 复用现有 `relay` 日志的 `reqId/detail` 串联。
|
|
72
|
+
|
|
73
|
+
### 3.4 可观测
|
|
74
|
+
|
|
75
|
+
* `-help`:` -addtogroup <host> <name> [--broadband] 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)`
|
|
76
|
+
* `-group list`:`3. relay://home-D [broadband] ok via leader 12s ago ip=183.14.22.78`
|
|
77
|
+
* `-log [N]`:`relay-ip-change / relay-forward / relay-heartbeat / client-abort` 均带 `reqId/detail`。
|
|
78
|
+
|
|
79
|
+
## 4. 实现清单
|
|
80
|
+
|
|
81
|
+
| 文件 | 改动 |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `bin/mslxdff.js` | `-addtogroup` 解析 `--broadband`,`WS` 客户端+心跳+重连,`listen` 在 `broadband` 下绑 `127.0.0.1`,`printHelp` 新增行,`groupSyncTimer` 对 `broadband` 组 30s |
|
|
84
|
+
| `src/groups.js` | 成员结构 `kind/publicIp/lastSeen/status`,`addGroupMember/upsertMember/syncPeersFromMembers` 支持 `broadband`,`refreshGroupMembers` 透传 `kind` |
|
|
85
|
+
| `src/peers.js` | 新增 `relayVia` 字段,`add({relayVia, kind}) / isRelay`,`broadband` 不进 `ordered()` 直连池 |
|
|
86
|
+
| `src/routes.js` | 新增 `WS /v1/groups/relay/connect` + `POST /v1/groups/relay/forward`,`forwardToPeer` 中继分支,`hops` 处理 |
|
|
87
|
+
| `src/state.js` | 持久化 `kind/publicIp/lastSeen`,新增 `load/save` 兼容 |
|
|
88
|
+
| `src/logs.js` | `relay-*` 事件类型 |
|
|
89
|
+
|
|
90
|
+
依赖:`ws`(或 Node 22 原生 `WebSocket`,服务端需 `upgrade` 处理)。
|
|
91
|
+
|
|
92
|
+
## 5. 验证
|
|
93
|
+
|
|
94
|
+
1. **IP变更**:`D` 拨号重拨,`WS` 重连,`events.log` 出现 `relay-ip-change old->new`,`-group list` IP 更新,`A` 经 `Leader` 仍命中 `D`。
|
|
95
|
+
2. **配额共享**:`A` 指定 `deepseek 429`,`-log` 显示 `peer-race via=relay-leader target=home-D`,`D` 的 `upstream-done 200` 且 `detail.exitReason:normal`,回包完整。
|
|
96
|
+
3. **本地可用**:`D` 本机 `curl http://127.0.0.1:8989/v1/chat/completions` 200,外网 `curl http://家庭公网IP:8989` 超时(符合预期)。
|
|
97
|
+
4. **`-help` / `-group list`** 均含 `broadband` 标识。
|
|
98
|
+
|
|
99
|
+
## 6. 风险与取舍
|
|
100
|
+
|
|
101
|
+
* `Leader` 单点中继增加 `10-30ms` 延迟,可接受;`Leader` 宕则 `broadband` 配额暂不可用(`static` 组员仍直连可用)。
|
|
102
|
+
* 多并发复用单 `WS` 需 `reqId` 复用与背压控制,首版可限并发 3(复用 `PEER_RACE_LIMIT`)。
|
|
103
|
+
* 不设 `--relay` 别名,术语统一为 `broadband`,内部变量 `relay` 仅作实现名。
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Domain Docs
|
|
2
|
+
|
|
3
|
+
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
|
4
|
+
|
|
5
|
+
## Before exploring, read these
|
|
6
|
+
|
|
7
|
+
- **`CONTEXT.md`** at the repo root, or
|
|
8
|
+
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
|
9
|
+
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
|
10
|
+
|
|
11
|
+
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
|
12
|
+
|
|
13
|
+
## File structure
|
|
14
|
+
|
|
15
|
+
Single-context repo (most repos):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/
|
|
19
|
+
├── CONTEXT.md
|
|
20
|
+
├── docs/adr/
|
|
21
|
+
│ ├── 0001-event-sourced-orders.md
|
|
22
|
+
│ └── 0002-postgres-for-write-model.md
|
|
23
|
+
└── src/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
/
|
|
30
|
+
├── CONTEXT-MAP.md
|
|
31
|
+
├── docs/adr/ ← system-wide decisions
|
|
32
|
+
└── src/
|
|
33
|
+
├── ordering/
|
|
34
|
+
│ ├── CONTEXT.md
|
|
35
|
+
│ └── docs/adr/ ← context-specific decisions
|
|
36
|
+
└── billing/
|
|
37
|
+
├── CONTEXT.md
|
|
38
|
+
└── docs/adr/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Use the glossary's vocabulary
|
|
42
|
+
|
|
43
|
+
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
|
44
|
+
|
|
45
|
+
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
|
46
|
+
|
|
47
|
+
## Flag ADR conflicts
|
|
48
|
+
|
|
49
|
+
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
|
50
|
+
|
|
51
|
+
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|