dsh-auto-model-routing 0.4.1 → 0.4.2

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/lib/client.js CHANGED
@@ -140,6 +140,7 @@ window.__ModuleLoader__.load({
140
140
  const [view, setView] = (0, react.useState)(void 0);
141
141
  const [open, setOpen] = (0, react.useState)(false);
142
142
  const [error, setError] = (0, react.useState)("");
143
+ const exposureTarget = (0, react.useRef)(null);
143
144
  const alive = (0, react.useRef)(true);
144
145
  (0, react.useEffect)(() => () => {
145
146
  alive.current = false;
@@ -158,6 +159,44 @@ window.__ModuleLoader__.load({
158
159
  sessionId,
159
160
  messageId
160
161
  ]);
162
+ (0, react.useEffect)(() => {
163
+ const target = exposureTarget.current;
164
+ if (view?.policy !== "policy-v2" || !target || typeof IntersectionObserver === "undefined") return;
165
+ let visible = false, sent = false;
166
+ let timer;
167
+ const update = () => {
168
+ clearTimeout(timer);
169
+ timer = void 0;
170
+ if (!sent && visible && document.visibilityState === "visible" && document.hasFocus()) timer = setTimeout(() => {
171
+ if (!visible || document.visibilityState !== "visible" || !document.hasFocus()) return;
172
+ sent = true;
173
+ rpc.call("/api", "auto-model-routing.observe", {
174
+ sessionId,
175
+ messageId
176
+ }).catch(() => {});
177
+ }, 3e3);
178
+ };
179
+ const observer = new IntersectionObserver((entries) => {
180
+ visible = entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= 1);
181
+ update();
182
+ }, { threshold: 1 });
183
+ observer.observe(target);
184
+ document.addEventListener("visibilitychange", update);
185
+ window.addEventListener("focus", update);
186
+ window.addEventListener("blur", update);
187
+ return () => {
188
+ clearTimeout(timer);
189
+ observer.disconnect();
190
+ document.removeEventListener("visibilitychange", update);
191
+ window.removeEventListener("focus", update);
192
+ window.removeEventListener("blur", update);
193
+ };
194
+ }, [
195
+ view?.policy,
196
+ rpc,
197
+ sessionId,
198
+ messageId
199
+ ]);
161
200
  if (!view) return null;
162
201
  const choose = (label) => {
163
202
  setOpen(false);
@@ -181,7 +220,7 @@ window.__ModuleLoader__.load({
181
220
  "aria-label": tooltipText(view),
182
221
  onClick: () => setOpen((value) => !value)
183
222
  }, (0, react.createElement)(ui.Icon));
184
- return (0, react.createElement)("span", null, (0, react.createElement)("style", null, CSS$3), (0, react.createElement)(ui.Menu, {
223
+ return (0, react.createElement)("span", { ref: exposureTarget }, (0, react.createElement)("style", null, CSS$3), (0, react.createElement)(ui.Menu, {
185
224
  open,
186
225
  side: "top",
187
226
  portal: true,
@@ -1083,6 +1122,7 @@ window.__ModuleLoader__.load({
1083
1122
  // src/client/index.ts
1084
1123
  const name = "dsh-auto-model-routing/client";
1085
1124
  const inject = ["slots", "connection"];
1125
+ const REPO_URL = "https://github.com/prgrmrwy/dsh-auto-model-routing";
1086
1126
  const CSS = `
1087
1127
  .amr-root{position:relative;min-width:0;display:inline-flex}
1088
1128
  .amr-trigger{display:flex;align-items:center;gap:4px;height:28px;padding:0 4px 0 8px;border:none;outline:none;background:0 0;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:400;line-height:20px;cursor:pointer;white-space:nowrap}
@@ -1090,6 +1130,11 @@ window.__ModuleLoader__.load({
1090
1130
  .amr-trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}
1091
1131
  .amr-trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}
1092
1132
  .amr-icon{flex:none;color:var(--dsw-alias-label-caption)}
1133
+ .amr-trigger[data-locked=true]{color:var(--dsw-alias-label-dimmed);cursor:default}
1134
+ .amr-trigger[data-locked=true]:hover{background:0 0}
1135
+ .amr-info{color:var(--dsw-alias-label-caption)}
1136
+ .amr-card a{color:var(--dsw-alias-state-business-primary);text-decoration:none;overflow-wrap:anywhere}
1137
+ .amr-card a:hover{text-decoration:underline}
1093
1138
  .amr-trigger[data-disabled=true],.amr-trigger[data-disabled=true] .amr-icon{color:var(--dsw-alias-state-error-primary)}
1094
1139
  .amr-panel{position:absolute;z-index:1100;bottom:100%;left:0;padding-bottom:6px}
1095
1140
  .amr-card{width:280px;padding:10px 12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg,#fff);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-elevation-prominent);font-size:12px;line-height:18px}
@@ -1101,7 +1146,7 @@ window.__ModuleLoader__.load({
1101
1146
  `;
1102
1147
  function SwapIcon() {
1103
1148
  return (0, react.createElement)("svg", {
1104
- className: "amr-icon",
1149
+ className: "amr-icon amr-swap",
1105
1150
  width: 14,
1106
1151
  height: 14,
1107
1152
  viewBox: "0 0 16 16",
@@ -1113,7 +1158,7 @@ window.__ModuleLoader__.load({
1113
1158
  "aria-hidden": true
1114
1159
  }, (0, react.createElement)("path", { d: "M3 5.5h9.5M10 3l2.5 2.5L10 8" }), (0, react.createElement)("path", { d: "M13 10.5H3.5M6 8l-2.5 2.5L6 13" }));
1115
1160
  }
1116
- function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast }) {
1161
+ function RoutingChip({ sessionId, rpc, initialView, initiallyOpen = false, initialToast, subscribeSelection }) {
1117
1162
  const [view, setView] = (0, react.useState)(initialView);
1118
1163
  const [open, setOpen] = (0, react.useState)(initiallyOpen);
1119
1164
  const [busy, setBusy] = (0, react.useState)(false);
@@ -1152,6 +1197,32 @@ window.__ModuleLoader__.load({
1152
1197
  const hide = setTimeout(() => setToast(void 0), TOAST_MS);
1153
1198
  return () => clearTimeout(hide);
1154
1199
  }, [toast]);
1200
+ const refreshRef = (0, react.useRef)(() => {});
1201
+ const subscription = (0, react.useRef)(void 0);
1202
+ const subscribeRef = (0, react.useRef)(void 0);
1203
+ subscribeRef.current = subscribeSelection;
1204
+ const ensureSubscription = () => {
1205
+ const subscribe = subscribeRef.current;
1206
+ if (!subscribe || subscription.current?.sessionId === sessionId) return;
1207
+ subscription.current?.stop();
1208
+ subscription.current = void 0;
1209
+ let handle;
1210
+ try {
1211
+ const stop = subscribe(sessionId, () => {
1212
+ if (handle) clearTimeout(handle);
1213
+ handle = setTimeout(() => refreshRef.current(), 80);
1214
+ });
1215
+ subscription.current = {
1216
+ sessionId,
1217
+ stop: () => {
1218
+ stop();
1219
+ if (handle) clearTimeout(handle);
1220
+ }
1221
+ };
1222
+ } catch {
1223
+ subscription.current = void 0;
1224
+ }
1225
+ };
1155
1226
  (0, react.useEffect)(() => {
1156
1227
  let active = true;
1157
1228
  primed.current = false;
@@ -1164,17 +1235,22 @@ window.__ModuleLoader__.load({
1164
1235
  viewSession.current = sessionId;
1165
1236
  setView(value);
1166
1237
  setError("");
1238
+ ensureSubscription();
1167
1239
  }
1168
1240
  }, (reason) => {
1169
1241
  if (active) setError(String(reason));
1170
1242
  });
1171
1243
  };
1244
+ refreshRef.current = refresh;
1172
1245
  refresh();
1173
1246
  const poll = setInterval(refresh, 3e3);
1174
1247
  return () => {
1175
1248
  active = false;
1176
1249
  clearInterval(poll);
1177
1250
  clearTimeout(timer.current);
1251
+ subscription.current?.stop();
1252
+ subscription.current = void 0;
1253
+ refreshRef.current = () => {};
1178
1254
  };
1179
1255
  }, [rpc, sessionId]);
1180
1256
  const on = (event) => () => {
@@ -1198,10 +1274,60 @@ window.__ModuleLoader__.load({
1198
1274
  };
1199
1275
  const off = view?.disabled;
1200
1276
  const hint = off ? CATEGORY_HINTS[off.category] ?? off.category : void 0;
1277
+ const locked = view?.autoSupport?.supported === false;
1278
+ const support = view?.autoSupport;
1279
+ const lockedModel = `${support?.provider ?? "未知 provider"} / ${support?.model ?? "未知模型"}`;
1201
1280
  const place = anchor.current ?? {
1202
1281
  left: typeof window === "undefined" ? 0 : window.innerWidth / 2,
1203
1282
  top: (typeof window === "undefined" ? 0 : window.innerHeight) - 96
1204
1283
  };
1284
+ const panel = locked ? [
1285
+ (0, react.createElement)("p", { key: "locked-title" }, (0, react.createElement)("strong", null, "AUTO 不支持当前模型")),
1286
+ (0, react.createElement)("p", { key: "locked-model" }, `${lockedModel} 不在自动路由支持范围,已切换为 MANUAL。`),
1287
+ (0, react.createElement)("p", { key: "locked-note" }, "目前仅支持 Claude / GPT 系列模型;其他模型策略欢迎提交 issue 或 PR:"),
1288
+ (0, react.createElement)("p", { key: "locked-link" }, (0, react.createElement)("a", {
1289
+ href: REPO_URL,
1290
+ target: "_blank",
1291
+ rel: "noopener noreferrer"
1292
+ }, REPO_URL)),
1293
+ (0, react.createElement)("p", {
1294
+ key: "locked-hint",
1295
+ className: "amr-muted"
1296
+ }, "切回 Claude / GPT 系列模型后,可重新点击启用 AUTO。"),
1297
+ error && (0, react.createElement)("p", {
1298
+ key: "error",
1299
+ role: "alert",
1300
+ className: "amr-error"
1301
+ }, error)
1302
+ ] : [
1303
+ (0, react.createElement)("p", { key: "title" }, (0, react.createElement)("strong", null, view?.mode === "AUTO" ? "自动调整推理强度" : "手动:不自动调整")),
1304
+ (0, react.createElement)("p", { key: "output" }, `本回合插件输出:${view?.effort ?? "未改写"} · ${view?.reason ?? "尚无决定"}`),
1305
+ view?.decision && (0, react.createElement)("div", {
1306
+ key: "latest",
1307
+ className: "amr-latest"
1308
+ }, (0, react.createElement)("p", null, (0, react.createElement)("strong", null, "最近决策")), ...toastText(view.decision).map((line, index) => (0, react.createElement)("p", {
1309
+ key: index,
1310
+ className: index ? "amr-muted" : void 0
1311
+ }, line))),
1312
+ (0, react.createElement)("p", {
1313
+ key: "policy",
1314
+ className: "amr-muted"
1315
+ }, view?.policy === "policy-v2" ? `AUTO · ${view.depth ?? "尚无判断"};主模型不变,推理投入按请求应用;子任务自动选配。` : "AUTO 的决定会同步到右侧模型选择;要手动指定 effort,请先切到 MANUAL。"),
1316
+ off && (0, react.createElement)("p", {
1317
+ key: "disabled",
1318
+ role: "status",
1319
+ className: "amr-error"
1320
+ }, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`),
1321
+ (0, react.createElement)("p", {
1322
+ key: "data",
1323
+ className: "amr-muted"
1324
+ }, "AUTO 会把脱敏任务文本发送给设置中所选的决策服务判档。"),
1325
+ error && (0, react.createElement)("p", {
1326
+ key: "error",
1327
+ role: "alert",
1328
+ className: "amr-error"
1329
+ }, error)
1330
+ ];
1205
1331
  return (0, react.createElement)("span", {
1206
1332
  ref: (el) => {
1207
1333
  rootRef.current = el;
@@ -1217,13 +1343,19 @@ window.__ModuleLoader__.load({
1217
1343
  className: "amr-trigger",
1218
1344
  disabled: busy || !view,
1219
1345
  onClick: () => {
1346
+ if (locked) return;
1220
1347
  toggle();
1221
1348
  },
1222
1349
  "data-disabled": off ? "true" : void 0,
1350
+ "data-locked": locked ? "true" : void 0,
1351
+ "aria-disabled": locked ? "true" : void 0,
1223
1352
  style: { border: "none" },
1224
- title: off ? `Jev 判档已停用:${hint}。点击 AUTO 重试` : "点击切换自动推理强度",
1225
- "aria-label": `推理强度模式:${view?.mode ?? "加载中"},点击切换`
1226
- }, (0, react.createElement)("span", null, off ? `${view?.mode} · Jev 停用` : view?.mode ?? "…"), (0, react.createElement)(SwapIcon)), toast && !open && (typeof document === "undefined" ? (0, react.createElement)("div", {
1353
+ title: locked ? "AUTO 仅支持 Claude / GPT 系列模型;悬停查看说明" : off ? `Jev 判档已停用:${hint}。点击 AUTO 重试` : "点击切换自动推理强度",
1354
+ "aria-label": locked ? `推理强度模式:${view?.mode ?? "MANUAL"}(AUTO 不支持当前模型,不可切换)` : `推理强度模式:${view?.mode ?? "加载中"},点击切换`
1355
+ }, (0, react.createElement)("span", null, locked ? view?.mode ?? "MANUAL" : off ? `${view?.mode} · Jev 停用` : view?.mode ?? "…"), locked ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.IconInfoOutlineRegular, {
1356
+ size: 14,
1357
+ className: "amr-icon amr-info"
1358
+ }) : (0, react.createElement)(SwapIcon)), toast && !open && (typeof document === "undefined" ? (0, react.createElement)("div", {
1227
1359
  className: "amr-toast",
1228
1360
  role: "status",
1229
1361
  style: { pointerEvents: "none" }
@@ -1244,16 +1376,7 @@ window.__ModuleLoader__.load({
1244
1376
  }, line))), document.body)), open && (0, react.createElement)("section", {
1245
1377
  className: "amr-panel",
1246
1378
  "aria-label": "自动路由状态"
1247
- }, (0, react.createElement)("div", { className: "amr-card" }, (0, react.createElement)("p", null, (0, react.createElement)("strong", null, view?.mode === "AUTO" ? "自动调整推理强度" : "手动:不自动调整")), (0, react.createElement)("p", null, `本回合插件输出:${view?.effort ?? "未改写"} · ${view?.reason ?? "尚无决定"}`), view?.decision && (0, react.createElement)("div", { className: "amr-latest" }, (0, react.createElement)("p", null, (0, react.createElement)("strong", null, "最近决策")), ...toastText(view.decision).map((line, index) => (0, react.createElement)("p", {
1248
- key: index,
1249
- className: index ? "amr-muted" : void 0
1250
- }, line))), (0, react.createElement)("p", { className: "amr-muted" }, view?.policy === "policy-v2" ? `AUTO · ${view.depth ?? "尚无判断"};主模型不变,推理投入按请求应用;子任务自动选配。` : "AUTO 的决定会同步到右侧模型选择;要手动指定 effort,请先切到 MANUAL。"), off && (0, react.createElement)("p", {
1251
- role: "status",
1252
- className: "amr-error"
1253
- }, `Jev 停用(${off.category}):${hint}。${new Date(off.at).toLocaleString()}。点击 AUTO 重试。`), (0, react.createElement)("p", { className: "amr-muted" }, "AUTO 会把脱敏任务文本发送给设置中所选的决策服务判档。"), error && (0, react.createElement)("p", {
1254
- role: "alert",
1255
- className: "amr-error"
1256
- }, error))));
1379
+ }, (0, react.createElement)("div", { className: "amr-card" }, ...panel)));
1257
1380
  }
1258
1381
  const labelUi = {
1259
1382
  Tooltip: _deepseek_ai_dsh_client_ui_primitives.Tooltip,
@@ -1261,6 +1384,25 @@ window.__ModuleLoader__.load({
1261
1384
  Icon: _deepseek_ai_dsh_client_ui_primitives.IconGaugeOutlineRegular
1262
1385
  };
1263
1386
  function apply(ctx) {
1387
+ const pickerSubscriber = { current: void 0 };
1388
+ const subscribeSelection = (id, onChange) => pickerSubscriber.current ? pickerSubscriber.current(id, onChange) : () => {};
1389
+ try {
1390
+ const inject = ctx.inject;
1391
+ if (typeof inject === "function") inject.call(ctx, ["modelDirectories"], (scope) => {
1392
+ const service = scope.modelDirectories;
1393
+ if (!service) return;
1394
+ pickerSubscriber.current = (id, onChange) => {
1395
+ try {
1396
+ return service.directoryFor(id).store.subscribe(onChange);
1397
+ } catch {
1398
+ return () => {};
1399
+ }
1400
+ };
1401
+ return () => {
1402
+ pickerSubscriber.current = void 0;
1403
+ };
1404
+ });
1405
+ } catch {}
1264
1406
  ctx.slots.inject("settings.section", () => ctx.slots.register({
1265
1407
  name: "settings.section",
1266
1408
  id: "auto-model-routing",
@@ -1288,7 +1430,8 @@ window.__ModuleLoader__.load({
1288
1430
  order: -10,
1289
1431
  inject: (sessionId) => ({
1290
1432
  sessionId,
1291
- rpc: ctx.connection.rpc
1433
+ rpc: ctx.connection.rpc,
1434
+ subscribeSelection
1292
1435
  })
1293
1436
  }, RoutingChip));
1294
1437
  }
@@ -4,6 +4,7 @@ import type { RoutingState } from './state.js';
4
4
  import { type Tier } from './effort.js';
5
5
  import type { TurnRecord } from './judgements.js';
6
6
  export declare function endpoint(operation: 'state' | 'setMode', body: unknown, resolveAgent: (id: string) => Promise<AgentRef | undefined>, router: Router, state: RoutingState, toastOn?: () => boolean): Promise<Response>;
7
+ export declare function observeEndpoint(body: unknown, resolveAgent: (id: string) => Promise<AgentRef | undefined>, book: FeedbackBook, warn: () => void): Promise<Response>;
7
8
  export interface LabelBook {
8
9
  lookup(sessionId: string, messageId: string): TurnRecord | undefined;
9
10
  label(sessionId: string, turn: number, label: Tier): void;
package/lib/endpoints.js CHANGED
@@ -15,6 +15,25 @@ export async function endpoint(operation, body, resolveAgent, router, state, toa
15
15
  const decision = router.judgement(sessionId);
16
16
  return Response.json({ ...state.read(sessionId), ...router.status(agent), ...(decision ? { decision } : {}), ...(toastOn() ? { decisionToast: true } : {}) });
17
17
  }
18
+ export async function observeEndpoint(body, resolveAgent, book, warn) {
19
+ if (typeof body !== 'object' || body === null)
20
+ return Response.json({ error: 'invalid request' }, { status: 400 });
21
+ const { sessionId, messageId } = body;
22
+ if (typeof sessionId !== 'string' || !sessionId || typeof messageId !== 'string' || !messageId)
23
+ return Response.json({ error: 'invalid request' }, { status: 400 });
24
+ const agent = await resolveAgent(sessionId);
25
+ if (!agent || agent.origin === 'subagent')
26
+ return Response.json({ error: 'unknown ordinary session' }, { status: 404 });
27
+ const binding = book.lookup(sessionId, messageId);
28
+ if (binding)
29
+ try {
30
+ book.fact(sessionId, binding.decisionId, 'exposed');
31
+ }
32
+ catch {
33
+ warn();
34
+ }
35
+ return Response.json({});
36
+ }
18
37
  /** Query (no `label`) or label the judged turn a reply belongs to (design D13). */
19
38
  export async function labelEndpoint(body, resolveAgent, book, feedbackBook) {
20
39
  if (typeof body !== 'object' || body === null)
package/lib/feedback.d.ts CHANGED
@@ -30,11 +30,24 @@ declare const schema: z.ZodObject<{
30
30
  }>>>;
31
31
  feedbackAt: z.ZodOptional<z.ZodNumber>;
32
32
  }, z.core.$strict>;
33
+ declare const factEvent: z.ZodObject<{
34
+ type: z.ZodLiteral<"fact">;
35
+ version: z.ZodLiteral<1>;
36
+ sessionId: z.ZodString;
37
+ decisionId: z.ZodNumber;
38
+ fact: z.ZodEnum<{
39
+ exposed: "exposed";
40
+ "manual-switch": "manual-switch";
41
+ }>;
42
+ at: z.ZodNumber;
43
+ }, z.core.$strict>;
44
+ export type FeedbackFact = z.infer<typeof factEvent>['fact'];
33
45
  export type FeedbackRecord = z.infer<typeof schema>;
34
46
  /** Immutable decision bindings and separate feedback events; never includes task text or credentials. */
35
47
  export declare class FeedbackBook {
36
48
  private file;
37
49
  private records;
50
+ private factRecords;
38
51
  constructor(file: string);
39
52
  private key;
40
53
  lookup(sessionId: string, messageId: string): {
@@ -90,6 +103,11 @@ export declare class FeedbackBook {
90
103
  }[];
91
104
  bind(record: FeedbackRecord): void;
92
105
  set(sessionId: string, messageId: string, feedback: typeof allocationFeedback[number] | null): void;
106
+ facts(sessionId: string, decisionId: number): {
107
+ exposed?: number;
108
+ "manual-switch"?: number;
109
+ };
110
+ fact(sessionId: string, decisionId: number, fact: FeedbackFact): void;
93
111
  private append;
94
112
  }
95
113
  export {};
package/lib/feedback.js CHANGED
@@ -11,10 +11,12 @@ const snapshot = z.object({
11
11
  }).strict();
12
12
  const schema = z.object({ sessionId: z.string(), messageId: z.string(), decisionId: z.number(), depth: z.string(), model: z.string(), effort: z.string(), snapshot: snapshot.optional(), boundAt: z.number().optional(), feedback: z.enum(allocationFeedback).nullable().optional(), feedbackAt: z.number().optional() }).strict();
13
13
  const feedbackEvent = z.object({ type: z.literal('feedback'), version: z.literal(2), sessionId: z.string(), messageId: z.string(), decisionId: z.number(), at: z.number(), feedback: z.enum(allocationFeedback).nullable() }).strict();
14
+ const factEvent = z.object({ type: z.literal('fact'), version: z.literal(1), sessionId: z.string().min(1), decisionId: z.number(), fact: z.enum(['exposed', 'manual-switch']), at: z.number().finite() }).strict();
14
15
  /** Immutable decision bindings and separate feedback events; never includes task text or credentials. */
15
16
  export class FeedbackBook {
16
17
  file;
17
18
  records = new Map();
19
+ factRecords = new Map();
18
20
  constructor(file) {
19
21
  this.file = file;
20
22
  let text;
@@ -29,6 +31,15 @@ export class FeedbackBook {
29
31
  for (const line of text.split('\n'))
30
32
  try {
31
33
  const raw = JSON.parse(line);
34
+ const fact = factEvent.safeParse(raw);
35
+ if (fact.success) {
36
+ const e = fact.data;
37
+ const key = JSON.stringify([e.sessionId, e.decisionId]);
38
+ const facts = this.factRecords.get(key) ?? {};
39
+ facts[e.fact] ??= e.at;
40
+ this.factRecords.set(key, facts);
41
+ continue;
42
+ }
32
43
  const event = feedbackEvent.safeParse(raw);
33
44
  if (event.success) {
34
45
  const e = event.data;
@@ -63,5 +74,15 @@ export class FeedbackBook {
63
74
  this.append(event);
64
75
  this.records.set(this.key(sessionId, messageId), { ...r, feedback, feedbackAt: event.at });
65
76
  }
77
+ facts(sessionId, decisionId) { return { ...this.factRecords.get(JSON.stringify([sessionId, decisionId])) }; }
78
+ fact(sessionId, decisionId, fact) {
79
+ const key = JSON.stringify([sessionId, decisionId]);
80
+ const facts = this.factRecords.get(key) ?? {};
81
+ if (facts[fact] !== undefined)
82
+ return;
83
+ const event = factEvent.parse({ type: 'fact', version: 1, sessionId, decisionId, fact, at: Date.now() });
84
+ this.append(event);
85
+ this.factRecords.set(key, { ...facts, [fact]: event.at });
86
+ }
66
87
  append(record) { mkdirSync(dirname(this.file), { recursive: true }); appendFileSync(this.file, JSON.stringify(record) + '\n', { mode: 0o600 }); }
67
88
  }
package/lib/index.js CHANGED
@@ -4,7 +4,7 @@ import { settingsHost } from './settings-host.js';
4
4
  import { homedir } from 'node:os';
5
5
  import { FileStateStore, RoutingState } from './state.js';
6
6
  import { Router } from './router.js';
7
- import { endpoint, labelEndpoint } from './endpoints.js';
7
+ import { endpoint, labelEndpoint, observeEndpoint } from './endpoints.js';
8
8
  import { FeedbackBook } from './feedback.js';
9
9
  import { JudgementBook } from './judgements.js';
10
10
  import { userInput } from './input.js';
@@ -13,6 +13,7 @@ import { routeSubagentTool } from './subagent-tool.js';
13
13
  import { buildState, previousTurn } from './context.js';
14
14
  import { policyHost } from './policy-host.js';
15
15
  import { PolicyStore } from './policy-store.js';
16
+ import { currentMainSelection, mainSupported } from './support.js';
16
17
  export const name = 'dsh-auto-model-routing';
17
18
  export const inject = ['llm', 'agents', 'tools'];
18
19
  const knownEfforts = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
@@ -36,15 +37,22 @@ export function apply(ctx, config = {}) {
36
37
  }
37
38
  }, () => settings.read().settings);
38
39
  const feedback = new FeedbackBook(resolve(home, 'state/auto-model-routing/allocation-feedback.jsonl'));
39
- const policy = policyHost(ctx, { feedback, jev, state, store: new PolicyStore(resolve(home, 'state/auto-model-routing/policy-v2')), settings: () => settings.read().settings, catalog: () => hostSettings.catalog(true) });
40
+ // Official writer is available only while the session controller is mounted.
41
+ let selectModel;
42
+ const policy = policyHost(ctx, { feedback, jev, state, selectModel: async (request) => { if (!selectModel)
43
+ throw new Error('session controller is unavailable'); await selectModel(request); }, store: new PolicyStore(resolve(home, 'state/auto-model-routing/policy-v2')), settings: () => settings.read().settings, catalog: () => hostSettings.catalog(true) });
40
44
  const toastOn = () => settings.read().settings.decisionToast;
41
45
  ctx.effect(() => ctx.tools.register(routeSubagentTool(jev, { agents: ctx.agents, state, readSettings: () => settings.read().settings, catalog: hostSettings.catalog, judged: (sessionId, value) => router.judged(sessionId, value) })));
42
46
  ctx.on('agent/created', ({ agent, source }) => {
43
47
  // Factory resume announces `resume`; a nonzero initial seed also excludes restored/forked histories.
44
48
  if (source !== 'startup' || agent.session.header.origin === 'subagent' || agent.session.firstLiveSeq !== 0 || agent.session.header.isSeeded || agent.session.snapshotEvents().some(event => event.type === 'session/end-seed') || stateFiles.get(agent.id))
45
49
  return;
46
- if (settings.read().settings.defaultMode === 'AUTO')
47
- state.setMode(agent.id, 'AUTO');
50
+ if (settings.read().settings.defaultMode !== 'AUTO')
51
+ return;
52
+ // A new session whose default main model is outside AUTO's Claude/GPT scope starts in MANUAL.
53
+ if (!mainSupported(currentMainSelection(ctx, agent)))
54
+ return;
55
+ state.setMode(agent.id, 'AUTO');
48
56
  return undefined;
49
57
  });
50
58
  // Agent is the WeakMap identity; origin stays on the immutable session header.
@@ -89,7 +97,6 @@ export function apply(ctx, config = {}) {
89
97
  book.assistant(session.id, data.message.id, data.turn);
90
98
  });
91
99
  // Official selection writer, set while the session controller is available.
92
- let selectModel;
93
100
  const inflight = new Map();
94
101
  ctx.on('agent/request', async (payload, next) => {
95
102
  const config = await next();
@@ -119,7 +126,7 @@ export function apply(ctx, config = {}) {
119
126
  child.effect(() => () => { selectModel = undefined; });
120
127
  });
121
128
  ctx.inject(['connection', 'sessionController'], child => {
122
- for (const operation of ['state', 'setMode', 'label', 'settings', 'saveSettings', 'setKey', 'testDecision', 'childRouting']) {
129
+ for (const operation of ['state', 'setMode', 'label', 'observe', 'settings', 'saveSettings', 'setKey', 'testDecision', 'childRouting']) {
123
130
  child.effect(() => child.connection.fetch.register({
124
131
  path: `/api/auto-model-routing.${operation}`,
125
132
  methods: ['POST'], requestBody: 'buffered',
@@ -139,12 +146,21 @@ export function apply(ctx, config = {}) {
139
146
  const result = await child.sessionController.resolveAgent(id);
140
147
  return 'agent' in result ? ref(result.agent) : undefined;
141
148
  };
149
+ const resolveSessionAgent = async (id) => {
150
+ const result = await child.sessionController.resolveAgent(id);
151
+ return 'agent' in result ? result.agent : undefined;
152
+ };
142
153
  const payload = enveloped ? wire.payload : body;
154
+ let manualDecision;
143
155
  if (operation === 'setMode' && typeof payload?.sessionId === 'string') {
144
156
  const id = payload.sessionId;
145
157
  const mode = payload.mode;
146
- if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id))
158
+ if ((mode === 'AUTO' || mode === 'MANUAL') && state.read(id).mode !== mode && await resolveAgent(id)) {
159
+ const decisionId = policy.appliedDecision(id);
160
+ if (mode === 'MANUAL' && decisionId !== undefined)
161
+ manualDecision = { sessionId: id, decisionId };
147
162
  policy.invalidate(id);
163
+ }
148
164
  }
149
165
  const response = operation === 'childRouting'
150
166
  ? (typeof payload?.sessionId === 'string'
@@ -152,12 +168,30 @@ export function apply(ctx, config = {}) {
152
168
  : Response.json({ error: 'invalid request' }, { status: 400 }))
153
169
  : operation === 'settings' || operation === 'saveSettings' || operation === 'setKey' || operation === 'testDecision'
154
170
  ? await hostSettings.endpoint(operation, payload)
155
- : operation === 'label' ? await labelEndpoint(payload, resolveAgent, book, feedback) : await endpoint(operation, payload, resolveAgent, router, state, toastOn);
171
+ : operation === 'observe' ? await observeEndpoint(payload, resolveAgent, feedback, () => ctx.logger.warn('auto-model-routing: could not record exposure'))
172
+ : operation === 'label' ? await labelEndpoint(payload, resolveAgent, book, feedback) : await endpoint(operation, payload, resolveAgent, router, state, toastOn);
173
+ if (manualDecision && response.ok) {
174
+ try {
175
+ feedback.fact(manualDecision.sessionId, manualDecision.decisionId, 'manual-switch');
176
+ }
177
+ catch {
178
+ ctx.logger.warn('auto-model-routing: could not record MANUAL switch');
179
+ }
180
+ }
156
181
  if ((operation === 'state' || operation === 'setMode') && response.ok && settings.read().settings.policy === 'policy-v2') {
182
+ const sessionId = payload.sessionId;
183
+ const agent = await resolveSessionAgent(sessionId).catch(() => undefined);
184
+ const selection = agent ? currentMainSelection(ctx, agent) : undefined;
185
+ const supported = mainSupported(selection);
186
+ // Leaving AUTO is persisted: a pick outside the Claude/GPT scope must not keep judging turns.
187
+ if (!supported && state.read(sessionId).mode === 'AUTO') {
188
+ state.setMode(sessionId, 'MANUAL');
189
+ policy.invalidate(sessionId);
190
+ }
157
191
  const value = await response.json();
158
- const main = policy.mainStatus(payload.sessionId);
192
+ const main = policy.mainStatus(sessionId);
159
193
  const { decision: _legacyDecision, effort: _legacyEffort, reason: _legacyReason, history: _legacyHistory, ...base } = value;
160
- const result = { ...base, history: [], policy: 'policy-v2', ...main };
194
+ const result = { ...base, mode: state.read(sessionId).mode, history: [], policy: 'policy-v2', ...main, autoSupport: { supported, ...(selection ? { provider: selection.provider, model: selection.model } : {}) } };
161
195
  return Response.json(enveloped ? { type: 'server-response', rpcId: wire.rpcId, result: { ok: true, value: result } } : result);
162
196
  }
163
197
  if (!enveloped)
@@ -5,6 +5,12 @@ import { PolicyStore, type ChildDecision } from './policy-store.js';
5
5
  import type { RoutingState } from './state.js';
6
6
  import type { RoutingCatalog, Settings } from './settings-contract.js';
7
7
  export declare function policyHost(ctx: Context, deps: {
8
+ selectModel: (request: {
9
+ sessionId: string;
10
+ provider: string;
11
+ model: string;
12
+ reasoningEffort: string;
13
+ }) => Promise<unknown>;
8
14
  feedback: FeedbackBook;
9
15
  jev: JevClient;
10
16
  state: RoutingState;
@@ -12,6 +18,7 @@ export declare function policyHost(ctx: Context, deps: {
12
18
  settings: () => Settings;
13
19
  catalog: () => Promise<RoutingCatalog>;
14
20
  }): {
21
+ appliedDecision: (id: string) => number | undefined;
15
22
  invalidate: (id: string) => void;
16
23
  status: (id: string) => {
17
24
  version: 1;
@@ -23,7 +30,7 @@ export declare function policyHost(ctx: Context, deps: {
23
30
  status: "failed" | "recommended" | "unsupported" | "needs-context" | "applied";
24
31
  path: "ordinary" | "fork" | "teammate";
25
32
  explicit: {
26
- provider: "claude" | "codex" | "dsh-magpie-connect";
33
+ provider: "claude" | "codex" | "dsh-magpie-connect" | "dsh-magpie-connector";
27
34
  model?: string | undefined;
28
35
  reasoningEffort?: string | undefined;
29
36
  };
@@ -47,12 +54,12 @@ export declare function policyHost(ctx: Context, deps: {
47
54
  capabilityProbabilities?: Record<string, number> | undefined;
48
55
  } | undefined;
49
56
  route?: {
50
- provider: "claude" | "codex" | "dsh-magpie-connect";
57
+ provider: "claude" | "codex" | "dsh-magpie-connect" | "dsh-magpie-connector";
51
58
  model: string;
52
59
  reasoningEffort: "low" | "medium" | "high" | "xhigh";
53
60
  } | undefined;
54
61
  actual?: {
55
- provider: "claude" | "codex" | "dsh-magpie-connect";
62
+ provider: "claude" | "codex" | "dsh-magpie-connect" | "dsh-magpie-connector";
56
63
  model: string;
57
64
  reasoningEffort: "low" | "medium" | "high" | "xhigh";
58
65
  } | undefined;