@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.
Files changed (37) 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 +315 -139
  6. package/gateway/dist/media/media-plugin-loader.js +25 -13
  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/hub.js +153 -19
  11. package/gateway/dist/plugins/package-context.js +24 -0
  12. package/gateway/dist/plugins/package-host.js +331 -0
  13. package/gateway/dist/plugins/package-types.js +2 -0
  14. package/gateway/dist/recall/gateway-db-migrate.js +5 -2
  15. package/gateway/dist/recall/redact.js +53 -0
  16. package/gateway/dist/recall/turn-pipeline.js +2 -0
  17. package/gateway/dist/routes/event-publish.js +44 -0
  18. package/gateway/dist/routes/plugins.js +19 -6
  19. package/gateway/dist/routes/waitwhat-command.js +43 -0
  20. package/gateway/dist/runtime/contract.js +4 -1
  21. package/gateway/dist/runtime/event-broadcast.js +41 -0
  22. package/gateway/dist/runtime/loader.js +45 -14
  23. package/gateway/dist/runtime/normalize.js +13 -0
  24. package/gateway/dist/runtime/pi/pi-approval-bridge.js +12 -2
  25. package/gateway/dist/runtime/pi/pi-approval-extension.js +11 -3
  26. package/gateway/dist/runtime/pi/pi-session.js +21 -3
  27. package/gateway/dist/runtime/plugins/pi-runtime.js +6 -3
  28. package/gateway/dist/runtime/serve-sidecar.js +4 -1
  29. package/gateway/dist/runtime/serve-supervisor.js +12 -0
  30. package/gateway/dist/runtime/validate.js +39 -0
  31. package/gateway/dist/skills/manager-identity.js +6 -1
  32. package/gateway/dist/usage/builtin-plugins/gateway.js +103 -26
  33. package/gateway/dist/usage/plugin-context.js +42 -2
  34. package/gateway/dist/usage/plugin-loader.js +32 -13
  35. package/gateway/package.json +2 -2
  36. package/package.json +3 -1
  37. package/packages/tui/dist/cli.js +28 -5
@@ -44,6 +44,8 @@ class MediaPluginLoader {
44
44
  pluginsDir;
45
45
  state = new Map();
46
46
  engines = new Map();
47
+ builtinEngines = new Map();
48
+ packageEngines = new Map();
47
49
  watcher;
48
50
  debounceTimer;
49
51
  getCredentials;
@@ -60,7 +62,6 @@ class MediaPluginLoader {
60
62
  if (!fs.existsSync(this.pluginsDir)) {
61
63
  fs.mkdirSync(this.pluginsDir, { recursive: true });
62
64
  fs.writeFileSync(path.join(this.pluginsDir, 'README.md'), README_CONTENT);
63
- fs.writeFileSync(path.join(this.pluginsDir, 'example.js.disabled'), EXAMPLE_CONTENT);
64
65
  logger_1.log.info(`[MediaPluginLoader] Created ${this.pluginsDir}`);
65
66
  }
66
67
  }
@@ -147,8 +148,30 @@ class MediaPluginLoader {
147
148
  logger_1.log.warn(`[MediaPluginLoader] ${file} load error: ${err.message}`);
148
149
  }
149
150
  }
151
+ /** 内置引擎登记(如 pi)。prompt 是占位——真正的内置路径由 resolveMediaPrompt 在调用点解析。 */
152
+ registerBuiltinEngine(name, modalities) {
153
+ this.builtinEngines.set(name, {
154
+ prompt: (async () => { throw new Error('builtin engine prompt is resolved at call site'); }),
155
+ modalities,
156
+ builtin: true,
157
+ });
158
+ }
159
+ getBuiltinEngineNames() {
160
+ return [...this.builtinEngines.keys()];
161
+ }
162
+ /** PluginHost 推送的包贡献。同名覆盖 legacy 与内置。 */
163
+ setPackageEntries(entries) {
164
+ this.packageEngines = new Map(entries.map((e) => [e.name, e]));
165
+ }
150
166
  getEngines() {
151
- return this.engines;
167
+ // 合并顺序:内置 → legacy 文件 → 包(后者覆盖前者同名)
168
+ const merged = new Map(this.builtinEngines);
169
+ for (const [name, e] of this.engines)
170
+ merged.set(name, e);
171
+ for (const [name, e] of this.packageEngines) {
172
+ merged.set(name, { prompt: e.prompt, modalities: e.modalities, source: e.source });
173
+ }
174
+ return merged;
152
175
  }
153
176
  getState() {
154
177
  return [...this.state.values()];
@@ -241,14 +264,3 @@ media:
241
264
  engine: qwen-vl # override for video
242
265
  \`\`\`
243
266
  `;
244
- const EXAMPLE_CONTENT = `// Rename to example.js to activate
245
- module.exports = {
246
- name: "example",
247
- modalities: ["image"],
248
- async createPrompt(ctx) {
249
- return async (parts, opts) => {
250
- return "Example analysis result";
251
- };
252
- },
253
- };
254
- `;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveMediaPrompt = resolveMediaPrompt;
4
+ /** media 引擎解析(deps 注入可单测)。用户引擎可同名覆盖内置 pi。 */
5
+ const logger_1 = require("../core/utils/logger");
6
+ function resolveMediaPrompt(engineName, kind, deps) {
7
+ const engine = deps.engines.get(engineName);
8
+ if (!engine) {
9
+ if (engineName !== 'pi')
10
+ logger_1.log.warn(`[MediaService] engine '${engineName}' not found, falling back to pi`);
11
+ return deps.builtinPi();
12
+ }
13
+ if (engine.builtin)
14
+ return deps.builtinPi();
15
+ if (!engine.modalities.includes(kind)) {
16
+ logger_1.log.warn(`[MediaService] engine '${engineName}' does not support modality '${kind}', falling back to pi`);
17
+ return deps.builtinPi();
18
+ }
19
+ return engine.prompt;
20
+ }
@@ -371,6 +371,12 @@ class GatewayDatabase {
371
371
  return this.db.prepare('SELECT COUNT(*) AS c FROM t1_observations').get().c;
372
372
  }
373
373
  // ── KV store (small critical state) ─────────────────────────────────────
374
+ // Scope 归属规范(新增 scope 必须声明一类):
375
+ // - runtime-scoped:生命周期绑定当前 agent runtime(会话 id 属于 runtime 存储),
376
+ // 切换时由 Scheduler.invalidateRuntimeScopedKv 统一失效——
377
+ // manager-session / internal-session / reflect-cursor
378
+ // - durable:跨 runtime 有效——registry/snapshot / milestone-notified
379
+ // runtime-scoped 条目的 value 必须携带 `at`(ISO 日期)供 TTL/审计。
374
380
  kvGet(scope, key) {
375
381
  const row = this.db.prepare('SELECT value FROM kv_store WHERE scope = ? AND key = ?').get(scope, key);
376
382
  if (!row)
@@ -402,6 +408,23 @@ class GatewayDatabase {
402
408
  }
403
409
  });
404
410
  }
411
+ kvClearScope(scope) {
412
+ const info = this.db.prepare('DELETE FROM kv_store WHERE scope = ?').run(scope);
413
+ return info.changes;
414
+ }
415
+ kvPruneOlderThan(scope, ttlDays) {
416
+ const now = Date.now();
417
+ let removed = 0;
418
+ for (const { key, value } of this.kvAll(scope)) {
419
+ const at = value && typeof value === 'object' ? value.at : undefined;
420
+ const ts = typeof at === 'string' ? Date.parse(at) : NaN;
421
+ if (!isNaN(ts) && now - ts > ttlDays * 86_400_000) {
422
+ this.kvDelete(scope, key);
423
+ removed++;
424
+ }
425
+ }
426
+ return removed;
427
+ }
405
428
  // ── Goal outcomes (RSI Phase 1) ─────────────────────────────────────────
406
429
  upsertGoalOutcome(o) {
407
430
  this.db.prepare(`
@@ -27,6 +27,28 @@ function messageToParts(message, parts) {
27
27
  async function createOpencodeAdapter(config) {
28
28
  const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
29
29
  const client = createOpencodeClient(config);
30
+ // opencode 原生 V1 路由的 workspace 路由细节收敛在 adapter 内(契约保持
31
+ // runtime 中立)——带 directory 的调用直连 fetch(V2 SDK 调用不带 workspace 语义)。
32
+ const directNative = async (path, method, body, directory) => {
33
+ const url = `${config.baseUrl}${path}${directory ? `?directory=${encodeURIComponent(directory)}` : ''}`;
34
+ const res = await fetch(url, {
35
+ method,
36
+ headers: {
37
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
38
+ ...(directory ? { 'x-opencode-directory': encodeURIComponent(directory) } : {}),
39
+ ...(config.headers ?? {}),
40
+ },
41
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
42
+ });
43
+ if (!res.ok)
44
+ throw new Error(`opencode native ${path} failed: ${res.status}`);
45
+ try {
46
+ return await res.json();
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ };
30
52
  return {
31
53
  session: {
32
54
  async create(opts) {
@@ -192,6 +214,10 @@ async function createOpencodeAdapter(config) {
192
214
  return Array.isArray(data) ? data : data?.items ?? [];
193
215
  },
194
216
  async reply(opts) {
217
+ if (opts.directory) {
218
+ await directNative(`/question/${opts.requestID}/reply`, 'POST', { answers: opts.answers }, opts.directory);
219
+ return;
220
+ }
195
221
  const result = await client.session.question.reply({
196
222
  requestID: opts.requestID,
197
223
  answers: opts.answers,
@@ -201,12 +227,20 @@ async function createOpencodeAdapter(config) {
201
227
  }
202
228
  },
203
229
  async reject(opts) {
230
+ if (opts.directory) {
231
+ await directNative(`/question/${opts.requestID}/reject`, 'POST', undefined, opts.directory);
232
+ return;
233
+ }
204
234
  const result = await client.session.question.reject({ requestID: opts.requestID });
205
235
  if (result && typeof result === 'object' && 'error' in result && result.error) {
206
236
  throw new Error(String(result.error));
207
237
  }
208
238
  },
209
239
  },
240
+ async permissionList(opts) {
241
+ const data = await directNative('/permission', 'GET', undefined, opts?.directory);
242
+ return Array.isArray(data) ? data : data?.items ?? [];
243
+ },
210
244
  },
211
245
  global: {
212
246
  event() {
@@ -36,11 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.HubError = void 0;
37
37
  exports.parseType = parseType;
38
38
  exports.listPlugins = listPlugins;
39
+ exports.cleanupExamples = cleanupExamples;
39
40
  exports.installPlugin = installPlugin;
40
41
  exports.setPluginEnabled = setPluginEnabled;
41
42
  exports.deletePlugin = deletePlugin;
42
43
  const fs = __importStar(require("fs"));
44
+ const os = __importStar(require("os"));
43
45
  const path = __importStar(require("path"));
46
+ const logger_1 = require("../core/utils/logger");
44
47
  class HubError extends Error {
45
48
  status;
46
49
  constructor(status, message) {
@@ -87,6 +90,19 @@ function statEntry(type, dir, file) {
87
90
  };
88
91
  }
89
92
  function listPlugins(deps) {
93
+ const userEntries = collectUserEntries(deps);
94
+ const builtins = (deps.builtinEntries?.() ?? []).map((e) => ({ ...e, builtin: true }));
95
+ const userKeys = new Set(userEntries.map((e) => `${e.type}/${e.name}`));
96
+ const configDisabled = deps.configDisabledUsage?.() ?? new Set();
97
+ for (const b of builtins) {
98
+ if (userKeys.has(`${b.type}/${b.name}`))
99
+ b.overridden = true;
100
+ if (b.type === 'usage' && configDisabled.has(b.name) && b.status === 'enabled')
101
+ b.status = 'config-disabled';
102
+ }
103
+ return [...builtins, ...userEntries];
104
+ }
105
+ function collectUserEntries(deps) {
90
106
  const entries = [];
91
107
  for (const type of PLUGIN_TYPES) {
92
108
  const dir = deps.dirs[type];
@@ -130,24 +146,52 @@ function listPlugins(deps) {
130
146
  }
131
147
  return entries;
132
148
  }
133
- function decodeContent(contentBase64, maxBytes) {
134
- if (typeof contentBase64 !== 'string' || contentBase64.length === 0)
135
- throw new HubError(400, 'empty content');
136
- const buf = Buffer.from(contentBase64, 'base64');
137
- if (buf.length === 0)
138
- throw new HubError(400, 'empty content');
139
- // round-trip check rejects non-base64 garbage
140
- if (buf.toString('base64') !== contentBase64)
141
- throw new HubError(400, 'content is not valid base64');
142
- if (buf.length > maxBytes)
143
- throw new HubError(413, `content exceeds ${maxBytes} bytes`);
144
- return buf;
149
+ function cleanupExamples(deps) {
150
+ const removed = [];
151
+ const failed = [];
152
+ for (const dir of Object.values(deps.dirs)) {
153
+ const target = path.join(dir, 'example.js.disabled');
154
+ try {
155
+ if (fs.existsSync(target)) {
156
+ fs.unlinkSync(target);
157
+ removed.push(target);
158
+ }
159
+ }
160
+ catch {
161
+ failed.push(target);
162
+ }
163
+ }
164
+ return { removed, failed };
145
165
  }
146
166
  async function installPlugin(deps, input) {
147
- const type = parseType(input.type);
148
167
  const filename = validateFilename(input.filename);
149
168
  if (!filename.endsWith('.js'))
150
169
  throw new HubError(400, `install filename must end with .js: ${filename}`);
170
+ const maxBytes = deps.maxBytes ?? DEFAULT_MAX_BYTES;
171
+ if (!Buffer.isBuffer(input.bytes) || input.bytes.length === 0)
172
+ throw new HubError(400, 'empty content');
173
+ if (input.bytes.length > maxBytes)
174
+ throw new HubError(413, `content exceeds ${maxBytes} bytes`);
175
+ const explicitType = input.type ? parseType(input.type) : undefined;
176
+ const { matches, mod } = inspectPlugin(filename, input.bytes);
177
+ validateName(mod, filename);
178
+ let type;
179
+ if (matches.length === 0)
180
+ throw new HubError(400, 'unrecognized plugin interface: export createRuntime / createPrompt / fetch / tools');
181
+ if (explicitType) {
182
+ if (!matches.includes(explicitType)) {
183
+ const listing = matches.map((m) => `${m} (${MATCH_IFACE[m]})`).join('/');
184
+ throw new HubError(400, `plugin interface mismatch: selected ${explicitType}, exports ${listing}`);
185
+ }
186
+ type = explicitType;
187
+ }
188
+ else {
189
+ if (matches.length > 1)
190
+ throw new HubError(400, `ambiguous plugin interface: ${matches.join('/')}`);
191
+ type = matches[0];
192
+ }
193
+ if (type === 'media')
194
+ await validateMediaActivation(mod);
151
195
  const dir = deps.dirs[type];
152
196
  fs.mkdirSync(dir, { recursive: true });
153
197
  const target = resolveInDir(dir, filename);
@@ -156,23 +200,113 @@ async function installPlugin(deps, input) {
156
200
  if (!input.overwrite && (fs.existsSync(dup) || fs.existsSync(dupDisabled))) {
157
201
  throw new HubError(409, `plugin already exists: ${filename}`);
158
202
  }
159
- const buf = decodeContent(input.contentBase64, deps.maxBytes ?? DEFAULT_MAX_BYTES);
160
203
  const tmp = `${target}.tmp`;
161
- fs.writeFileSync(tmp, buf);
204
+ fs.writeFileSync(tmp, input.bytes);
162
205
  fs.renameSync(tmp, target);
163
206
  await deps.reload?.(type);
164
207
  return statEntry(type, dir, filename);
165
208
  }
209
+ const MATCH_IFACE = {
210
+ runtime: 'createRuntime',
211
+ media: 'createPrompt',
212
+ usage: 'fetch',
213
+ ui: 'tools',
214
+ };
215
+ function computeMatches(mod) {
216
+ const matches = [];
217
+ if (typeof mod?.createRuntime === 'function')
218
+ matches.push('runtime');
219
+ if (typeof mod?.createPrompt === 'function' || typeof mod?.fixPayload === 'function'
220
+ || typeof mod?.engine === 'string' || Array.isArray(mod?.modalities))
221
+ matches.push('media');
222
+ if (typeof mod?.fetch === 'function'
223
+ && (mod.type === undefined || mod.type === 'api' || mod.type === 'token-plan' || mod.type === 'local'))
224
+ matches.push('usage');
225
+ if (mod?.tools && typeof mod.tools === 'object' && Object.keys(mod.tools).length > 0)
226
+ matches.push('ui');
227
+ return matches;
228
+ }
229
+ function inspectPlugin(filename, bytes) {
230
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mafw-hub-sniff-'));
231
+ const tmpFile = path.join(tmpDir, filename);
232
+ try {
233
+ fs.writeFileSync(tmpFile, bytes);
234
+ try {
235
+ delete require.cache[require.resolve(tmpFile)];
236
+ }
237
+ catch { /* first load */ }
238
+ let mod;
239
+ try {
240
+ mod = require(tmpFile);
241
+ }
242
+ catch (err) {
243
+ throw new HubError(400, `plugin failed to load: ${err.message}`);
244
+ }
245
+ return { matches: computeMatches(mod), mod };
246
+ }
247
+ finally {
248
+ try {
249
+ fs.rmSync(tmpDir, { recursive: true, force: true });
250
+ }
251
+ catch { /* best effort */ }
252
+ }
253
+ }
254
+ function validateName(mod, filename) {
255
+ const name = mod?.name;
256
+ if (typeof name !== 'string' || name.length === 0)
257
+ throw new HubError(400, 'missing plugin name');
258
+ const stem = baseName(filename);
259
+ if (name !== stem) {
260
+ throw new HubError(400, `plugin name mismatch: exports '${name}', filename '${filename}' (must match)`);
261
+ }
262
+ }
263
+ const VALID_MODALITIES = new Set(['image', 'video', 'audio']);
264
+ const STUB_MEDIA_CTX = {
265
+ apiKey: () => null,
266
+ fetch: (url, opts) => fetch(url, opts),
267
+ pluginConfig: () => null,
268
+ log: logger_1.log,
269
+ };
270
+ async function validateMediaActivation(mod) {
271
+ const modalities = mod?.modalities;
272
+ if (!Array.isArray(modalities) || modalities.length === 0
273
+ || !modalities.every((m) => VALID_MODALITIES.has(m))) {
274
+ throw new HubError(400, 'invalid modalities');
275
+ }
276
+ const hasCreatePrompt = typeof mod?.createPrompt === 'function';
277
+ const hasPiEngine = mod?.engine === 'pi';
278
+ if (!hasCreatePrompt && !hasPiEngine)
279
+ throw new HubError(400, 'missing createPrompt() or engine:"pi"');
280
+ if (hasCreatePrompt && hasPiEngine)
281
+ throw new HubError(400, 'createPrompt and engine:"pi" are mutually exclusive');
282
+ if (hasCreatePrompt) {
283
+ const fn = await mod.createPrompt(STUB_MEDIA_CTX);
284
+ if (typeof fn !== 'function')
285
+ throw new HubError(400, 'createPrompt did not return a function');
286
+ }
287
+ }
166
288
  async function setPluginEnabled(deps, input) {
167
289
  const type = parseType(input.type);
168
290
  const filename = validateFilename(input.filename);
169
291
  const dir = deps.dirs[type];
170
- const current = resolveInDir(dir, filename);
171
- if (!fs.existsSync(current))
292
+ // Accept either on-disk state: a stale client snapshot (double-toggled
293
+ // switch, unrefreshed list) sends the opposite-state filename, which must
294
+ // resolve idempotently instead of 404ing ("激活 not found").
295
+ const primary = resolveInDir(dir, filename);
296
+ const variant = /\.js\.disabled$/.test(filename)
297
+ ? filename.replace(/\.js\.disabled$/, '.js')
298
+ : filename.replace(/\.js$/, '.js.disabled');
299
+ const variantPath = resolveInDir(dir, variant);
300
+ const current = fs.existsSync(primary) ? primary : fs.existsSync(variantPath) ? variantPath : null;
301
+ if (!current)
172
302
  throw new HubError(404, `plugin file not found: ${filename}`);
173
- const nextName = input.enabled ? filename.replace(/\.js\.disabled$/, '.js') : filename.replace(/\.js$/, '.js.disabled');
303
+ const currentName = path.basename(current);
304
+ const nextName = input.enabled
305
+ ? currentName.replace(/\.js\.disabled$/, '.js')
306
+ : currentName.replace(/\.js$/, '.js.disabled');
174
307
  const next = resolveInDir(dir, nextName);
175
- fs.renameSync(current, next);
308
+ if (next !== current)
309
+ fs.renameSync(current, next);
176
310
  await deps.reload?.(type);
177
311
  return statEntry(type, dir, nextName);
178
312
  }
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPluginPackageContext = createPluginPackageContext;
4
+ const config_1 = require("../config");
5
+ const logger_1 = require("../core/utils/logger");
6
+ const auth_1 = require("../runtime/auth");
7
+ const EMPTY_USAGE = { modelStats: () => [] };
8
+ function createPluginPackageContext(name, deps) {
9
+ return {
10
+ log: logger_1.log,
11
+ fetch: (url, opts) => fetch(url, { ...opts, signal: opts?.signal ?? AbortSignal.timeout(60_000) }),
12
+ apiKey: (provider) => (0, auth_1.getProviderApiKey)(provider, undefined, deps.getCredentials?.()) ?? null,
13
+ pluginConfig: () => config_1.config.raw?.plugins?.[name]?.config
14
+ ?? config_1.config.raw?.usage?.pluginConfig?.[name]
15
+ ?? config_1.config.raw?.media?.pluginConfig?.[name]
16
+ ?? config_1.config.raw?.runtime?.pluginConfig?.[name]
17
+ ?? {},
18
+ credentials: deps.getCredentials?.(),
19
+ projectDir: deps.projectDir,
20
+ gatewayPort: deps.gatewayPort,
21
+ usage: deps.usageStats?.() ?? EMPTY_USAGE,
22
+ emit: (event) => deps.emit?.(event),
23
+ };
24
+ }