u-foo 2.2.4 → 2.3.0

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 (57) hide show
  1. package/SKILLS/ufoo/SKILL.md +56 -12
  2. package/SKILLS/uinit/SKILL.md +3 -2
  3. package/modules/AGENTS.template.md +2 -1
  4. package/modules/bus/README.md +1 -1
  5. package/modules/context/SKILLS/uctx/SKILL.md +6 -4
  6. package/package.json +1 -1
  7. package/src/agent/codexThreadProvider.js +2 -2
  8. package/src/agent/controllerToolExecutor.js +24 -1
  9. package/src/agent/credentials/claude.js +85 -16
  10. package/src/agent/credentials/codex.js +251 -23
  11. package/src/agent/defaultBootstrap.js +3 -1
  12. package/src/agent/directAuthStatus.js +264 -0
  13. package/src/agent/internalRunner.js +18 -12
  14. package/src/agent/loopObservability.js +10 -0
  15. package/src/agent/loopRuntime.js +19 -0
  16. package/src/agent/ufooAgent.js +43 -13
  17. package/src/agent/upstreamTransport.js +23 -8
  18. package/src/bus/index.js +6 -1
  19. package/src/bus/message.js +156 -8
  20. package/src/chat/commandExecutor.js +187 -7
  21. package/src/chat/commands.js +23 -4
  22. package/src/chat/completionController.js +30 -7
  23. package/src/chat/index.js +3 -5
  24. package/src/cli/groupCoreCommands.js +5 -0
  25. package/src/cli.js +309 -0
  26. package/src/code/UCODE_PROMPT.md +3 -2
  27. package/src/code/prompts/ufoo.js +3 -2
  28. package/src/config.js +16 -3
  29. package/src/context/doctor.js +1 -1
  30. package/src/daemon/groupOrchestrator.js +13 -9
  31. package/src/daemon/promptRequest.js +11 -2
  32. package/src/daemon/soloBootstrap.js +2 -0
  33. package/src/group/bootstrap.js +1 -1
  34. package/src/group/promptProfiles.js +106 -22
  35. package/src/group/templates.js +1 -0
  36. package/src/init/index.js +4 -0
  37. package/src/memory/historySearch.js +308 -0
  38. package/src/memory/index.js +653 -8
  39. package/src/providerapi/redactor.js +4 -1
  40. package/src/status/index.js +24 -1
  41. package/src/tools/handlers/memory.js +168 -0
  42. package/src/tools/index.js +12 -0
  43. package/src/tools/registry.js +12 -0
  44. package/src/tools/schemaFixtures.js +213 -0
  45. package/src/tools/tier1/editMemory.js +14 -0
  46. package/src/tools/tier1/forget.js +14 -0
  47. package/src/tools/tier1/recall.js +14 -0
  48. package/src/tools/tier1/remember.js +14 -0
  49. package/src/tools/tier1/searchHistory.js +14 -0
  50. package/src/tools/tier1/searchMemory.js +14 -0
  51. package/templates/groups/build-lane.json +44 -6
  52. package/templates/groups/build-ultra.json +6 -5
  53. package/templates/groups/design-system.json +84 -0
  54. package/templates/groups/product-discovery.json +9 -4
  55. package/templates/groups/ui-plan-review.json +84 -0
  56. package/templates/groups/ui-polish.json +6 -2
  57. package/templates/groups/verify-ship.json +9 -4
@@ -135,6 +135,10 @@ function readRecentLoopSummary(projectRoot, options = {}) {
135
135
  output_tokens: 0,
136
136
  cache_read_tokens: 0,
137
137
  cache_creation_tokens: 0,
138
+ cache_semistatic_hit: 0,
139
+ cache_semistatic_miss: 0,
140
+ memory_prefix_tokens: 0,
141
+ dynamic_memory_tokens: 0,
138
142
  total_tokens: 0,
139
143
  total_latency_ms: 0,
140
144
  first_token_ms: 0,
@@ -154,18 +158,24 @@ function readRecentLoopSummary(projectRoot, options = {}) {
154
158
  summary.output_tokens += Number(row.output_tokens) || 0;
155
159
  summary.cache_read_tokens += Number(row.cache_read_tokens) || 0;
156
160
  summary.cache_creation_tokens += Number(row.cache_creation_tokens) || 0;
161
+ summary.cache_semistatic_hit += Number(row.cache_semistatic_hit) || 0;
162
+ summary.cache_semistatic_miss += Number(row.cache_semistatic_miss) || 0;
163
+ summary.memory_prefix_tokens += Number(row.memory_prefix_tokens) || 0;
164
+ summary.dynamic_memory_tokens += Number(row.dynamic_memory_tokens) || 0;
157
165
  summary.total_latency_ms += Number(row.latency_ms) || 0;
158
166
  summary.first_token_ms += Number(row.first_token_ms) || 0;
159
167
  } else if (row.event === "tool_call") {
160
168
  summary.tool_calls += 1;
161
169
  const name = String(row.tool_name || "").trim() || "unknown";
162
170
  toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
171
+ summary.dynamic_memory_tokens += Number(row.dynamic_memory_tokens) || 0;
163
172
  } else if (row.event === "loop_terminal") {
164
173
  summary.terminal_reason = String(row.terminal_reason || "").trim();
165
174
  if ((Number(row.rounds) || 0) > 0) summary.rounds = Number(row.rounds) || summary.rounds;
166
175
  if ((Number(row.tool_calls) || 0) >= 0) summary.tool_calls = Number(row.tool_calls) || summary.tool_calls;
167
176
  if ((Number(row.total_tokens) || 0) > 0) summary.total_tokens = Number(row.total_tokens) || 0;
168
177
  if ((Number(row.total_latency_ms) || 0) > 0) summary.total_latency_ms = Number(row.total_latency_ms) || 0;
178
+ if ((Number(row.dynamic_memory_tokens) || 0) > 0) summary.dynamic_memory_tokens = Number(row.dynamic_memory_tokens) || summary.dynamic_memory_tokens;
169
179
  }
170
180
  }
171
181
 
@@ -58,6 +58,10 @@ function extractModelMetrics(result) {
58
58
  output_tokens: toNonNegativeInt(source.output_tokens),
59
59
  cache_read_tokens: toNonNegativeInt(source.cache_read_tokens),
60
60
  cache_creation_tokens: toNonNegativeInt(source.cache_creation_tokens),
61
+ cache_semistatic_hit: toNonNegativeInt(source.cache_semistatic_hit),
62
+ cache_semistatic_miss: toNonNegativeInt(source.cache_semistatic_miss),
63
+ memory_prefix_tokens: toNonNegativeInt(source.memory_prefix_tokens),
64
+ dynamic_memory_tokens: toNonNegativeInt(source.dynamic_memory_tokens),
61
65
  latency_ms: toNonNegativeInt(source.latency_ms),
62
66
  first_token_ms: toNonNegativeInt(source.first_token_ms),
63
67
  stop_reason: String(source.stop_reason || "").trim(),
@@ -168,6 +172,7 @@ function buildTerminalPayload(reason, lastPayload, rounds, toolCalls, toolErrors
168
172
  fallback_used: normalizeFallbackUsed(totals.fallback_used),
169
173
  total_tokens: toNonNegativeInt(totals.total_tokens),
170
174
  total_latency_ms: toNonNegativeInt(totals.total_latency_ms),
175
+ dynamic_memory_tokens: toNonNegativeInt(totals.dynamic_memory_tokens),
171
176
  };
172
177
  return payload;
173
178
  }
@@ -205,6 +210,7 @@ async function runPromptWithControllerLoop({
205
210
  let toolErrors = 0;
206
211
  let totalTokens = 0;
207
212
  let totalLatencyMs = 0;
213
+ let dynamicMemoryTokens = 0;
208
214
  const toolResults = [];
209
215
 
210
216
  const checkCancellation = () => {
@@ -220,6 +226,7 @@ async function runPromptWithControllerLoop({
220
226
  fallback_used: FALLBACK_USED_VALUES.NONE,
221
227
  total_tokens: totalTokens,
222
228
  total_latency_ms: totalLatencyMs,
229
+ dynamic_memory_tokens: dynamicMemoryTokens,
223
230
  });
224
231
 
225
232
  const terminate = (reason, payloadBase, roundsCount) => {
@@ -304,6 +311,10 @@ async function runPromptWithControllerLoop({
304
311
  output_tokens: metrics.output_tokens,
305
312
  cache_read_tokens: metrics.cache_read_tokens,
306
313
  cache_creation_tokens: metrics.cache_creation_tokens,
314
+ cache_semistatic_hit: metrics.cache_semistatic_hit,
315
+ cache_semistatic_miss: metrics.cache_semistatic_miss,
316
+ memory_prefix_tokens: metrics.memory_prefix_tokens,
317
+ dynamic_memory_tokens: metrics.dynamic_memory_tokens,
307
318
  latency_ms: modelLatency,
308
319
  first_token_ms: metrics.first_token_ms,
309
320
  tool_call_count: toolCall ? 1 : 0,
@@ -339,6 +350,7 @@ async function runPromptWithControllerLoop({
339
350
  fallback_used: FALLBACK_USED_VALUES.NONE,
340
351
  total_tokens: totalTokens,
341
352
  total_latency_ms: totalLatencyMs,
353
+ dynamic_memory_tokens: dynamicMemoryTokens,
342
354
  },
343
355
  };
344
356
  observer.emit("loop_terminal", finalPayload.loop);
@@ -382,13 +394,19 @@ async function runPromptWithControllerLoop({
382
394
  const toolDuration = Math.max(0, now() - toolStartedAt);
383
395
 
384
396
  let toolResultSize = 0;
397
+ let toolDynamicMemoryTokens = 0;
385
398
  try {
386
399
  toolResultSize = toolResult && toolResult.result !== undefined
387
400
  ? JSON.stringify(toolResult.result).length
388
401
  : 0;
402
+ toolDynamicMemoryTokens = toolResult && toolResult.result && Number.isFinite(Number(toolResult.result.dynamic_memory_tokens))
403
+ ? Math.max(0, Math.floor(Number(toolResult.result.dynamic_memory_tokens)))
404
+ : 0;
389
405
  } catch {
390
406
  toolResultSize = 0;
407
+ toolDynamicMemoryTokens = 0;
391
408
  }
409
+ dynamicMemoryTokens += toolDynamicMemoryTokens;
392
410
 
393
411
  observer.emit("tool_call", {
394
412
  round,
@@ -397,6 +415,7 @@ async function runPromptWithControllerLoop({
397
415
  turn_id: toolResult && toolResult.turn_id ? String(toolResult.turn_id) : `loop-round-${round}`,
398
416
  duration_ms: toolDuration,
399
417
  result_size: toolResultSize,
418
+ dynamic_memory_tokens: toolDynamicMemoryTokens,
400
419
  retry_count: 0,
401
420
  final_status: toolResult && toolResult.ok === true ? "ok" : "error",
402
421
  });
@@ -5,8 +5,9 @@ const { normalizeCliOutput } = require("./normalizeOutput");
5
5
  const { buildStatus } = require("../daemon/status");
6
6
  const { getUfooPaths } = require("../ufoo/paths");
7
7
  const { normalizeGateRouterResult } = require("../controller/gateRouter");
8
- const { sendUpstreamPrompt } = require("./upstreamTransport");
8
+ const { normalizeProvider, sendUpstreamPrompt } = require("./upstreamTransport");
9
9
  const { normalizeAgentTypeAlias } = require("../bus/utils");
10
+ const { buildCachedMemoryPrefix } = require("../memory");
10
11
  const { listProjectRuntimes, isGlobalControllerProjectRoot } = require("../projects");
11
12
  const {
12
13
  CONTROLLER_MODES,
@@ -522,6 +523,14 @@ function buildSystemPrompt(context, options = {}) {
522
523
  ].join("\n");
523
524
  }
524
525
 
526
+ function buildMemoryPrefixResult(projectRoot, limit = 50) {
527
+ try {
528
+ return buildCachedMemoryPrefix(projectRoot, { limit });
529
+ } catch {
530
+ return { prefix: "", estimated_tokens: 0, cache_hit: false, cache_semistatic_hit: 0, cache_semistatic_miss: 0 };
531
+ }
532
+ }
533
+
525
534
  function loadHistory(projectRoot, maxTurns = 6) {
526
535
  const file = path.join(getUfooPaths(projectRoot).agentDir, "ufoo-agent.history.jsonl");
527
536
  try {
@@ -594,9 +603,9 @@ function extractNickname(prompt) {
594
603
  return "";
595
604
  }
596
605
 
597
- function isUcodeProvider(value = "") {
598
- const text = String(value || "").trim().toLowerCase();
599
- return text === "ucode" || text === "ufoo" || text === "ufoo-code";
606
+ function shouldUseDirectProvider(value = "") {
607
+ const provider = normalizeProvider(value);
608
+ return provider === "ucode" || provider === "codex" || provider === "claude";
600
609
  }
601
610
 
602
611
  function stripMarkdownFence(text = "") {
@@ -644,32 +653,42 @@ async function runUfooAgent({
644
653
  const bus = routingContext || (mode === "global-router"
645
654
  ? buildGlobalProjectRouterContext(projectRoot)
646
655
  : loadBusSummary(projectRoot));
647
- const systemPrompt = buildSystemPrompt(bus, {
656
+ let systemPrompt = buildSystemPrompt(bus, {
648
657
  routingMode: mode,
649
658
  loopRuntime,
650
659
  controllerMode: resolvedControllerMode,
651
660
  });
661
+ const memoryPrefixResult = buildMemoryPrefixResult(projectRoot);
662
+ const memoryPrefix = String(memoryPrefixResult.prefix || "").trim();
663
+ if (memoryPrefix) {
664
+ systemPrompt = `${systemPrompt}\n\n${memoryPrefix}`;
665
+ }
652
666
  const history = loadHistory(projectRoot);
653
667
  const historyPrompt = buildHistoryPrompt(history);
654
668
  const fullPrompt = historyPrompt ? `${historyPrompt}User: ${prompt}` : prompt;
655
669
 
656
670
  let res;
657
671
 
658
- if (isUcodeProvider(provider)) {
659
- // Native path: direct HTTP to LLM API, no CLI binary needed
672
+ const useDirectProvider = shouldUseDirectProvider(provider);
673
+ let usedDirectProvider = false;
674
+
675
+ if (useDirectProvider) {
660
676
  res = await runNativeRouterCall({
661
677
  projectRoot,
662
678
  prompt: fullPrompt,
663
679
  systemPrompt,
680
+ provider,
664
681
  model,
665
682
  });
666
683
  if (!res.ok) {
667
684
  return { ok: false, error: res.error };
685
+ } else {
686
+ usedDirectProvider = true;
687
+ res = { ok: true, output: res.output, sessionId: "", provider: res.provider, model: res.model };
668
688
  }
669
- // Native path returns { ok, output } where output is raw text
670
- res = { ok: true, output: res.output, sessionId: "" };
671
- } else {
672
- // CLI path: spawn codex/claude binary
689
+ }
690
+
691
+ if (!useDirectProvider) {
673
692
  res = await runCliAgent({
674
693
  provider,
675
694
  model,
@@ -700,7 +719,7 @@ async function runUfooAgent({
700
719
  }
701
720
  }
702
721
 
703
- const rawText = isUcodeProvider(provider)
722
+ const rawText = usedDirectProvider
704
723
  ? String(res.output || "").trim()
705
724
  : normalizeCliOutput(res.output);
706
725
  const text = stripMarkdownFence(rawText);
@@ -733,7 +752,18 @@ async function runUfooAgent({
733
752
 
734
753
  appendHistory(projectRoot, { prompt, reply: payload.reply || "" });
735
754
 
736
- return { ok: true, payload };
755
+ return {
756
+ ok: true,
757
+ payload,
758
+ meta: {
759
+ memory_prefix_tokens: memoryPrefixResult.estimated_tokens || 0,
760
+ cache_semistatic_hit: memoryPrefixResult.cache_semistatic_hit || 0,
761
+ cache_semistatic_miss: memoryPrefixResult.cache_semistatic_miss || 0,
762
+ memory_prefix_truncated: memoryPrefixResult.truncated === true,
763
+ memory_prefix_entries: memoryPrefixResult.entry_count || 0,
764
+ memory_prefix_emitted: memoryPrefixResult.emitted_count || 0,
765
+ },
766
+ };
737
767
  }
738
768
 
739
769
  async function runUfooRouteAgent({
@@ -16,7 +16,7 @@ function normalizeProvider(value = "") {
16
16
  if (!text) return "ucode";
17
17
  if (text === "codex-cli" || text === "codex-code" || text === "codex" || text === "openai") return "codex";
18
18
  if (text === "claude-cli" || text === "claude-code" || text === "claude" || text === "anthropic") return "claude";
19
- if (text === "ucode") return "ucode";
19
+ if (text === "ucode" || text === "ufoo" || text === "ufoo-code") return "ucode";
20
20
  return text;
21
21
  }
22
22
 
@@ -200,6 +200,7 @@ async function resolveUpstreamRuntime({
200
200
  provider = "",
201
201
  model = "",
202
202
  env = process.env,
203
+ fetchImpl = global.fetch,
203
204
  loadConfigImpl = loadConfig,
204
205
  } = {}) {
205
206
  const normalizedProvider = normalizeProvider(provider);
@@ -208,6 +209,8 @@ async function resolveUpstreamRuntime({
208
209
  if (normalizedProvider === "codex") {
209
210
  const credential = await resolveCodexUpstreamCredentials({
210
211
  authPath: config.codexAuthPath,
212
+ refreshWindowMs: Number(config.codexOauthRefreshWindowSec || 300) * 1000,
213
+ fetchImpl,
211
214
  env,
212
215
  });
213
216
  const useCodexResponses = credential.credentialKind === "oauth" && Boolean(credential.accessToken);
@@ -408,13 +411,25 @@ async function sendUpstreamPrompt({
408
411
  env = process.env,
409
412
  loadConfigImpl = loadConfig,
410
413
  } = {}) {
411
- const runtime = await resolveUpstreamRuntime({
412
- projectRoot,
413
- provider,
414
- model,
415
- env,
416
- loadConfigImpl,
417
- });
414
+ let runtime;
415
+ try {
416
+ runtime = await resolveUpstreamRuntime({
417
+ projectRoot,
418
+ provider,
419
+ model,
420
+ env,
421
+ fetchImpl,
422
+ loadConfigImpl,
423
+ });
424
+ } catch (err) {
425
+ return {
426
+ ok: false,
427
+ error: err && err.message ? err.message : "upstream runtime resolution failed",
428
+ errorCode: err && err.code ? err.code : "UPSTREAM_RUNTIME_RESOLUTION_FAILED",
429
+ provider: normalizeProvider(provider),
430
+ model: String(model || "").trim(),
431
+ };
432
+ }
418
433
 
419
434
  const requestModel = String(runtime.model || "").trim();
420
435
  const request = runtime.transport === "anthropic-messages"
package/src/bus/index.js CHANGED
@@ -65,7 +65,12 @@ class EventBus {
65
65
  this.messageManager = new MessageManager(
66
66
  this.busDir,
67
67
  this.busData,
68
- this.queueManager
68
+ this.queueManager,
69
+ {
70
+ projectRoot: this.projectRoot,
71
+ enableGroupPolicyHook: true,
72
+ warn: logWarn,
73
+ }
69
74
  );
70
75
 
71
76
  // 自动清理不活跃的 agents
@@ -8,9 +8,11 @@ const {
8
8
  readLastLine,
9
9
  isPidAlive,
10
10
  normalizeAgentTypeAlias,
11
+ logWarn,
11
12
  } = require("./utils");
12
13
  const NicknameManager = require("./nickname");
13
14
  const { buildMessageData } = require("./messageMeta");
15
+ const { getUfooPaths } = require("../ufoo/paths");
14
16
 
15
17
  const SEQ_LOCK_TIMEOUT_MS = 5000;
16
18
  const SEQ_LOCK_POLL_MS = 25;
@@ -20,13 +22,21 @@ const SEQ_LOCK_STALE_MS = 30000;
20
22
  * 消息管理器
21
23
  */
22
24
  class MessageManager {
23
- constructor(busDir, busData, queueManager) {
25
+ constructor(busDir, busData, queueManager, options = {}) {
24
26
  this.busDir = busDir;
25
27
  this.busData = busData;
26
28
  this.queueManager = queueManager;
27
29
  this.eventsDir = path.join(busDir, "events");
28
30
  this.seqFile = path.join(busDir, "seq.counter");
29
31
  this.seqLockFile = path.join(busDir, "seq.counter.lock");
32
+ this.projectRoot = typeof options.projectRoot === "string" && options.projectRoot.trim()
33
+ ? options.projectRoot
34
+ : "";
35
+ this.warn = typeof options.warn === "function" ? options.warn : logWarn;
36
+ this.preSendHooks = Array.isArray(options.preSendHooks) ? options.preSendHooks.slice() : [];
37
+ if (options.enableGroupPolicyHook === true && this.projectRoot) {
38
+ this.preSendHooks.push((context) => this.checkGroupSoftPolicy(context));
39
+ }
30
40
  }
31
41
 
32
42
  /**
@@ -255,6 +265,131 @@ class MessageManager {
255
265
  return false;
256
266
  }
257
267
 
268
+ readActiveGroupStates() {
269
+ if (!this.projectRoot) return [];
270
+ const groupsDir = getUfooPaths(this.projectRoot).groupsDir;
271
+ if (!fs.existsSync(groupsDir)) return [];
272
+
273
+ const groups = [];
274
+ let files = [];
275
+ try {
276
+ files = fs.readdirSync(groupsDir, { withFileTypes: true })
277
+ .filter((item) => item.isFile() && item.name.endsWith(".json"))
278
+ .map((item) => path.join(groupsDir, item.name))
279
+ .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
280
+ } catch {
281
+ return [];
282
+ }
283
+
284
+ for (const filePath of files) {
285
+ try {
286
+ const runtime = JSON.parse(fs.readFileSync(filePath, "utf8"));
287
+ const status = typeof runtime.status === "string" ? runtime.status : "";
288
+ if (status === "active" || status === "starting") {
289
+ groups.push(runtime);
290
+ }
291
+ } catch {
292
+ // Ignore malformed runtime files; policy warnings must never block send.
293
+ }
294
+ }
295
+ return groups;
296
+ }
297
+
298
+ findGroupMember(runtime = {}, subscriberId = "") {
299
+ const members = Array.isArray(runtime.members) ? runtime.members : [];
300
+ const target = typeof subscriberId === "string" ? subscriberId.trim() : "";
301
+ if (!target) return null;
302
+
303
+ return members.find((member) => {
304
+ if (!member || typeof member !== "object") return false;
305
+ return member.subscriber_id === target
306
+ || member.bootstrapped_subscriber_id === target
307
+ || member.nickname === target
308
+ || member.scoped_nickname === target
309
+ || member.runtime_nickname === target;
310
+ }) || null;
311
+ }
312
+
313
+ checkGroupSoftPolicy({ publisher, targets } = {}) {
314
+ const resolvedTargets = Array.isArray(targets) ? targets : [];
315
+ if (!publisher || resolvedTargets.length === 0) return [];
316
+
317
+ const groups = this.readActiveGroupStates();
318
+ if (groups.length === 0) return [];
319
+
320
+ const warnings = [];
321
+ const seen = new Set();
322
+
323
+ for (const runtime of groups) {
324
+ const publisherMember = this.findGroupMember(runtime, publisher);
325
+ if (!publisherMember) continue;
326
+
327
+ const publisherNickname = publisherMember.nickname
328
+ || publisherMember.scoped_nickname
329
+ || publisherMember.runtime_nickname
330
+ || publisher;
331
+ for (const targetSubscriber of resolvedTargets) {
332
+ const targetMember = this.findGroupMember(runtime, targetSubscriber);
333
+ if (!targetMember || targetMember === publisherMember) continue;
334
+
335
+ const acceptFrom = Array.isArray(targetMember.accept_from)
336
+ ? targetMember.accept_from.filter((item) => typeof item === "string" && item.trim())
337
+ : [];
338
+ const allowed = acceptFrom.includes(publisherNickname)
339
+ || acceptFrom.includes(publisherMember.scoped_nickname)
340
+ || acceptFrom.includes(publisherMember.runtime_nickname)
341
+ || acceptFrom.includes(publisher);
342
+
343
+ if (allowed) continue;
344
+
345
+ const groupId = runtime.group_id || "unknown-group";
346
+ const targetNickname = targetMember.nickname
347
+ || targetMember.scoped_nickname
348
+ || targetMember.runtime_nickname
349
+ || targetSubscriber;
350
+ const key = `${groupId}:${publisherNickname}:${targetNickname}`;
351
+ if (seen.has(key)) continue;
352
+ seen.add(key);
353
+
354
+ const allowedText = acceptFrom.length > 0 ? acceptFrom.join(", ") : "none";
355
+ warnings.push(
356
+ `group policy warning: ${publisherNickname} -> ${targetNickname} violates accept_from for group ${groupId}; allowed: ${allowedText}`
357
+ );
358
+ }
359
+ }
360
+
361
+ return warnings;
362
+ }
363
+
364
+ async runPreSendHooks(context) {
365
+ if (!Array.isArray(this.preSendHooks) || this.preSendHooks.length === 0) {
366
+ return [];
367
+ }
368
+
369
+ const warnings = [];
370
+ for (const hook of this.preSendHooks) {
371
+ if (typeof hook !== "function") continue;
372
+ try {
373
+ // eslint-disable-next-line no-await-in-loop
374
+ const result = await hook(context);
375
+ if (Array.isArray(result)) {
376
+ warnings.push(...result.filter(Boolean).map((item) => String(item)));
377
+ } else if (typeof result === "string" && result) {
378
+ warnings.push(result);
379
+ } else if (result && typeof result.message === "string") {
380
+ warnings.push(result.message);
381
+ }
382
+ } catch (err) {
383
+ warnings.push(`preSendHook failed: ${err && err.message ? err.message : err}`);
384
+ }
385
+ }
386
+
387
+ for (const warning of warnings) {
388
+ this.warn(warning);
389
+ }
390
+ return warnings;
391
+ }
392
+
258
393
  /**
259
394
  * 发送消息
260
395
  */
@@ -269,6 +404,16 @@ class MessageManager {
269
404
  throw new Error(`Target "${target}" not found`);
270
405
  }
271
406
 
407
+ const warnings = await this.runPreSendHooks({
408
+ publisher,
409
+ target,
410
+ targets,
411
+ message,
412
+ options,
413
+ busData: this.busData,
414
+ projectRoot: this.projectRoot,
415
+ });
416
+
272
417
  const data = buildMessageData(message, options);
273
418
 
274
419
  // 构建事件
@@ -295,7 +440,7 @@ class MessageManager {
295
440
  }
296
441
  }
297
442
 
298
- return { seq, targets };
443
+ return { seq, targets, warnings };
299
444
  }
300
445
 
301
446
  /**
@@ -421,12 +566,15 @@ class MessageManager {
421
566
 
422
567
  return false;
423
568
  })
424
- .map(([id, meta]) => ({
425
- id,
426
- nickname: meta.nickname || meta.scoped_nickname || "",
427
- agent_type: meta.agent_type,
428
- last_seen: meta.last_seen,
429
- }));
569
+ .map(([id, meta]) => {
570
+ const nickname = meta.nickname || meta.scoped_nickname;
571
+ return {
572
+ id,
573
+ ...(nickname ? { nickname } : {}),
574
+ agent_type: meta.agent_type,
575
+ last_seen: meta.last_seen,
576
+ };
577
+ });
430
578
 
431
579
  // 如果只有一个候选者,直接返回
432
580
  if (candidates.length === 1) {