@jack200714/mafw 4.5.2 → 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.
- package/README.md +27 -3
- package/gateway/dist/core/manager/goal-snapshot.js +2 -2
- package/gateway/dist/core/manager/manager-session-runtime.js +59 -0
- package/gateway/dist/core/manager/milestone-push.js +25 -10
- package/gateway/dist/index.js +315 -139
- package/gateway/dist/media/media-plugin-loader.js +25 -13
- package/gateway/dist/media/resolve-prompt.js +20 -0
- package/gateway/dist/memory/gateway-db.js +23 -0
- package/gateway/dist/opencode-adapter.js +34 -0
- package/gateway/dist/plugins/hub.js +153 -19
- package/gateway/dist/plugins/package-context.js +24 -0
- package/gateway/dist/plugins/package-host.js +331 -0
- package/gateway/dist/plugins/package-types.js +2 -0
- package/gateway/dist/recall/gateway-db-migrate.js +5 -2
- package/gateway/dist/recall/redact.js +53 -0
- package/gateway/dist/recall/turn-pipeline.js +2 -0
- package/gateway/dist/routes/event-publish.js +44 -0
- package/gateway/dist/routes/plugins.js +19 -6
- package/gateway/dist/routes/waitwhat-command.js +43 -0
- package/gateway/dist/runtime/contract.js +4 -1
- package/gateway/dist/runtime/event-broadcast.js +41 -0
- package/gateway/dist/runtime/loader.js +45 -14
- package/gateway/dist/runtime/normalize.js +13 -0
- package/gateway/dist/runtime/pi/pi-approval-bridge.js +12 -2
- package/gateway/dist/runtime/pi/pi-approval-extension.js +11 -3
- package/gateway/dist/runtime/pi/pi-session.js +21 -3
- package/gateway/dist/runtime/plugins/pi-runtime.js +6 -3
- package/gateway/dist/runtime/serve-sidecar.js +4 -1
- package/gateway/dist/runtime/serve-supervisor.js +12 -0
- package/gateway/dist/runtime/validate.js +39 -0
- package/gateway/dist/skills/manager-identity.js +6 -1
- package/gateway/dist/usage/builtin-plugins/gateway.js +103 -26
- package/gateway/dist/usage/plugin-context.js +42 -2
- package/gateway/dist/usage/plugin-loader.js +32 -13
- package/gateway/package.json +2 -2
- package/package.json +3 -1
- package/packages/tui/dist/cli.js +28 -5
|
@@ -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
|
}
|
|
@@ -69,7 +70,6 @@ class RuntimePluginLoader {
|
|
|
69
70
|
if (!fs.existsSync(this.pluginsDir)) {
|
|
70
71
|
fs.mkdirSync(this.pluginsDir, { recursive: true });
|
|
71
72
|
fs.writeFileSync(path.join(this.pluginsDir, 'README.md'), README_CONTENT);
|
|
72
|
-
fs.writeFileSync(path.join(this.pluginsDir, 'example.js.disabled'), EXAMPLE_CONTENT);
|
|
73
73
|
logger_1.log.info(`[RuntimePluginLoader] Created ${this.pluginsDir}`);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -90,6 +90,10 @@ class RuntimePluginLoader {
|
|
|
90
90
|
this.factories.delete(prev.name);
|
|
91
91
|
this.meta.delete(prev.name);
|
|
92
92
|
}
|
|
93
|
+
if (prev?.alias) {
|
|
94
|
+
this.factories.delete(prev.alias);
|
|
95
|
+
this.meta.delete(prev.alias);
|
|
96
|
+
}
|
|
93
97
|
this.state.delete(file);
|
|
94
98
|
}
|
|
95
99
|
}
|
|
@@ -123,9 +127,22 @@ class RuntimePluginLoader {
|
|
|
123
127
|
const external = mod.external !== false;
|
|
124
128
|
this.factories.set(name, mod.createRuntime);
|
|
125
129
|
this.meta.set(name, { capabilities, external });
|
|
126
|
-
this.state.set(file, { file, name, status: 'ok', capabilities });
|
|
127
130
|
loadedNames.add(name);
|
|
128
|
-
|
|
131
|
+
// Filename-stem alias: the plugin hub addresses runtime plugins by
|
|
132
|
+
// filename stem (statEntry baseName) while switch/createRuntime
|
|
133
|
+
// validate by module.exports.name — register both so hub "激活" works
|
|
134
|
+
// regardless of the declared name. The real module name always wins
|
|
135
|
+
// (unconditional set above overwrites an earlier stem alias), and an
|
|
136
|
+
// alias never shadows a builtin.
|
|
137
|
+
const stem = file.replace(/\.js$/, '');
|
|
138
|
+
let alias;
|
|
139
|
+
if (stem !== name && !this.factories.has(stem) && !this.builtins.has(stem)) {
|
|
140
|
+
this.factories.set(stem, mod.createRuntime);
|
|
141
|
+
this.meta.set(stem, { capabilities, external });
|
|
142
|
+
alias = stem;
|
|
143
|
+
}
|
|
144
|
+
this.state.set(file, { file, name, status: 'ok', capabilities, alias });
|
|
145
|
+
logger_1.log.info(`[RuntimePluginLoader] Loaded ${file} (${name})${alias ? ` [alias: ${alias}]` : ''}`);
|
|
129
146
|
}
|
|
130
147
|
catch (err) {
|
|
131
148
|
const prev = this.state.get(file);
|
|
@@ -133,31 +150,55 @@ class RuntimePluginLoader {
|
|
|
133
150
|
logger_1.log.warn(`[RuntimePluginLoader] ${file} load error: ${err.message}`);
|
|
134
151
|
}
|
|
135
152
|
}
|
|
153
|
+
/** PluginHost 推送的包贡献。查找顺序:legacy 文件 > 包 > 内置。scan() 不影响。 */
|
|
154
|
+
setPackageEntries(entries) {
|
|
155
|
+
this.packageEntries = new Map(entries.map((e) => [e.name, e]));
|
|
156
|
+
}
|
|
136
157
|
/** 插件不存在或未通过校验时返回 undefined(调用方回退内置 opencode)。文件插件优先于内置。 */
|
|
137
158
|
get(name) {
|
|
138
159
|
const createRuntime = this.factories.get(name);
|
|
139
160
|
const meta = this.meta.get(name);
|
|
140
161
|
if (createRuntime && meta)
|
|
141
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
|
+
}
|
|
142
171
|
const builtin = this.builtins.get(name);
|
|
143
172
|
if (builtin)
|
|
144
173
|
return { createRuntime: builtin.factory, ...builtin };
|
|
145
174
|
return undefined;
|
|
146
175
|
}
|
|
176
|
+
/** 已注册内置件名(builtin 文件件不在其中,opencode 为恒等默认由 index.ts 注入 hub)。 */
|
|
177
|
+
getBuiltinNames() {
|
|
178
|
+
return [...this.builtins.keys()];
|
|
179
|
+
}
|
|
147
180
|
getState() {
|
|
148
181
|
for (const [name, b] of this.builtins) {
|
|
149
182
|
this.state.set(`builtin:${name}`, { file: `builtin:${name}`, name, status: 'ok', capabilities: b.capabilities });
|
|
150
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
|
+
}
|
|
151
190
|
return [...this.state.values()];
|
|
152
191
|
}
|
|
153
192
|
}
|
|
154
193
|
exports.RuntimePluginLoader = RuntimePluginLoader;
|
|
155
|
-
function createRuntimePluginContext(credentials) {
|
|
194
|
+
function createRuntimePluginContext(credentials, extra) {
|
|
156
195
|
return {
|
|
157
196
|
fetch: (url, opts) => fetch(url, { ...opts, signal: opts?.signal ?? AbortSignal.timeout(60000) }),
|
|
158
197
|
log: logger_1.log,
|
|
159
198
|
pluginConfig: (name) => config_1.config.raw?.runtime?.pluginConfig?.[name] ?? {},
|
|
160
199
|
credentials,
|
|
200
|
+
projectDir: extra?.projectDir,
|
|
201
|
+
gatewayPort: extra?.gatewayPort,
|
|
161
202
|
};
|
|
162
203
|
}
|
|
163
204
|
const README_CONTENT = `# Runtime Plugins
|
|
@@ -228,13 +269,3 @@ Capabilities declared here gate gateway features declaratively: missing
|
|
|
228
269
|
capabilities disable the corresponding features (503 on gated endpoints,
|
|
229
270
|
skipped event subscription) — they never crash.
|
|
230
271
|
`;
|
|
231
|
-
const EXAMPLE_CONTENT = `// Rename to example.js to activate
|
|
232
|
-
module.exports = {
|
|
233
|
-
name: "example",
|
|
234
|
-
capabilities: { eventStream: false },
|
|
235
|
-
async createRuntime(ctx) {
|
|
236
|
-
ctx.log.info("[example-runtime] created");
|
|
237
|
-
throw new Error("example plugin: implement createRuntime before activating");
|
|
238
|
-
},
|
|
239
|
-
};
|
|
240
|
-
`;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
//
|
|
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,
|
|
10
|
+
let priceCache = { at: 0, catalog: null };
|
|
7
11
|
|
|
8
|
-
async function
|
|
12
|
+
async function loadCatalog(ctx, baseURL, key) {
|
|
9
13
|
const now = Date.now();
|
|
10
|
-
if (priceCache.
|
|
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.
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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.
|
|
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,12 +110,25 @@ function creditsInWindow(rows, prices) {
|
|
|
56
110
|
return { credits, perModel };
|
|
57
111
|
}
|
|
58
112
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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()) },
|
|
63
119
|
];
|
|
64
120
|
|
|
121
|
+
// 月度刷新 = 自然月(每月 1 号 00:00 本地时区重置),非滚动 30 天。
|
|
122
|
+
function calendarMonthStart(now) {
|
|
123
|
+
const d = new Date(now);
|
|
124
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function calendarMonthEnd(now) {
|
|
128
|
+
const d = new Date(now);
|
|
129
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
130
|
+
}
|
|
131
|
+
|
|
65
132
|
module.exports = {
|
|
66
133
|
name: 'gateway',
|
|
67
134
|
type: 'token-plan',
|
|
@@ -85,7 +152,9 @@ module.exports = {
|
|
|
85
152
|
};
|
|
86
153
|
if (lim.credit <= 0 && lim.day <= 0 && lim.week <= 0 && lim.month <= 0) return null;
|
|
87
154
|
const baseURL = typeof cfg.baseURL === 'string' && cfg.baseURL.trim() ? cfg.baseURL.trim() : DEFAULT_BASE_URL;
|
|
88
|
-
const
|
|
155
|
+
const catalog = (await loadCatalog(ctx, baseURL, key)) || { prices: null, hist: {} };
|
|
156
|
+
const prices = catalog.prices;
|
|
157
|
+
const hist = catalog.hist || {};
|
|
89
158
|
const stats = (opts) => (ctx.usage && ctx.usage.modelStats ? ctx.usage.modelStats(opts) : []);
|
|
90
159
|
|
|
91
160
|
const windows = [];
|
|
@@ -104,22 +173,30 @@ module.exports = {
|
|
|
104
173
|
used,
|
|
105
174
|
limit: lim.credit,
|
|
106
175
|
unit: 'credit',
|
|
107
|
-
pct: Math.round((used / lim.credit) * 100
|
|
176
|
+
pct: Math.round((used / lim.credit) * 10000) / 100,
|
|
108
177
|
remaining: Math.max(0, Math.round((lim.credit - used) * 100) / 100),
|
|
109
178
|
detailLines,
|
|
110
179
|
});
|
|
111
180
|
}
|
|
112
|
-
for (const
|
|
113
|
-
if (lim[
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
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,
|
|
118
193
|
used,
|
|
119
|
-
limit: lim[
|
|
194
|
+
limit: lim[w.cfg],
|
|
120
195
|
unit: 'credit',
|
|
121
|
-
pct: Math.round((used / lim[
|
|
122
|
-
}
|
|
196
|
+
pct: Math.round((used / lim[w.cfg]) * 10000) / 100,
|
|
197
|
+
};
|
|
198
|
+
if (w.resetAt) win.resetAt = w.resetAt();
|
|
199
|
+
windows.push(win);
|
|
123
200
|
}
|
|
124
201
|
if (windows.length === 0) return null;
|
|
125
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
|
|
63
|
-
logger_1.log.warn(`[PluginLoader] ${file}:
|
|
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;
|