@jack200714/mafw 4.8.0 → 4.10.1

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 (36) 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 +265 -103
  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 +34 -0
  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/waitwhat-command.js +43 -0
  19. package/gateway/dist/runtime/contract.js +4 -1
  20. package/gateway/dist/runtime/event-broadcast.js +8 -0
  21. package/gateway/dist/runtime/loader.js +22 -1
  22. package/gateway/dist/runtime/normalize.js +13 -0
  23. package/gateway/dist/runtime/pi/pi-approval-bridge.js +12 -2
  24. package/gateway/dist/runtime/pi/pi-approval-extension.js +11 -3
  25. package/gateway/dist/runtime/pi/pi-session.js +21 -3
  26. package/gateway/dist/runtime/plugins/pi-runtime.js +6 -3
  27. package/gateway/dist/runtime/serve-sidecar.js +4 -1
  28. package/gateway/dist/runtime/serve-supervisor.js +12 -0
  29. package/gateway/dist/runtime/validate.js +39 -0
  30. package/gateway/dist/skills/manager-identity.js +6 -1
  31. package/gateway/dist/usage/builtin-plugins/gateway.js +91 -36
  32. package/gateway/dist/usage/plugin-context.js +42 -2
  33. package/gateway/dist/usage/plugin-loader.js +32 -2
  34. package/gateway/package.json +1 -1
  35. package/package.json +3 -1
  36. package/packages/tui/dist/cli.js +20 -2
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ // /waitwhat command (UI-driven, via /api/mafw-commands/run): the user signals
3
+ // "that last reply didn't land" and the agent re-pitches its own last message
4
+ // in simplified language, using the project's CONTEXT.md glossary when one
5
+ // exists. Inspired by mattpocock/skills wait-what (MIT).
6
+ //
7
+ // The re-pitch is prompted INTO THE SAME session so every connected client
8
+ // (desktop / TUI / opencode) sees the answer inline via the event stream.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.buildWaitwhatPrompt = buildWaitwhatPrompt;
11
+ exports.runWaitwhat = runWaitwhat;
12
+ function buildWaitwhatPrompt(original) {
13
+ return [
14
+ '[/waitwhat] 用户没看懂你上一条回复。请把它重述一遍:',
15
+ '1. 先用一两句补上"我们在做什么、刚才说到哪"的上下文定位;',
16
+ '2. 然后用简明语言重述:短句、一次一个概念、避免嵌套行话(STE100 简化技术英语的风格);',
17
+ '3. 如果项目根目录有 CONTEXT.md,先读它并用其中的项目术语(ubiquitous language)来表达;',
18
+ '4. 只是重述,不要新增内容、不引入新话题。',
19
+ '',
20
+ '—— 需要重述的上一条回复原文 ——',
21
+ original,
22
+ ].join('\n');
23
+ }
24
+ /**
25
+ * Re-pitch the last assistant message of the session. Returns ok:false with
26
+ * an error string (without prompting) when there is nothing to re-pitch.
27
+ */
28
+ async function runWaitwhat(sessionID, deps) {
29
+ const messages = await deps.listMessages(sessionID);
30
+ const lastAssistant = [...(Array.isArray(messages) ? messages : [])]
31
+ .reverse()
32
+ .find((m) => m?.info?.role === 'assistant');
33
+ const original = (lastAssistant?.parts || [])
34
+ .filter((p) => p?.type === 'text' && typeof p?.text === 'string')
35
+ .map((p) => p.text)
36
+ .join('\n')
37
+ .trim();
38
+ if (!original) {
39
+ return { ok: false, error: 'no assistant message to re-pitch' };
40
+ }
41
+ await deps.promptAsync(sessionID, buildWaitwhatPrompt(original));
42
+ return { ok: true };
43
+ }
@@ -15,7 +15,10 @@ function fullCapabilities() {
15
15
  agentProcessApi: true,
16
16
  completionApi: true,
17
17
  sessionBranchApi: true,
18
- turnBudgetApi: true,
18
+ // opencode 适配器不转发 maxTurns/maxCostUsd(SDK 无对应字段)——预算由
19
+ // gateway 侧 BudgetGuard 承担;声明 true 会让 attachBudgetGuardForGoal
20
+ // 跳过挂载,goal 预算在默认 runtime 上完全失效。
21
+ turnBudgetApi: false,
19
22
  questionApi: true,
20
23
  };
21
24
  }
@@ -12,6 +12,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.opencodeBroadcast = opencodeBroadcast;
13
13
  exports.projectRegisteredEvent = projectRegisteredEvent;
14
14
  function opencodeBroadcast(data, internal) {
15
+ if (typeof data?.type !== 'string' || !data.type) {
16
+ // 形状守卫(fail-open):信封照常返回,但给出可定位诊断——畸形事件
17
+ // 在桌面/TUI 是"静默忽略",没有这行日志时插件作者无从排查。
18
+ // eslint-disable-next-line no-console
19
+ console.error(`[EventBroadcast] malformed opencode_event data: 'type' must be a non-empty string ` +
20
+ `(got ${JSON.stringify(data?.type)}); payload keys: ${data ? Object.keys(data).join(',') : '(null)'} ` +
21
+ `— downstream (desktop/TUI) will ignore this event`);
22
+ }
15
23
  return {
16
24
  type: 'opencode_event',
17
25
  data: {
@@ -54,6 +54,7 @@ class RuntimePluginLoader {
54
54
  meta = new Map();
55
55
  state = new Map();
56
56
  builtins = new Map();
57
+ packageEntries = new Map();
57
58
  constructor(pluginsDir) {
58
59
  this.pluginsDir = pluginsDir;
59
60
  }
@@ -149,12 +150,24 @@ class RuntimePluginLoader {
149
150
  logger_1.log.warn(`[RuntimePluginLoader] ${file} load error: ${err.message}`);
150
151
  }
151
152
  }
153
+ /** PluginHost 推送的包贡献。查找顺序:legacy 文件 > 包 > 内置。scan() 不影响。 */
154
+ setPackageEntries(entries) {
155
+ this.packageEntries = new Map(entries.map((e) => [e.name, e]));
156
+ }
152
157
  /** 插件不存在或未通过校验时返回 undefined(调用方回退内置 opencode)。文件插件优先于内置。 */
153
158
  get(name) {
154
159
  const createRuntime = this.factories.get(name);
155
160
  const meta = this.meta.get(name);
156
161
  if (createRuntime && meta)
157
162
  return { createRuntime, ...meta };
163
+ const pkg = this.packageEntries.get(name);
164
+ if (pkg) {
165
+ return {
166
+ createRuntime: pkg.createRuntime,
167
+ capabilities: { ...(0, contract_1.minimalCapabilities)(), ...pkg.capabilities },
168
+ external: pkg.external,
169
+ };
170
+ }
158
171
  const builtin = this.builtins.get(name);
159
172
  if (builtin)
160
173
  return { createRuntime: builtin.factory, ...builtin };
@@ -168,16 +181,24 @@ class RuntimePluginLoader {
168
181
  for (const [name, b] of this.builtins) {
169
182
  this.state.set(`builtin:${name}`, { file: `builtin:${name}`, name, status: 'ok', capabilities: b.capabilities });
170
183
  }
184
+ for (const [name, p] of this.packageEntries) {
185
+ this.state.set(`package:${name}`, {
186
+ file: `package:${name}`, name, status: 'ok',
187
+ capabilities: { ...(0, contract_1.minimalCapabilities)(), ...p.capabilities },
188
+ });
189
+ }
171
190
  return [...this.state.values()];
172
191
  }
173
192
  }
174
193
  exports.RuntimePluginLoader = RuntimePluginLoader;
175
- function createRuntimePluginContext(credentials) {
194
+ function createRuntimePluginContext(credentials, extra) {
176
195
  return {
177
196
  fetch: (url, opts) => fetch(url, { ...opts, signal: opts?.signal ?? AbortSignal.timeout(60000) }),
178
197
  log: logger_1.log,
179
198
  pluginConfig: (name) => config_1.config.raw?.runtime?.pluginConfig?.[name] ?? {},
180
199
  credentials,
200
+ projectDir: extra?.projectDir,
201
+ gatewayPort: extra?.gatewayPort,
181
202
  };
182
203
  }
183
204
  const README_CONTENT = `# Runtime Plugins
@@ -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 || '';
@@ -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);
@@ -32,6 +32,18 @@ function createServeSupervisor(deps) {
32
32
  throw refused();
33
33
  if (instance && await deps.probe(instance.url))
34
34
  return instance.url;
35
+ // Adopt: gateway startup may have adopted a healthy serve, or a
36
+ // user-managed opencode may already listen here — never killPort a
37
+ // healthy listener on this port (that would drop every SSE/desktop/TUI
38
+ // connection on a pi→opencode switch).
39
+ const candidateUrl = `http://${host}:${deps.port}`;
40
+ try {
41
+ if (await deps.probe(candidateUrl)) {
42
+ instance = { url: candidateUrl, close: () => { } };
43
+ return candidateUrl;
44
+ }
45
+ }
46
+ catch { /* not healthy → spawn below */ }
35
47
  if (starting)
36
48
  return starting;
37
49
  starting = (async () => {
@@ -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;