neoagent 3.2.1-beta.10 → 3.2.1-beta.12

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 (62) hide show
  1. package/flutter_app/lib/main_controller.dart +46 -4
  2. package/flutter_app/lib/main_models.dart +4 -2
  3. package/flutter_app/lib/main_operations.dart +0 -49
  4. package/flutter_app/lib/main_settings.dart +338 -0
  5. package/flutter_app/lib/src/backend_client.dart +19 -0
  6. package/package.json +1 -1
  7. package/server/http/routes.js +1 -0
  8. package/server/public/.last_build_id +1 -1
  9. package/server/public/flutter_bootstrap.js +1 -1
  10. package/server/public/main.dart.js +65635 -65088
  11. package/server/routes/behavior.js +80 -0
  12. package/server/routes/settings.js +4 -0
  13. package/server/services/ai/history.js +1 -1
  14. package/server/services/ai/loop/agent_engine_core.js +97 -16
  15. package/server/services/ai/loop/completion_judge.js +211 -44
  16. package/server/services/ai/loop/conversation_loop.js +6 -11
  17. package/server/services/ai/loop/messaging_delivery.js +47 -0
  18. package/server/services/ai/messagingFallback.js +2 -2
  19. package/server/services/ai/model_failure_cache.js +7 -0
  20. package/server/services/ai/systemPrompt.js +30 -128
  21. package/server/services/ai/taskAnalysis.js +47 -2
  22. package/server/services/ai/toolEvidence.js +65 -159
  23. package/server/services/ai/tools.js +60 -8
  24. package/server/services/behavior/config.js +251 -0
  25. package/server/services/behavior/defaults.js +68 -0
  26. package/server/services/behavior/delivery.js +176 -0
  27. package/server/services/behavior/index.js +43 -0
  28. package/server/services/behavior/model_client.js +35 -0
  29. package/server/services/behavior/modules/agent_identity.js +37 -0
  30. package/server/services/behavior/modules/channel_style.js +28 -0
  31. package/server/services/behavior/modules/index.js +29 -0
  32. package/server/services/behavior/modules/norms.js +101 -0
  33. package/server/services/behavior/modules/persona.js +172 -0
  34. package/server/services/behavior/modules/persona_prompt.js +238 -0
  35. package/server/services/behavior/modules/social_memory.js +86 -0
  36. package/server/services/behavior/modules/social_observability.js +94 -0
  37. package/server/services/behavior/modules/social_signals.js +41 -0
  38. package/server/services/behavior/modules/theory_of_mind.js +118 -0
  39. package/server/services/behavior/modules/turn_taking.js +237 -0
  40. package/server/services/behavior/pipeline.js +324 -0
  41. package/server/services/behavior/registry.js +78 -0
  42. package/server/services/behavior/signals.js +107 -0
  43. package/server/services/behavior/state.js +99 -0
  44. package/server/services/behavior/system_prompt.js +75 -0
  45. package/server/services/memory/manager.js +14 -33
  46. package/server/services/messaging/access_policy.js +10 -6
  47. package/server/services/messaging/automation.js +158 -27
  48. package/server/services/messaging/discord.js +11 -1
  49. package/server/services/messaging/formatting_guides.js +2 -4
  50. package/server/services/messaging/http_platforms.js +4 -3
  51. package/server/services/messaging/inbound_queue.js +74 -13
  52. package/server/services/messaging/inbound_store.js +33 -0
  53. package/server/services/messaging/manager.js +18 -0
  54. package/server/services/messaging/telegram.js +10 -1
  55. package/server/services/messaging/whatsapp.js +11 -0
  56. package/server/services/social_reach/channels/social_video.js +1 -1
  57. package/server/services/social_video/captions.js +2 -2
  58. package/server/services/social_video/service.js +194 -29
  59. package/server/services/voice/message.js +1 -1
  60. package/server/services/voice/runtime.js +2 -2
  61. package/server/utils/logger.js +19 -0
  62. package/server/services/ai/terminal_reply.js +0 -18
@@ -200,41 +200,6 @@ function isSubstantiveProgressToolName(toolName = '') {
200
200
  return true;
201
201
  }
202
202
 
203
- const PRIMARY_RESEARCH_SOURCES = new Set([
204
- 'browser',
205
- 'http',
206
- 'integration',
207
- 'files',
208
- 'command',
209
- 'mcp',
210
- 'android',
211
- 'data',
212
- 'vision',
213
- 'skills',
214
- 'subagent',
215
- ]);
216
-
217
- const SECONDARY_RESEARCH_SOURCES = new Set([
218
- 'search',
219
- 'memory',
220
- ]);
221
-
222
- // External/source-backed tools only. Local file/edit/shell work must not
223
- // inherit a research burden just because inspection tools are available.
224
- const RESEARCH_TOOL_HINTS = new Set([
225
- 'web_search',
226
- 'http_request',
227
- 'browser_navigate',
228
- 'browser_open',
229
- 'browser_click',
230
- 'browser_type',
231
- 'browser_snapshot',
232
- 'browser_evaluate',
233
- 'analyze_image',
234
- 'spawn_subagent',
235
- 'delegate_to_agent',
236
- ]);
237
-
238
203
  function clampResearchText(value, maxChars = 220) {
239
204
  const text = String(value || '').replace(/\s+/g, ' ').trim();
240
205
  if (!text) return '';
@@ -266,18 +231,20 @@ function collectResearchTargets(analysis = null, goalContext = null) {
266
231
  ], { limit: 8 });
267
232
  }
268
233
 
269
- function hasResearchToolHint(analysis = null) {
270
- const tools = Array.isArray(analysis?.suggested_tools) ? analysis.suggested_tools : [];
271
- return tools.some((name) => {
272
- const tool = String(name || '').trim().toLowerCase();
273
- if (!tool) return false;
274
- if (RESEARCH_TOOL_HINTS.has(tool)) return true;
275
- // Tool-schema identity only: family prefixes, not free-text phrase matching.
276
- return tool.startsWith('browser_') || tool.startsWith('mcp_');
277
- });
278
- }
279
-
280
234
  function resolveResearchIntensity(analysis = null, goalContext = null) {
235
+ const declaredDepth = String(
236
+ analysis?.research_depth
237
+ || analysis?.researchDepth
238
+ || '',
239
+ ).trim().toLowerCase();
240
+ const declaredTargets = collectResearchTargets(analysis, goalContext);
241
+ if (
242
+ ['none', 'light', 'deep'].includes(declaredDepth)
243
+ && !(declaredDepth === 'none' && declaredTargets.length > 0)
244
+ ) {
245
+ return declaredDepth;
246
+ }
247
+
281
248
  const mode = String(analysis?.mode || '').trim().toLowerCase();
282
249
  const complexity = String(
283
250
  goalContext?.effectiveComplexity
@@ -297,21 +264,16 @@ function resolveResearchIntensity(analysis = null, goalContext = null) {
297
264
  const verificationNeed = String(analysis?.verification_need || '').trim().toLowerCase();
298
265
  const freshnessRisk = String(analysis?.freshness_risk || '').trim().toLowerCase();
299
266
  const planningDepth = String(analysis?.planning_depth || '').trim().toLowerCase();
300
- const targets = collectResearchTargets(analysis, goalContext);
301
- const researchToolHint = hasResearchToolHint(analysis);
267
+ const targets = declaredTargets;
302
268
 
303
269
  if (mode === 'direct_answer' && verificationNeed === 'none' && freshnessRisk === 'none') {
304
270
  return 'none';
305
271
  }
306
272
 
307
- // External/source-backed work only. Pure local implementation tasks must not
308
- // inherit a research burden just because they are complex or multi-step.
309
273
  const needsExternalEvidence = (
310
274
  targets.length > 0
311
- || researchToolHint
312
275
  || freshnessRisk === 'possible'
313
276
  || freshnessRisk === 'high'
314
- || verificationNeed === 'required'
315
277
  );
316
278
  if (!needsExternalEvidence) {
317
279
  return 'none';
@@ -342,32 +304,32 @@ function isSuccessfulResearchExecution(item = {}) {
342
304
  return Boolean(item.evidenceRelevant || item.dependsOnOutput || item.stateChanged);
343
305
  }
344
306
 
345
- function isPrimaryResearchSource(source = '') {
346
- return PRIMARY_RESEARCH_SOURCES.has(String(source || '').trim().toLowerCase());
307
+ function selectResearchEvidenceCandidates(toolExecutions = [], maxItems = 80) {
308
+ const candidates = (Array.isArray(toolExecutions) ? toolExecutions : [])
309
+ .map((execution, index) => ({ execution, evidenceIndex: index + 1 }))
310
+ .filter(({ execution }) => isSuccessfulResearchExecution(execution));
311
+ if (candidates.length <= maxItems) return candidates;
312
+
313
+ const firstCount = Math.floor(maxItems / 4);
314
+ return [
315
+ ...candidates.slice(0, firstCount),
316
+ ...candidates.slice(-(maxItems - firstCount)),
317
+ ];
347
318
  }
348
319
 
349
- function isSecondaryResearchSource(source = '') {
350
- return SECONDARY_RESEARCH_SOURCES.has(String(source || '').trim().toLowerCase());
320
+ function summarizeResearchEvidenceCatalog(toolExecutions = [], maxItems = 80) {
321
+ return selectResearchEvidenceCandidates(toolExecutions, maxItems)
322
+ .map(({ execution, evidenceIndex }) => (
323
+ `E${evidenceIndex}. ${execution.toolName} [${execution.evidenceSource || 'tool'}] :: ${clampRunContext(execution.summary || '', 160)}`
324
+ ))
325
+ .join('\n');
351
326
  }
352
327
 
353
- function executionMentionsTarget(execution = {}, target = '') {
354
- const needle = String(target || '').trim().toLowerCase();
355
- if (!needle) return false;
356
- const haystack = [
357
- execution.summary,
358
- execution.toolName,
359
- execution.evidenceSource,
360
- JSON.stringify(execution.input || {}),
361
- ].join(' ').toLowerCase();
362
- if (haystack.includes(needle)) return true;
363
-
364
- const tokens = needle
365
- .split(/[^a-z0-9+]+/i)
366
- .map((token) => token.trim())
367
- .filter((token) => token.length >= 3);
368
- if (tokens.length === 0) return false;
369
- const matched = tokens.filter((token) => haystack.includes(token)).length;
370
- return matched >= Math.min(2, tokens.length);
328
+ function researchExecutionSignature(execution = {}) {
329
+ return JSON.stringify([
330
+ String(execution.toolName || '').trim(),
331
+ execution.input && typeof execution.input === 'object' ? execution.input : {},
332
+ ]);
371
333
  }
372
334
 
373
335
  function assessResearchAdequacy({
@@ -380,111 +342,53 @@ function assessResearchAdequacy({
380
342
 
381
343
  const successful = (Array.isArray(toolExecutions) ? toolExecutions : [])
382
344
  .filter(isSuccessfulResearchExecution);
383
- const primary = successful.filter((item) => isPrimaryResearchSource(item.evidenceSource));
384
- const secondary = successful.filter((item) => isSecondaryResearchSource(item.evidenceSource));
385
- const coveredTargets = targets.filter((target) => (
386
- successful.some((item) => executionMentionsTarget(item, target))
387
- ));
388
- const primaryCoveredTargets = targets.filter((target) => (
389
- primary.some((item) => executionMentionsTarget(item, target))
390
- ));
391
- const uncoveredTargets = targets.filter((target) => !coveredTargets.includes(target));
392
- const primaryUncoveredTargets = targets.filter((target) => !primaryCoveredTargets.includes(target));
393
-
394
- const requiredPrimarySources = intensity === 'deep'
395
- ? (targets.length > 0 ? Math.min(4, targets.length) : 2)
396
- : intensity === 'light'
397
- ? 1
398
- : 0;
399
- const requiredSecondarySources = intensity === 'deep' ? 1 : 0;
400
- const requiredTargetCoverage = intensity === 'deep'
401
- ? targets.length
402
- : intensity === 'light'
403
- ? Math.min(targets.length, 1)
404
- : 0;
405
- const requiredPrimaryTargetCoverage = intensity === 'deep'
406
- ? targets.length
407
- : 0;
408
-
409
- const missing = [];
410
- if (primary.length < requiredPrimarySources) {
411
- missing.push(
412
- `Need ${requiredPrimarySources} primary source open/fetch/inspect step(s); have ${primary.length}.`,
413
- );
414
- }
415
- if (secondary.length < requiredSecondarySources && primary.length < requiredPrimarySources) {
416
- missing.push('Need at least one search lead before finishing deep research.');
417
- }
418
- if (targets.length > 0 && coveredTargets.length < requiredTargetCoverage) {
419
- missing.push(
420
- `Need evidence for: ${uncoveredTargets.slice(0, 4).join('; ') || targets.slice(0, 4).join('; ')}.`,
421
- );
422
- }
423
- if (targets.length > 0 && primaryCoveredTargets.length < requiredPrimaryTargetCoverage) {
424
- missing.push(
425
- `Need primary-source evidence for: ${primaryUncoveredTargets.slice(0, 4).join('; ') || targets.slice(0, 4).join('; ')}.`,
426
- );
427
- }
428
- if (intensity === 'deep' && primary.length === 0 && secondary.length > 0) {
429
- missing.push('Search snippets alone are not enough; open primary sources for the key claims.');
430
- }
431
- if (intensity === 'light' && primary.length === 0 && secondary.length === 0) {
432
- missing.push('Need at least one successful search or primary-source check before finishing.');
433
- }
434
-
435
- const adequate = intensity === 'none' ? true : missing.length === 0;
345
+ const uniqueEvidenceCandidateCount = new Set(
346
+ successful.map((item) => researchExecutionSignature(item)),
347
+ ).size;
348
+ const structurallyReady = intensity === 'none' || uniqueEvidenceCandidateCount > 0;
349
+ const missing = structurallyReady
350
+ ? []
351
+ : ['No successful source-bearing tool evidence is available for semantic review.'];
436
352
  const nextActions = [];
437
- if (!adequate) {
438
- if (primaryUncoveredTargets.length > 0) {
439
- nextActions.push(
440
- `Open or fetch primary sources for each remaining target: ${primaryUncoveredTargets.slice(0, 4).join('; ')}.`,
441
- );
442
- } else if (uncoveredTargets.length > 0) {
443
- nextActions.push(
444
- `Research each remaining target separately: ${uncoveredTargets.slice(0, 4).join('; ')}.`,
445
- );
446
- }
447
- if (primary.length < requiredPrimarySources) {
448
- nextActions.push('Open or fetch primary pages/docs for the remaining claims instead of guessing from memory or snippets.');
449
- }
450
- if (secondary.length === 0 && intensity === 'deep') {
451
- nextActions.push('Run targeted searches, then open the strongest sources.');
452
- }
353
+ if (!structurallyReady) {
354
+ nextActions.push('Gather source-backed evidence before requesting completion.');
355
+ } else if (intensity !== 'none') {
356
+ nextActions.push('Have the completion judge map each requested target to concrete evidence entries and assess source quality.');
453
357
  }
454
358
 
455
359
  return {
456
360
  intensity,
457
- adequate,
458
- requiredPrimarySources,
459
- requiredSecondarySources,
460
- requiredTargetCoverage,
461
- requiredPrimaryTargetCoverage,
462
- primarySourceCount: primary.length,
463
- secondarySourceCount: secondary.length,
361
+ adequate: structurallyReady,
362
+ structurallyReady,
363
+ semanticReviewRequired: intensity !== 'none',
364
+ evidenceCandidateCount: successful.length,
365
+ uniqueEvidenceCandidateCount,
464
366
  targets,
465
- coveredTargets,
466
- primaryCoveredTargets,
467
- uncoveredTargets,
468
- primaryUncoveredTargets,
367
+ coveredTargets: [],
368
+ primaryCoveredTargets: [],
369
+ uncoveredTargets: intensity === 'none' ? [] : targets,
370
+ primaryUncoveredTargets: intensity === 'none' ? [] : targets,
469
371
  missing,
470
372
  nextActions,
471
- reason: adequate
373
+ reason: structurallyReady
472
374
  ? (intensity === 'none'
473
375
  ? 'No research burden for this run.'
474
- : 'Research evidence covers the requested targets.')
376
+ : 'Source candidates exist; the AI completion judge must still validate target coverage and source quality.')
475
377
  : clampResearchText(missing.join(' ') || 'Research evidence is still incomplete.', 320),
476
378
  };
477
379
  }
478
380
 
479
381
  function formatResearchAdequacyGuidance(assessment = null) {
480
- if (!assessment || assessment.adequate !== false) return '';
382
+ if (!assessment || assessment.intensity === 'none') return '';
481
383
  const lines = [
482
- 'Research self-check: evidence is still incomplete for this run.',
384
+ assessment.structurallyReady
385
+ ? 'Research self-check: source candidates exist, but semantic target coverage is not inferred from tool names, arguments, or token overlap.'
386
+ : 'Research self-check: no successful source evidence is available yet.',
483
387
  assessment.reason ? `Gap: ${assessment.reason}` : '',
484
388
  assessment.nextActions?.length
485
389
  ? `Next safe steps:\n- ${assessment.nextActions.join('\n- ')}`
486
390
  : '',
487
- 'Do not complete with memory, guesses, or a partial comparison while these gaps remain. Gather the missing evidence first, or return a blocker that names exactly what could not be verified.',
391
+ 'The completion judge must explicitly map every requested target to supporting evidence and judge whether that evidence is primary, secondary, or merely contextual.',
488
392
  ];
489
393
  return lines.filter(Boolean).join('\n');
490
394
  }
@@ -569,6 +473,8 @@ module.exports = {
569
473
  isSubstantiveProgressEvidence,
570
474
  isSubstantiveProgressToolName,
571
475
  resolveResearchIntensity,
476
+ selectResearchEvidenceCandidates,
477
+ summarizeResearchEvidenceCatalog,
572
478
  summarizeProgressToolExecutions,
573
479
  summarizeToolExecutions,
574
480
  summarizeAvailableTools,
@@ -2400,6 +2400,13 @@ async function executeTool(toolName, args, context, engine) {
2400
2400
  if (!engine || !runId) {
2401
2401
  return { error: 'Interim updates require an active run.' };
2402
2402
  }
2403
+ if (engine.getRunMeta(runId)?.messagingContext?.behavior?.isGroup === true) {
2404
+ return {
2405
+ sent: false,
2406
+ skipped: true,
2407
+ reason: 'Interim progress updates are suppressed in shared rooms.',
2408
+ };
2409
+ }
2403
2410
  const interimContent = typeof args.content === 'string' ? args.content : '';
2404
2411
  const expectsReply = args.expects_reply === true;
2405
2412
  const deferFollowUp = args.defer_follow_up === true;
@@ -2495,13 +2502,58 @@ async function executeTool(toolName, args, context, engine) {
2495
2502
  if (triggerSource === 'messaging' && originDelivery) {
2496
2503
  await engine?.stopMessagingProgressSupervisor?.(runId);
2497
2504
  }
2498
- const sendResult = await manager.sendMessage(userId, args.platform, args.to, args.content, {
2499
- agentId,
2500
- mediaPath: args.media_path,
2501
- runId,
2502
- persistConversation: triggerSource === 'schedule' || triggerSource === 'tasks',
2503
- signal,
2504
- });
2505
+ const behavior = runState?.messagingContext?.behavior;
2506
+ const behaviorPipeline = app?.locals?.behaviorPipeline;
2507
+ let deliveredContent = normalizedMessage;
2508
+ let sendResult;
2509
+ if (
2510
+ originDelivery
2511
+ && behavior
2512
+ && behavior.enabled !== false
2513
+ && behaviorPipeline
2514
+ && typeof behaviorPipeline.refineAndMaybeDeliver === 'function'
2515
+ ) {
2516
+ const behaviorResult = await behaviorPipeline.refineAndMaybeDeliver({
2517
+ userId,
2518
+ agentId,
2519
+ msg: behavior.message,
2520
+ config: behavior.config,
2521
+ draft: normalizedMessage,
2522
+ messagingManager: manager,
2523
+ runId,
2524
+ signal,
2525
+ mediaPath: args.media_path,
2526
+ turnEpoch: behavior.turnEpoch,
2527
+ deliver: true,
2528
+ });
2529
+ deliveredContent = behaviorResult.content;
2530
+ if (behaviorResult.delivered) {
2531
+ sendResult = { success: true, behavior: true, result: behaviorResult.delivery };
2532
+ } else if (behaviorResult.suppressed) {
2533
+ markProactiveNoResponse({ runState, deliveryState });
2534
+ sendResult = {
2535
+ success: true,
2536
+ sent: false,
2537
+ suppressed: true,
2538
+ reason: behaviorResult.reasonCodes?.[0] || 'behavior_suppressed',
2539
+ };
2540
+ } else {
2541
+ sendResult = {
2542
+ success: false,
2543
+ error: behaviorResult.delivery?.error
2544
+ || behaviorResult.delivery?.reason
2545
+ || 'Behavior delivery was not confirmed.',
2546
+ };
2547
+ }
2548
+ } else {
2549
+ sendResult = await manager.sendMessage(userId, args.platform, args.to, args.content, {
2550
+ agentId,
2551
+ mediaPath: args.media_path,
2552
+ runId,
2553
+ persistConversation: triggerSource === 'schedule' || triggerSource === 'tasks',
2554
+ signal,
2555
+ });
2556
+ }
2505
2557
  // Track that the agent explicitly sent a message during this run
2506
2558
  if (
2507
2559
  !suppressReply
@@ -2509,7 +2561,7 @@ async function executeTool(toolName, args, context, engine) {
2509
2561
  && sendResult?.suppressed !== true
2510
2562
  && originDelivery
2511
2563
  ) {
2512
- markProactiveMessageSent({ runState, deliveryState, content: normalizedMessage });
2564
+ markProactiveMessageSent({ runState, deliveryState, content: deliveredContent });
2513
2565
  if (runState && triggerSource === 'messaging') {
2514
2566
  runState.explicitMessageSent = true;
2515
2567
  }
@@ -0,0 +1,251 @@
1
+ 'use strict';
2
+
3
+ const db = require('../../db/database');
4
+ const { isMainAgent } = require('../agents/manager');
5
+ const { MODULE_IDS, cloneDefaults, DEFAULT_MODULE_CONFIG } = require('./defaults');
6
+
7
+ const SETTINGS_KEY = 'behavior_modules_config';
8
+ const PARTICIPATION_MODES = new Set(['automatic', 'mention_only', 'always']);
9
+ const MODEL_PURPOSES = new Set(['fast', 'general']);
10
+ const DELIVERY_STYLES = new Set(['single', 'natural_bubbles']);
11
+
12
+ function clampNumber(value, min, max, fallback) {
13
+ const number = Number(value);
14
+ if (!Number.isFinite(number)) return fallback;
15
+ return Math.min(max, Math.max(min, number));
16
+ }
17
+
18
+ function asObject(value) {
19
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
20
+ }
21
+
22
+ function normalizeModules(rawModules, fallbackEnabled = true, sparse = false) {
23
+ const source = asObject(rawModules);
24
+ const modules = {};
25
+ for (const id of MODULE_IDS) {
26
+ if (sparse && !Object.prototype.hasOwnProperty.call(source, id)) continue;
27
+ const entry = asObject(source[id]);
28
+ modules[id] = {
29
+ enabled: entry.enabled == null ? fallbackEnabled : entry.enabled !== false,
30
+ };
31
+ }
32
+ return modules;
33
+ }
34
+
35
+ function normalizeOptionalString(value, fallback = null) {
36
+ if (value == null) return fallback;
37
+ const normalized = String(value).trim();
38
+ return normalized || null;
39
+ }
40
+
41
+ function normalizeLeafConfig(raw = {}, base = cloneDefaults(), sparse = false) {
42
+ const input = asObject(raw);
43
+ const output = {};
44
+ const assign = (key, value) => {
45
+ if (!sparse || Object.prototype.hasOwnProperty.call(input, key)) output[key] = value;
46
+ };
47
+ const enabled = input.enabled == null ? base.enabled !== false : input.enabled !== false;
48
+ assign('enabled', enabled);
49
+ if (!sparse || Object.prototype.hasOwnProperty.call(input, 'modules')) {
50
+ output.modules = normalizeModules(input.modules, enabled, sparse);
51
+ }
52
+ assign(
53
+ 'participationMode',
54
+ PARTICIPATION_MODES.has(String(input.participationMode || ''))
55
+ ? String(input.participationMode)
56
+ : (base.participationMode || DEFAULT_MODULE_CONFIG.participationMode),
57
+ );
58
+ assign('minimumNeedScore', clampNumber(
59
+ input.minimumNeedScore,
60
+ 0,
61
+ 1,
62
+ base.minimumNeedScore ?? DEFAULT_MODULE_CONFIG.minimumNeedScore,
63
+ ));
64
+ assign('batchWindowMs', Math.round(clampNumber(
65
+ input.batchWindowMs,
66
+ 0,
67
+ 5000,
68
+ base.batchWindowMs ?? DEFAULT_MODULE_CONFIG.batchWindowMs,
69
+ )));
70
+ assign('decisionContextMessageLimit', Math.round(clampNumber(
71
+ input.decisionContextMessageLimit,
72
+ 4,
73
+ 30,
74
+ base.decisionContextMessageLimit ?? DEFAULT_MODULE_CONFIG.decisionContextMessageLimit,
75
+ )));
76
+ assign(
77
+ 'decisionModelId',
78
+ normalizeOptionalString(input.decisionModelId, base.decisionModelId || null),
79
+ );
80
+ assign(
81
+ 'decisionModelPurpose',
82
+ MODEL_PURPOSES.has(String(input.decisionModelPurpose || ''))
83
+ ? String(input.decisionModelPurpose)
84
+ : (base.decisionModelPurpose || DEFAULT_MODULE_CONFIG.decisionModelPurpose),
85
+ );
86
+ assign(
87
+ 'deliveryStyle',
88
+ DELIVERY_STYLES.has(String(input.deliveryStyle || ''))
89
+ ? String(input.deliveryStyle)
90
+ : (base.deliveryStyle || DEFAULT_MODULE_CONFIG.deliveryStyle),
91
+ );
92
+ assign('maxBubbles', Math.round(clampNumber(
93
+ input.maxBubbles,
94
+ 1,
95
+ 5,
96
+ base.maxBubbles ?? DEFAULT_MODULE_CONFIG.maxBubbles,
97
+ )));
98
+ assign('bubbleGapMs', Math.round(clampNumber(
99
+ input.bubbleGapMs,
100
+ 0,
101
+ 5000,
102
+ base.bubbleGapMs ?? DEFAULT_MODULE_CONFIG.bubbleGapMs,
103
+ )));
104
+ assign('normsRefreshMessageGap', Math.round(clampNumber(
105
+ input.normsRefreshMessageGap,
106
+ 3,
107
+ 200,
108
+ base.normsRefreshMessageGap ?? DEFAULT_MODULE_CONFIG.normsRefreshMessageGap,
109
+ )));
110
+ assign('observabilityIntervalMinutes', Math.round(clampNumber(
111
+ input.observabilityIntervalMinutes,
112
+ 30,
113
+ 10080,
114
+ base.observabilityIntervalMinutes ?? DEFAULT_MODULE_CONFIG.observabilityIntervalMinutes,
115
+ )));
116
+ return output;
117
+ }
118
+
119
+ function normalizeStoredConfig(raw) {
120
+ const base = cloneDefaults();
121
+ const input = asObject(raw);
122
+ const normalized = {
123
+ schemaVersion: DEFAULT_MODULE_CONFIG.schemaVersion,
124
+ ...normalizeLeafConfig(input, base),
125
+ };
126
+ const platformOverrides = {};
127
+ for (const [platform, value] of Object.entries(asObject(input.platformOverrides))) {
128
+ const key = String(platform || '').trim();
129
+ if (!key) continue;
130
+ platformOverrides[key] = normalizeLeafConfig(value, normalized, true);
131
+ }
132
+ const roomOverrides = {};
133
+ const rawRoomOverrides = Object.keys(asObject(input.roomOverrides)).length
134
+ ? asObject(input.roomOverrides)
135
+ : asObject(input.groupOverrides);
136
+ for (const [roomKey, value] of Object.entries(rawRoomOverrides)) {
137
+ const key = String(roomKey || '').trim();
138
+ if (!key) continue;
139
+ roomOverrides[key] = normalizeLeafConfig(value, normalized, true);
140
+ }
141
+ return {
142
+ ...normalized,
143
+ platformOverrides,
144
+ roomOverrides,
145
+ };
146
+ }
147
+
148
+ function readSettingRow(userId, agentId) {
149
+ const row = db.prepare(
150
+ 'SELECT value FROM agent_settings WHERE user_id = ? AND agent_id = ? AND key = ?',
151
+ ).get(userId, agentId, SETTINGS_KEY);
152
+ if (row?.value != null) return row.value;
153
+ if (isMainAgent(userId, agentId)) {
154
+ const legacy = db.prepare(
155
+ 'SELECT value FROM user_settings WHERE user_id = ? AND key = ?',
156
+ ).get(userId, SETTINGS_KEY);
157
+ return legacy?.value;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function parseStoredValue(value) {
163
+ if (value == null || value === '') return cloneDefaults();
164
+ if (typeof value === 'object') return normalizeStoredConfig(value);
165
+ try {
166
+ return normalizeStoredConfig(JSON.parse(value));
167
+ } catch {
168
+ return cloneDefaults();
169
+ }
170
+ }
171
+
172
+ function getBehaviorConfig(userId, agentId = null) {
173
+ return parseStoredValue(readSettingRow(userId, agentId));
174
+ }
175
+
176
+ function setBehaviorConfig(userId, agentId, config) {
177
+ const normalized = normalizeStoredConfig(config);
178
+ db.prepare(
179
+ `INSERT INTO agent_settings (user_id, agent_id, key, value)
180
+ VALUES (?, ?, ?, ?)
181
+ ON CONFLICT(user_id, agent_id, key) DO UPDATE SET value = excluded.value`,
182
+ ).run(userId, agentId, SETTINGS_KEY, JSON.stringify(normalized));
183
+ return normalized;
184
+ }
185
+
186
+ function roomConfigKey(platform, chatId) {
187
+ return `${String(platform || '').trim()}::${String(chatId || '').trim()}`;
188
+ }
189
+
190
+ function mergeConfigs(base, override) {
191
+ if (!override) return base;
192
+ return {
193
+ ...base,
194
+ ...override,
195
+ modules: {
196
+ ...asObject(base.modules),
197
+ ...asObject(override.modules),
198
+ },
199
+ };
200
+ }
201
+
202
+ function resolveBehaviorConfig(userId, agentId, { platform = null, chatId = null, isGroup = false } = {}) {
203
+ const storedValue = readSettingRow(userId, agentId);
204
+ const stored = parseStoredValue(storedValue);
205
+ let effective = normalizeLeafConfig(stored, cloneDefaults());
206
+ let participationModeSource = storedValue == null ? 'default' : 'agent';
207
+ const platformKey = String(platform || '').trim();
208
+ if (platformKey && stored.platformOverrides?.[platformKey]) {
209
+ effective = mergeConfigs(effective, stored.platformOverrides[platformKey]);
210
+ if (Object.prototype.hasOwnProperty.call(stored.platformOverrides[platformKey], 'participationMode')) {
211
+ participationModeSource = 'platform';
212
+ }
213
+ }
214
+ if (isGroup && platformKey && chatId) {
215
+ const key = roomConfigKey(platformKey, chatId);
216
+ if (stored.roomOverrides?.[key]) {
217
+ effective = mergeConfigs(effective, stored.roomOverrides[key]);
218
+ if (Object.prototype.hasOwnProperty.call(stored.roomOverrides[key], 'participationMode')) {
219
+ participationModeSource = 'room';
220
+ }
221
+ }
222
+ }
223
+ effective.modules = normalizeModules(effective.modules, effective.enabled !== false);
224
+ effective.platformOverrides = stored.platformOverrides || {};
225
+ effective.roomOverrides = stored.roomOverrides || {};
226
+ effective.participationModeSource = participationModeSource;
227
+ effective.scope = {
228
+ platform: platformKey || null,
229
+ chatId: chatId ? String(chatId) : null,
230
+ isGroup: Boolean(isGroup),
231
+ roomKey: isGroup && platformKey && chatId ? roomConfigKey(platformKey, chatId) : null,
232
+ };
233
+ return effective;
234
+ }
235
+
236
+ function isModuleEnabled(config, moduleId) {
237
+ if (!config || config.enabled === false) return false;
238
+ const entry = config.modules?.[moduleId];
239
+ if (!entry) return false;
240
+ return entry.enabled !== false;
241
+ }
242
+
243
+ module.exports = {
244
+ SETTINGS_KEY,
245
+ getBehaviorConfig,
246
+ setBehaviorConfig,
247
+ resolveBehaviorConfig,
248
+ normalizeStoredConfig,
249
+ roomConfigKey,
250
+ isModuleEnabled,
251
+ };