@jack200714/mafw 4.8.0 → 4.10.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.
Files changed (42) hide show
  1. package/README.md +27 -3
  2. package/gateway/dist/core/manager/goal-snapshot.js +2 -2
  3. package/gateway/dist/core/manager/manager-session-runtime.js +59 -0
  4. package/gateway/dist/core/manager/milestone-push.js +25 -10
  5. package/gateway/dist/index.js +465 -440
  6. package/gateway/dist/media/media-plugin-loader.js +25 -1
  7. package/gateway/dist/media/resolve-prompt.js +20 -0
  8. package/gateway/dist/memory/gateway-db.js +23 -0
  9. package/gateway/dist/opencode-adapter.js +53 -4
  10. package/gateway/dist/plugins/package-context.js +24 -0
  11. package/gateway/dist/plugins/package-host.js +331 -0
  12. package/gateway/dist/plugins/package-types.js +2 -0
  13. package/gateway/dist/recall/gateway-db-migrate.js +5 -2
  14. package/gateway/dist/recall/redact.js +53 -0
  15. package/gateway/dist/recall/turn-pipeline.js +2 -0
  16. package/gateway/dist/routes/event-publish.js +44 -0
  17. package/gateway/dist/routes/plugins.js +4 -1
  18. package/gateway/dist/routes/registry.js +125 -0
  19. package/gateway/dist/routes/route-catalog.js +192 -0
  20. package/gateway/dist/routes/triage-dismiss.js +32 -0
  21. package/gateway/dist/routes/waitwhat-command.js +43 -0
  22. package/gateway/dist/routes/wave1-handlers.js +62 -0
  23. package/gateway/dist/routes/wave2-handlers.js +47 -0
  24. package/gateway/dist/runtime/contract.js +4 -1
  25. package/gateway/dist/runtime/event-broadcast.js +8 -0
  26. package/gateway/dist/runtime/loader.js +22 -1
  27. package/gateway/dist/runtime/normalize.js +13 -0
  28. package/gateway/dist/runtime/opencode-runtime.js +18 -2
  29. package/gateway/dist/runtime/pi/pi-approval-bridge.js +12 -2
  30. package/gateway/dist/runtime/pi/pi-approval-extension.js +11 -3
  31. package/gateway/dist/runtime/pi/pi-session.js +21 -3
  32. package/gateway/dist/runtime/plugins/pi-runtime.js +6 -3
  33. package/gateway/dist/runtime/serve-sidecar.js +4 -1
  34. package/gateway/dist/runtime/serve-supervisor.js +19 -20
  35. package/gateway/dist/runtime/validate.js +39 -0
  36. package/gateway/dist/skills/manager-identity.js +6 -1
  37. package/gateway/dist/usage/builtin-plugins/gateway.js +91 -36
  38. package/gateway/dist/usage/plugin-context.js +42 -2
  39. package/gateway/dist/usage/plugin-loader.js +32 -2
  40. package/gateway/package.json +2 -1
  41. package/package.json +3 -1
  42. package/packages/tui/dist/cli.js +114 -51
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMalformedEvent = isMalformedEvent;
3
4
  exports.normalizeOpencodeEvent = normalizeOpencodeEvent;
4
5
  /**
5
6
  * 事件归一化器 —— 把 runtime 原生事件翻译成 EventFacets(正交切面)。
@@ -13,6 +14,18 @@ exports.normalizeOpencodeEvent = normalizeOpencodeEvent;
13
14
  * 与现 handleOpencodeEvent 的多路消费行为逐点等价。
14
15
  */
15
16
  const step_inject_1 = require("../recall/step-inject");
17
+ /**
18
+ * 畸形事件判定:类型与属性全空 = 归一化后无任何可消费信息。
19
+ * handleOpencodeEvent 入口据此做限频 warn——runtime 插件发坏事件时
20
+ * 给出可定位诊断,而不是静默穿过下发到桌面/TUI。
21
+ */
22
+ function isMalformedEvent(evt) {
23
+ if (!evt || typeof evt !== 'object')
24
+ return true;
25
+ const type = evt.payload?.type || evt.type;
26
+ const props = evt.payload?.properties || evt.properties;
27
+ return !type && (!props || (typeof props === 'object' && Object.keys(props).length === 0));
28
+ }
16
29
  function normalizeOpencodeEvent(evt) {
17
30
  const payload = evt?.payload || {};
18
31
  const type = payload?.type || evt?.type || '';
@@ -259,7 +259,9 @@ async function createOpencodeRuntime(config) {
259
259
  },
260
260
  },
261
261
  getBaseUrl() {
262
- return process.env.MAFW_SERVER_SERVE_URL || config_1.config.server.serveUrl;
262
+ // 地址归 runtime 所有:bootstrap 取配置,spawnServe 吸收 sidecar 实际
263
+ // URL 后(含动态端口),gateway 一律经此读取,不直连配置。
264
+ return process.env.MAFW_SERVER_SERVE_URL || config.baseUrl;
263
265
  },
264
266
  async healthCheck() {
265
267
  try {
@@ -325,7 +327,21 @@ async function createOpencodeRuntime(config) {
325
327
  // Gateway core never hardcodes agent-specific spawn details.
326
328
  if (!rt.external) {
327
329
  rt.agentProcess = {
328
- spawnServe: (opts) => (0, serve_sidecar_1.startServeSidecar)(opts),
330
+ spawnServe: async (opts) => {
331
+ // host/port 缺省由 runtime 自定(serve 端口是实现细节,gateway 不传)
332
+ const sidecar = await (0, serve_sidecar_1.startServeSidecar)({
333
+ host: opts.host ?? config_1.config.server.serveHost ?? '127.0.0.1',
334
+ port: opts.port ?? config_1.config.server.servePort,
335
+ timeoutMs: opts.timeoutMs,
336
+ onOutput: opts.onOutput,
337
+ onExit: opts.onExit,
338
+ });
339
+ // 吸收 sidecar 报告的实际地址(可能与请求端口不同,如动态分配):
340
+ // 之后 getBaseUrl/healthCheck/adapter 请求全部跟随实际 URL。
341
+ config.baseUrl = sidecar.url;
342
+ return sidecar;
343
+ },
344
+ killServe: () => (0, serve_sidecar_1.killServePort)(config_1.config.server.servePort),
329
345
  async restart() {
330
346
  logger_1.log.info('[Runtime] agentProcess.restart() — killing serve (gateway orchestrates respawn)');
331
347
  (0, serve_sidecar_1.killServePort)(config_1.config.server.servePort);
@@ -8,15 +8,25 @@ class ApprovalBridge {
8
8
  constructor(timeoutMs = 300_000) {
9
9
  this.timeoutMs = timeoutMs;
10
10
  }
11
- request(requestId) {
11
+ request(requestId, meta) {
12
12
  return new Promise((resolve) => {
13
13
  const timeout = setTimeout(() => {
14
14
  this.pending.delete(requestId);
15
15
  resolve(false);
16
16
  }, this.timeoutMs);
17
- this.pending.set(requestId, { resolve, timeout });
17
+ this.pending.set(requestId, { resolve, timeout, meta });
18
18
  });
19
19
  }
20
+ /** 待审批请求列表(gateway session.permissionList 契约的 pi 实现)。 */
21
+ listPending() {
22
+ return [...this.pending.entries()].map(([id, req]) => ({
23
+ id,
24
+ sessionID: req.meta?.sessionID,
25
+ permission: req.meta?.permission,
26
+ patterns: req.meta?.patterns,
27
+ metadata: req.meta?.metadata,
28
+ }));
29
+ }
20
30
  /** 三值回复;兼容旧 boolean 调用(true→once / false→reject)。 */
21
31
  reply(requestId, decision, message) {
22
32
  const req = this.pending.get(requestId);
@@ -6,7 +6,7 @@ const DEFAULT_POLICY = {
6
6
  autoApprove: ['read', 'grep', 'ls', 'find', 'glob'],
7
7
  autoDeny: [],
8
8
  };
9
- function createMafwApprovalExtension(bridge, emitEvent, policy = DEFAULT_POLICY) {
9
+ function createMafwApprovalExtension(bridge, emitEvent, policy = DEFAULT_POLICY, gatewaySessionId) {
10
10
  return {
11
11
  name: 'mafw-approval',
12
12
  on: (emitter) => {
@@ -19,7 +19,9 @@ function createMafwApprovalExtension(bridge, emitEvent, policy = DEFAULT_POLICY)
19
19
  return { block: true, reason: 'auto-denied by policy' };
20
20
  }
21
21
  const requestId = (0, crypto_1.randomUUID)();
22
- const sessionID = ctx.sessionId;
22
+ // pi 原生 ctx.sessionId 与 gateway 生成的 pi_* 注册 id 不同——事件
23
+ // 必须携带 gateway id,否则 permissionReply 按 id 查 bridge 404。
24
+ const sessionID = gatewaySessionId;
23
25
  emitEvent({
24
26
  payload: {
25
27
  type: 'permission.asked',
@@ -32,7 +34,13 @@ function createMafwApprovalExtension(bridge, emitEvent, policy = DEFAULT_POLICY)
32
34
  },
33
35
  },
34
36
  });
35
- const approved = await bridge.request(requestId);
37
+ // permissionList 元数据:PendingMeta permission.asked 事件同源
38
+ const approved = await bridge.request(requestId, {
39
+ sessionID,
40
+ permission: toolName,
41
+ patterns: [],
42
+ metadata: { args: event.input, risk: 'medium' },
43
+ });
36
44
  const record = bridge.lastDecision(requestId);
37
45
  const decision = record?.decision ?? (approved ? 'once' : 'reject');
38
46
  const message = record?.message;
@@ -25,7 +25,12 @@ class PiSessionRegistry {
25
25
  const id = `pi_${(0, crypto_1.randomUUID)().slice(0, 8)}`;
26
26
  const bridge = new pi_approval_bridge_1.ApprovalBridge();
27
27
  this.approvalBridges.set(id, bridge);
28
- const approvalExtension = (0, pi_approval_extension_1.createMafwApprovalExtension)(bridge, this.emitEvent, this.policy);
28
+ // 'always' allowlist 必须是会话私有副本:共享引用会把一次 "always" 扩散到
29
+ // 全部 pi 会话(契约语义是 session 级动态 allowlist)。
30
+ const sessionPolicy = this.policy
31
+ ? { autoApprove: [...(this.policy.autoApprove ?? [])], autoDeny: [...(this.policy.autoDeny ?? [])] }
32
+ : undefined;
33
+ const approvalExtension = (0, pi_approval_extension_1.createMafwApprovalExtension)(bridge, this.emitEvent, sessionPolicy, id);
29
34
  // Compaction listener: pi fires session_before_compact / session_compact to
30
35
  // extensions; re-emit as normalized runtime events (compaction facet).
31
36
  const compactionExtension = this.makeCompactionExtension(id);
@@ -219,6 +224,16 @@ class PiSessionRegistry {
219
224
  return false;
220
225
  return bridge.reply(requestId, reply, message);
221
226
  }
227
+ /** 全部 session 的待审批请求(ApprovalBridge pending map 汇总)。 */
228
+ listPendingPermissions() {
229
+ const out = [];
230
+ for (const [sessionID, bridge] of this.approvalBridges) {
231
+ for (const item of bridge.listPending()) {
232
+ out.push({ ...item, sessionID: item.sessionID ?? sessionID });
233
+ }
234
+ }
235
+ return out;
236
+ }
222
237
  async abort(id) {
223
238
  const s = this.sessions.get(id);
224
239
  if (s) {
@@ -244,9 +259,12 @@ class PiSessionRegistry {
244
259
  const file = sm.createBranchedSession(leafId);
245
260
  if (!file)
246
261
  throw new Error(`pi fork failed: createBranchedSession returned nothing for leaf ${leafId}`);
247
- const bridge = new pi_approval_bridge_1.ApprovalBridge();
248
- const approvalExtension = (0, pi_approval_extension_1.createMafwApprovalExtension)(bridge, this.emitEvent, this.policy);
249
262
  const newId = `pi_${(0, crypto_1.randomUUID)().slice(0, 8)}`;
263
+ const bridge = new pi_approval_bridge_1.ApprovalBridge();
264
+ const forkPolicy = this.policy
265
+ ? { autoApprove: [...(this.policy.autoApprove ?? [])], autoDeny: [...(this.policy.autoDeny ?? [])] }
266
+ : undefined;
267
+ const approvalExtension = (0, pi_approval_extension_1.createMafwApprovalExtension)(bridge, this.emitEvent, forkPolicy, newId);
250
268
  const compactionExtension = this.makeCompactionExtension(newId);
251
269
  const mediaExtension = this.makeMediaExtension(newId);
252
270
  try {
@@ -123,7 +123,9 @@ async function createPiRuntime(ctx, deps = {}) {
123
123
  }
124
124
  return mrPromise;
125
125
  }
126
- const eventStream = new pi_events_1.PiEventStream((session) => undefined);
126
+ // Resolver maps a live pi AgentSession to its registry id — without it the
127
+ // event stream drops EVERY pi event (no session.idle / step-finish / deltas).
128
+ const eventStream = new pi_events_1.PiEventStream((session) => registry.sessionIdFor(session));
127
129
  const registry = new pi_session_1.PiSessionRegistry({
128
130
  createSession: async (opts) => {
129
131
  const pi = await imp('@earendil-works/pi-coding-agent');
@@ -162,7 +164,7 @@ async function createPiRuntime(ctx, deps = {}) {
162
164
  }, { sessionTtlMs: cfg.sessionTtlMs, emitEvent: (evt) => eventStream.push(evt), policy: cfg.approvalPolicy });
163
165
  const sessionAPI = {
164
166
  create: async (opts) => {
165
- const { id } = await registry.create(opts?.directory ?? config_1.config.raw.paths.projectDir, { model: { provider, modelID } });
167
+ const { id } = await registry.create(opts?.directory ?? ctx.projectDir ?? config_1.config.raw.paths.projectDir, { model: { provider, modelID } });
166
168
  return { id };
167
169
  },
168
170
  promptAsync: async (opts) => {
@@ -194,6 +196,7 @@ async function createPiRuntime(ctx, deps = {}) {
194
196
  return (0, pi_session_storage_1.listByDirectory)(directory, limit);
195
197
  },
196
198
  permissionReply: (sessionID, requestId, reply, message) => registry.permissionReply(sessionID, requestId, reply, message),
199
+ permissionList: async () => registry.listPendingPermissions(),
197
200
  fork: async (opts) => registry.fork(opts.sessionID, opts.messageID),
198
201
  revert: async (opts) => registry.revert(opts.sessionID, opts.messageID),
199
202
  };
@@ -269,7 +272,7 @@ async function createPiRuntime(ctx, deps = {}) {
269
272
  },
270
273
  },
271
274
  registry,
272
- getBaseUrl: () => `http://127.0.0.1:${config_1.config.server.apiPort ?? 3000}`,
275
+ getBaseUrl: () => `http://127.0.0.1:${ctx.gatewayPort ?? config_1.config.server.apiPort ?? 3000}`,
273
276
  healthCheck: async () => { try {
274
277
  await modelRuntime();
275
278
  return true;
@@ -61,7 +61,10 @@ function killServePort(port) {
61
61
  try {
62
62
  if (process.platform === 'win32') {
63
63
  const out = childProcess.execSync(`netstat -ano | findstr :${port}`, { windowsHide: true }).toString();
64
- const match = out.match(/LISTENING\s+(\d+)/);
64
+ // findstr ":4096" also matches ":40960" etc. — require the exact local
65
+ // port followed by whitespace on a LISTENING line before trusting the PID.
66
+ const line = out.split(/\r?\n/).find((l) => new RegExp(`:${port}\\s`).test(l) && /LISTENING/i.test(l));
67
+ const match = line ? line.match(/LISTENING\s+(\d+)/) : null;
65
68
  const pid = match ? Number(match[1]) : null;
66
69
  if (pid)
67
70
  process.kill(pid);
@@ -1,42 +1,43 @@
1
1
  "use strict";
2
2
  /**
3
- * Serve 进程唯一所有者:kill / spawn / 就绪等待 / 健康探测。
3
+ * Serve 进程唯一编排者:adopt / kill / spawn / 就绪等待。
4
4
  * 从 index.ts 下沉(原 killProcessOnPort + startServe + isServeHealthy 的动作段)。
5
- * kill spawn 都是 runtime 契约原语(killServePort / agentProcess.spawnServe),
6
- * 本模块只做通用编排;gateway 编排层(recoverServe/watchdog)在其上叠加事件流重订。
7
- * deps 全部注入以便单测;生产装配见 index.ts。
5
+ * 原则:gateway 只编排,不实现——健康检测经 runtime 契约(health())、
6
+ * 地址归 runtime(baseUrl())、kill/spawn 都是 runtime 原语,gateway 不传
7
+ * host/port(serve 端口是 runtime 实现细节)。deps 全部注入以便单测。
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.createServeSupervisor = createServeSupervisor;
11
11
  function createServeSupervisor(deps) {
12
- const host = deps.host ?? '127.0.0.1';
13
12
  const probeIntervalMs = deps.probeIntervalMs ?? 500;
14
13
  const probeTimeoutMs = deps.probeTimeoutMs ?? 60_000;
15
14
  let instance;
16
15
  let starting;
17
- const refused = () => new Error('external agent process is not managed by the gateway');
16
+ const refused = () => new Error('unmanaged runtime: gateway holds no start/stop primitives');
18
17
  const spawnAndWait = async () => {
19
- instance = await deps.spawn({ host, port: deps.port, timeoutMs: probeTimeoutMs });
18
+ instance = await deps.spawn({ timeoutMs: probeTimeoutMs });
20
19
  const deadline = Date.now() + probeTimeoutMs;
21
20
  while (Date.now() < deadline) {
22
- if (await deps.probe(instance.url))
23
- return instance.url;
21
+ if (await deps.health())
22
+ return deps.baseUrl();
24
23
  await new Promise(r => setTimeout(r, probeIntervalMs));
25
24
  }
26
- throw new Error(`serve did not become healthy within ${probeTimeoutMs}ms`);
25
+ throw new Error(`runtime did not become healthy within ${probeTimeoutMs}ms`);
27
26
  };
28
27
  return {
29
- get owned() { return !deps.external; },
28
+ get owned() { return deps.managed(); },
30
29
  async ensureStarted() {
31
- if (deps.external)
30
+ if (!deps.managed())
32
31
  throw refused();
33
- if (instance && await deps.probe(instance.url))
34
- return instance.url;
32
+ // Adopt:runtime 健康(含用户自管/已有监听)直接收养,绝不 kill——
33
+ // 健康判定来自 runtime 契约,gateway 不构造候选 URL。
34
+ if (await deps.health())
35
+ return deps.baseUrl();
35
36
  if (starting)
36
37
  return starting;
37
38
  starting = (async () => {
38
39
  try {
39
- deps.killPort(deps.port);
40
+ deps.killServe();
40
41
  instance = undefined;
41
42
  return await spawnAndWait();
42
43
  }
@@ -47,13 +48,13 @@ function createServeSupervisor(deps) {
47
48
  return starting;
48
49
  },
49
50
  async restart() {
50
- if (deps.external)
51
+ if (!deps.managed())
51
52
  throw refused();
52
53
  if (starting)
53
54
  return starting;
54
55
  starting = (async () => {
55
56
  try {
56
- deps.killPort(deps.port);
57
+ deps.killServe();
57
58
  instance?.close();
58
59
  instance = undefined;
59
60
  return await spawnAndWait();
@@ -65,9 +66,7 @@ function createServeSupervisor(deps) {
65
66
  return starting;
66
67
  },
67
68
  async health() {
68
- if (!instance)
69
- return false;
70
- return deps.probe(instance.url);
69
+ return deps.health();
71
70
  },
72
71
  close() {
73
72
  try {
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateRuntimeShape = validateRuntimeShape;
4
+ const SESSION_REQUIRED_BY_SESSION_API = ['create', 'promptAsync', 'prompt', 'messages'];
5
+ function isObject(v) {
6
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
7
+ }
8
+ function validateRuntimeShape(rt, source) {
9
+ if (!isObject(rt))
10
+ return [`${source}: runtime must be an object (got ${typeof rt})`];
11
+ const issues = [];
12
+ const caps = rt.capabilities;
13
+ if (typeof rt.name !== 'string' || !rt.name) {
14
+ issues.push(`${source}: runtime.name must be a non-empty string (got ${typeof rt.name})`);
15
+ }
16
+ if (!isObject(caps)) {
17
+ issues.push(`${source}: runtime.capabilities must be an object (got ${typeof caps})`);
18
+ return issues;
19
+ }
20
+ if (caps.sessionApi) {
21
+ if (!isObject(rt.session)) {
22
+ issues.push(`${source}: declares sessionApi but session is missing`);
23
+ }
24
+ else {
25
+ for (const method of SESSION_REQUIRED_BY_SESSION_API) {
26
+ if (typeof rt.session[method] !== 'function') {
27
+ issues.push(`${source}: declares sessionApi but session.${method} is missing (not a function)`);
28
+ }
29
+ }
30
+ }
31
+ }
32
+ if (caps.eventStream) {
33
+ const ev = rt.global;
34
+ if (!isObject(ev) || typeof ev.event !== 'function') {
35
+ issues.push(`${source}: declares eventStream but global.event is missing (not a function)`);
36
+ }
37
+ }
38
+ return issues;
39
+ }
@@ -8,4 +8,9 @@ Constraints:
8
8
  - goal 状态只能从工具查询获得,不许凭记忆回答进度
9
9
  - 用户没有问进度时,不主动汇报中间态,只在完成/失败/被阻塞时发言
10
10
  - 不确定是否为任务时先澄清,创建 goal 前必须复述确认
11
- - 需要重启或自更新 gateway 时,加载 mafw-gateway-restart skill 并按流程执行(改码→build→写令牌→等通知→续跑)`;
11
+ - 需要重启或自更新 gateway 时,加载 mafw-gateway-restart skill 并按流程执行(改码→build→写令牌→等通知→续跑)
12
+ Goal charter 写法(mafw_create_goal / mafw_set_goal 的 charter,超出单轮能做的 goal 适用):
13
+ - charter 是一张地图而非仓库,分五段:## Destination(终点长什么样,一两行,一切条目向它对齐)/ ## Plan(执行计划)/ ## Decisions so far(决策索引:一行 gist + 记忆 id,绝不复制详情)/ ## Not yet specified(已知的未知:还不能精确陈述、暂不成条目的问题;frontier 推进后毕业为正式条目并从此段清除)/ ## Out of scope(明确排除项,防范围蔓延;排除是划界动作,不计入路线)
14
+ - 每个决策用 mafw_add_memory(semantic,cueAnchors 带 goalId 与主题实体)记录,charter 与 state 只存指针 id——记忆可跨会话检索,state 只是索引
15
+ - 给用户看的列表与汇报里按名引用(用标题),不用裸 goalId 刷屏;id 放在名字后的括号里
16
+ - 一个 decision 没弄清楚前不要急着进 EXECUTING;规划阶段的产出是决策,不是交付物`;
@@ -1,35 +1,89 @@
1
1
  // Builtin usage plugin: 蓝区统一网关(providerID: gateway)
2
- // credit 总量 + day/7d/month 滚动窗口。限额由用户手配(usage.pluginConfig.gateway),
3
- // 消耗 = 本地 trajectory tokens × per-model credit 价格(GET {baseURL}/models5min 缓存)。
2
+ // credit 总量 + day/7d/month 窗口。限额由用户手配(usage.pluginConfig.gateway)。
3
+ // day/7d/month 优先走网关权威用量(GET {baseURL}/usage/by-model?window=today|week|month
4
+ // BlueRegionUsage 链路,2026-09-16 实测生产可用):逐日 tokens × /models credit_history
5
+ // 当日生效系数(区间语义 [from, 下一条 from))÷ 1e6,端点失败逐窗口回退本地 trajectory 估算。
6
+ // balance(credit 总量)网关无全历史端点(retention 31 天),始终用本地全历史估算。
7
+ // /models 价格 5min 缓存。
4
8
  const DEFAULT_BASE_URL = 'https://st8tp3ajl0df3n8b8l8qu.apigateway-cn-beijing.volceapi.com/v1';
5
9
  const PRICE_CACHE_MS = 5 * 60 * 1000;
6
- let priceCache = { at: 0, prices: null };
10
+ let priceCache = { at: 0, catalog: null };
7
11
 
8
- async function loadPrices(ctx, baseURL, key) {
12
+ async function loadCatalog(ctx, baseURL, key) {
9
13
  const now = Date.now();
10
- if (priceCache.prices && now - priceCache.at < PRICE_CACHE_MS) return priceCache.prices;
14
+ if (priceCache.catalog && now - priceCache.at < PRICE_CACHE_MS) return priceCache.catalog;
11
15
  try {
12
16
  const res = await ctx.fetch(baseURL.replace(/\/+$/, '') + '/models', {
13
17
  headers: { Authorization: 'Bearer ' + key },
14
18
  });
15
19
  if (!res.ok) {
16
20
  ctx.log.warn('[GatewayPlugin] /models HTTP ' + res.status);
17
- return priceCache.prices;
21
+ return priceCache.catalog;
18
22
  }
19
23
  const data = await res.json();
20
24
  const prices = {};
25
+ const hist = {};
21
26
  for (const m of (data && data.data) || []) {
22
- const c = Number(m && m.credit);
23
- if (m && m.id && Number.isFinite(c)) prices[m.id] = c;
27
+ if (!m || !m.id) continue;
28
+ const c = Number(m.credit);
29
+ if (Number.isFinite(c)) prices[m.id] = c;
30
+ if (Array.isArray(m.credit_history)) hist[m.id] = m.credit_history;
24
31
  }
25
- priceCache = { at: now, prices };
26
- return prices;
32
+ priceCache = { at: now, catalog: { prices, hist } };
33
+ return priceCache.catalog;
27
34
  } catch (err) {
28
35
  ctx.log.warn('[GatewayPlugin] /models failed: ' + ((err && err.message) || err));
29
- return priceCache.prices;
36
+ return priceCache.catalog;
30
37
  }
31
38
  }
32
39
 
40
+ // 网关权威用量:by-model 逐日明细。失败/契约不符返回 null(调用方回退本地估算)。
41
+ async function fetchGatewayByModel(ctx, baseURL, key, gwWindow) {
42
+ try {
43
+ const res = await ctx.fetch(baseURL.replace(/\/+$/, '') + '/usage/by-model?window=' + gwWindow, {
44
+ headers: { Authorization: 'Bearer ' + key },
45
+ });
46
+ if (!res.ok) return null;
47
+ const data = await res.json();
48
+ const rows = (data && data.data) || [];
49
+ if (!Array.isArray(rows)) return null;
50
+ // 契约校验:by-model 行必须含 model + daily 逐日明细(防 /models 形状误入)
51
+ if (rows.some((r) => !r || typeof r.model !== 'string' || !Array.isArray(r.daily))) return null;
52
+ return rows;
53
+ } catch (err) {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // date 当日生效系数:取 from <= date 的最后一条;date 早于首条时用首条系数
59
+ function creditForDate(hist, date) {
60
+ if (!Array.isArray(hist) || hist.length === 0) return null;
61
+ let cur = null;
62
+ let first = null;
63
+ for (const e of hist) {
64
+ if (!e || typeof e.credit !== 'number') continue;
65
+ if (first === null) first = e.credit;
66
+ if (typeof e.from === 'string' && e.from <= date) cur = e.credit;
67
+ }
68
+ return cur !== null ? cur : first;
69
+ }
70
+
71
+ function gatewayCredits(rows, hist, prices) {
72
+ let credits = 0;
73
+ for (const r of rows) {
74
+ const h = hist ? hist[r.model] : null;
75
+ for (const d of r.daily) {
76
+ if (!d) continue;
77
+ const tok = Number(d.total_tokens != null ? d.total_tokens : (Number(d.req_tokens) || 0) + (Number(d.rsp_tokens) || 0));
78
+ if (!Number.isFinite(tok) || tok <= 0) continue;
79
+ // 无 credit_history 的模型回退 /models 当前系数(官方面板同款降级)
80
+ const coef = creditForDate(h, d.date) ?? (prices ? prices[r.model] : null);
81
+ if (coef != null) credits += (tok * coef) / 1e6;
82
+ }
83
+ }
84
+ return credits;
85
+ }
86
+
33
87
  function rowTokens(r) {
34
88
  const t = (r && r.tokens) || {};
35
89
  const cache = t.cache || {};
@@ -56,9 +110,12 @@ function creditsInWindow(rows, prices) {
56
110
  return { credits, perModel };
57
111
  }
58
112
 
59
- const ROLLING_WINDOWS = [
60
- ['day', 'day', 24 * 3600e3],
61
- ['week', '7d', 7 * 24 * 3600e3],
113
+ // day/week/month 窗口表:gw = 网关权威窗口(UTC+8 日历对齐),localSince = 回退本地估算起点。
114
+ // day 本地回退为滚动 24h,week 为滚动 7d,month 为自然月——仅网关端点不可达时生效。
115
+ const WINDOWS = [
116
+ { cfg: 'day', label: 'day', gw: 'today', localSince: (now) => now - 24 * 3600e3, resetAt: null },
117
+ { cfg: 'week', label: '7d', gw: 'week', localSince: (now) => now - 7 * 24 * 3600e3, resetAt: null },
118
+ { cfg: 'month', label: 'month', gw: 'month', localSince: () => calendarMonthStart(Date.now()), resetAt: () => calendarMonthEnd(Date.now()) },
62
119
  ];
63
120
 
64
121
  // 月度刷新 = 自然月(每月 1 号 00:00 本地时区重置),非滚动 30 天。
@@ -95,7 +152,9 @@ module.exports = {
95
152
  };
96
153
  if (lim.credit <= 0 && lim.day <= 0 && lim.week <= 0 && lim.month <= 0) return null;
97
154
  const baseURL = typeof cfg.baseURL === 'string' && cfg.baseURL.trim() ? cfg.baseURL.trim() : DEFAULT_BASE_URL;
98
- const prices = await loadPrices(ctx, baseURL, key);
155
+ const catalog = (await loadCatalog(ctx, baseURL, key)) || { prices: null, hist: {} };
156
+ const prices = catalog.prices;
157
+ const hist = catalog.hist || {};
99
158
  const stats = (opts) => (ctx.usage && ctx.usage.modelStats ? ctx.usage.modelStats(opts) : []);
100
159
 
101
160
  const windows = [];
@@ -119,29 +178,25 @@ module.exports = {
119
178
  detailLines,
120
179
  });
121
180
  }
122
- for (const [cfgKey, win, ms] of ROLLING_WINDOWS) {
123
- if (lim[cfgKey] <= 0) continue;
124
- const { credits } = creditsInWindow(stats({ provider: 'gateway', sinceMs: Date.now() - ms }), prices);
125
- const used = Math.round(credits * 100) / 100;
126
- windows.push({
127
- window: win,
128
- used,
129
- limit: lim[cfgKey],
130
- unit: 'credit',
131
- pct: Math.round((used / lim[cfgKey]) * 10000) / 100,
132
- });
133
- }
134
- if (lim.month > 0) {
135
- const { credits } = creditsInWindow(stats({ provider: 'gateway', sinceMs: calendarMonthStart(Date.now()) }), prices);
136
- const used = Math.round(credits * 100) / 100;
137
- windows.push({
138
- window: 'month',
181
+ for (const w of WINDOWS) {
182
+ if (lim[w.cfg] <= 0) continue;
183
+ // 权威路径:网关逐日明细 × credit_history 当日系数;失败逐窗口回退本地估算
184
+ const rows = await fetchGatewayByModel(ctx, baseURL, key, w.gw);
185
+ let used = null;
186
+ if (rows !== null) used = Math.round(gatewayCredits(rows, hist, prices) * 100) / 100;
187
+ if (used === null) {
188
+ const { credits } = creditsInWindow(stats({ provider: 'gateway', sinceMs: w.localSince(Date.now()) }), prices);
189
+ used = Math.round(credits * 100) / 100;
190
+ }
191
+ const win = {
192
+ window: w.label,
139
193
  used,
140
- limit: lim.month,
194
+ limit: lim[w.cfg],
141
195
  unit: 'credit',
142
- pct: Math.round((used / lim.month) * 10000) / 100,
143
- resetAt: calendarMonthEnd(Date.now()),
144
- });
196
+ pct: Math.round((used / lim[w.cfg]) * 10000) / 100,
197
+ };
198
+ if (w.resetAt) win.resetAt = w.resetAt();
199
+ windows.push(win);
145
200
  }
146
201
  if (windows.length === 0) return null;
147
202
  return { name: 'gateway', type: 'token-plan', plan: '蓝区统一网关', windows };
@@ -40,6 +40,37 @@ function severityFromPct(pct) {
40
40
  return 'mid';
41
41
  return 'low';
42
42
  }
43
+ /** 字段级 windows 校验(插件作者可定位:windows[i].<key> must be <type> (got <got>))。 */
44
+ function typeName(v) {
45
+ if (v === null)
46
+ return 'null';
47
+ if (Array.isArray(v))
48
+ return 'array';
49
+ return typeof v;
50
+ }
51
+ function validateWindowsShape(windows) {
52
+ for (let i = 0; i < windows.length; i++) {
53
+ const w = windows[i];
54
+ const at = `windows[${i}]`;
55
+ if (!w || typeof w !== 'object' || Array.isArray(w)) {
56
+ return `${at} must be an object (got ${typeName(w)})`;
57
+ }
58
+ if (typeof w.window !== 'string')
59
+ return `${at}.window must be string (got ${typeName(w.window)})`;
60
+ for (const key of ['used', 'limit']) {
61
+ if (typeof w[key] !== 'number')
62
+ return `${at}.${key} must be number (got ${typeName(w[key])})`;
63
+ }
64
+ for (const key of ['pct', 'unit', 'label']) {
65
+ if (w[key] === undefined)
66
+ continue;
67
+ const expected = key === 'pct' ? 'number' : 'string';
68
+ if (typeof w[key] !== expected)
69
+ return `${at}.${key} must be ${expected} (got ${typeName(w[key])})`;
70
+ }
71
+ }
72
+ return null;
73
+ }
43
74
  function makeAdapter(mod, file, usageStats, resolveInlineApiKey) {
44
75
  return {
45
76
  name: mod.name,
@@ -59,8 +90,17 @@ function makeAdapter(mod, file, usageStats, resolveInlineApiKey) {
59
90
  const result = await mod.fetch(ctx);
60
91
  if (result === null)
61
92
  return null;
62
- if (!result.name || !Array.isArray(result.windows)) {
63
- logger_1.log.warn(`[PluginLoader] ${file}: invalid return structure`);
93
+ if (!result.name) {
94
+ logger_1.log.warn(`[PluginLoader] ${file}: result.name must be string (got ${typeName(result.name)})`);
95
+ return null;
96
+ }
97
+ if (!Array.isArray(result.windows)) {
98
+ logger_1.log.warn(`[PluginLoader] ${file}: result.windows must be array (got ${typeName(result.windows)})`);
99
+ return null;
100
+ }
101
+ const shapeErr = validateWindowsShape(result.windows);
102
+ if (shapeErr) {
103
+ logger_1.log.warn(`[PluginLoader] ${file}: ${shapeErr}`);
64
104
  return null;
65
105
  }
66
106
  const maxPct = result.windows.length > 0 ? Math.max(...result.windows.map((w) => w.pct ?? 0)) : 0;
@@ -74,6 +74,7 @@ class PluginLoader {
74
74
  builtinNames;
75
75
  usageStats;
76
76
  resolveInlineApiKey;
77
+ packageEntries = [];
77
78
  constructor(pluginsDir, builtinNames, opts) {
78
79
  this.pluginsDir = pluginsDir;
79
80
  this.builtinNames = new Set(builtinNames);
@@ -173,18 +174,47 @@ class PluginLoader {
173
174
  this.builtinNames = builtinNameSet;
174
175
  }
175
176
  getAdapters() {
177
+ const packageNames = new Set(this.packageEntries.map((e) => e.mod.name));
176
178
  const adapters = [];
177
179
  for (const s of this.state.values()) {
178
- if (s.status === 'ok' && s.adapter && !s.disabled)
180
+ if (s.status === 'ok' && s.adapter && !s.disabled && !packageNames.has(s.name))
179
181
  adapters.push(s.adapter);
180
182
  }
183
+ for (const e of this.packageEntries) {
184
+ if (this.disabledPlugins.has(e.mod.name))
185
+ continue;
186
+ adapters.push((0, plugin_context_1.makeAdapter)(e.mod, e.source, this.usageStats, this.resolveInlineApiKey));
187
+ }
181
188
  return adapters;
182
189
  }
183
190
  isBuiltinName(name) {
184
191
  return this.builtinNames.has(name);
185
192
  }
186
193
  getState() {
187
- return [...this.state.values()];
194
+ const out = [...this.state.values()].map((s) => ({ ...s }));
195
+ const packageNames = new Set(this.packageEntries.map((e) => e.mod.name));
196
+ for (const s of out) {
197
+ if (s.name && packageNames.has(s.name))
198
+ s.overridden = true;
199
+ }
200
+ for (const e of this.packageEntries) {
201
+ out.push({
202
+ file: e.source,
203
+ name: e.mod.name,
204
+ status: 'ok',
205
+ overridden: false,
206
+ builtin: false,
207
+ adapter: undefined,
208
+ configSchema: validateConfigSchema(e.mod.configSchema),
209
+ disabled: this.disabledPlugins.has(e.mod.name),
210
+ pluginType: typeof e.mod.type === 'string' ? e.mod.type : undefined,
211
+ });
212
+ }
213
+ return out;
214
+ }
215
+ /** PluginHost 推送的包贡献。同名包覆盖 legacy 文件与内置;disabledPlugins 同样生效。 */
216
+ setPackageEntries(entries) {
217
+ this.packageEntries = entries ?? [];
188
218
  }
189
219
  async reload() {
190
220
  await this.scan();