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
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ const { requestStructuredJson } = require('../model_client');
4
+ const { buildDecisionPacket, loadRecentRoomMessages } = require('../signals');
5
+ const { getThreadState, setThreadState } = require('../state');
6
+ const { isModuleEnabled } = require('../config');
7
+
8
+ const SYSTEM_PROMPT = `You are NeoAgent's group turn-taking gate.
9
+ Decide whether the agent should speak now or stay silent in a multi-party chat.
10
+ Default posture in groups: prefer holding back. Speak only when the agent would add clear value, is addressed, can usefully answer an open need, should correct a harmful misunderstanding, or media/context clearly calls for a response.
11
+ Judge the meaning and flow of the provided room context. Do not use phrase matching or keyword rules.
12
+ Return JSON only with keys:
13
+ decision ("speak" or "stay_silent"),
14
+ needScore (0-1 number measuring how worthwhile an agent contribution is now),
15
+ confidence (0-1 number),
16
+ reasonCodes (array of short snake_case strings),
17
+ urgency ("low"|"medium"|"high"),
18
+ rationale (one short sentence).`;
19
+
20
+ function normalizeDecision(raw, fallback) {
21
+ const decision = String(raw?.decision || fallback.decision || 'stay_silent').trim().toLowerCase();
22
+ const score = (value, fallbackValue) => {
23
+ const number = Number(value);
24
+ const normalized = Number.isFinite(number) ? number : Number(fallbackValue);
25
+ return Math.max(0, Math.min(1, Number.isFinite(normalized) ? normalized : 0.5));
26
+ };
27
+ const normalized = {
28
+ decision: decision === 'speak' ? 'speak' : 'stay_silent',
29
+ needScore: score(raw?.needScore, fallback.needScore),
30
+ confidence: score(raw?.confidence, fallback.confidence),
31
+ reasonCodes: Array.isArray(raw?.reasonCodes)
32
+ ? raw.reasonCodes.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 8)
33
+ : (fallback.reasonCodes || []),
34
+ urgency: ['low', 'medium', 'high'].includes(String(raw?.urgency || ''))
35
+ ? String(raw.urgency)
36
+ : (fallback.urgency || 'low'),
37
+ rationale: String(raw?.rationale || fallback.rationale || '').trim().slice(0, 240),
38
+ tokenPath: fallback.tokenPath || 'gate_only',
39
+ model: raw?.model || fallback.model || null,
40
+ };
41
+ const usage = Number(raw?.usage ?? fallback.usage);
42
+ if (Number.isFinite(usage)) normalized.usage = usage;
43
+ return normalized;
44
+ }
45
+
46
+ function localFallbackDecision(packet, config) {
47
+ if (!packet.chat.isGroup) {
48
+ return normalizeDecision({
49
+ decision: 'speak',
50
+ needScore: 1,
51
+ confidence: 1,
52
+ reasonCodes: ['direct_chat'],
53
+ urgency: 'medium',
54
+ rationale: 'Direct chats always engage.',
55
+ }, { tokenPath: 'gate_skip' });
56
+ }
57
+
58
+ if (packet.event.wasMentioned || packet.event.repliedToAgent) {
59
+ return normalizeDecision({
60
+ decision: 'speak',
61
+ needScore: 1,
62
+ confidence: 0.85,
63
+ reasonCodes: [packet.event.repliedToAgent ? 'reply_to_agent' : 'addressed'],
64
+ urgency: 'medium',
65
+ rationale: 'Fallback engaged because platform metadata directly addresses the agent.',
66
+ }, { tokenPath: 'gate_fallback' });
67
+ }
68
+ return normalizeDecision({
69
+ decision: 'stay_silent',
70
+ needScore: 0,
71
+ confidence: 0.7,
72
+ reasonCodes: ['prefer_hold_back', 'model_unavailable'],
73
+ urgency: 'low',
74
+ rationale: 'Fallback gate holds back in groups when address is unclear.',
75
+ }, { tokenPath: 'gate_fallback' });
76
+ }
77
+
78
+ async function shouldEngage(ctx) {
79
+ const startedAt = Date.now();
80
+ const {
81
+ userId,
82
+ agentId,
83
+ msg,
84
+ config,
85
+ signal = null,
86
+ memoryHints = [],
87
+ agentEngine,
88
+ turnEpoch,
89
+ } = ctx;
90
+
91
+ if (!msg?.isGroup || !isModuleEnabled(config, 'turn_taking') || config.enabled === false) {
92
+ return {
93
+ decision: 'speak',
94
+ needScore: 1,
95
+ confidence: 1,
96
+ reasonCodes: msg?.isGroup ? ['turn_taking_disabled'] : ['direct_chat'],
97
+ urgency: 'medium',
98
+ rationale: msg?.isGroup ? 'Turn-taking disabled; engaging.' : 'Direct chat always engages.',
99
+ tokenPath: 'gate_skip',
100
+ latencyMs: Date.now() - startedAt,
101
+ turnEpoch,
102
+ };
103
+ }
104
+
105
+ const mode = config.participationMode || 'automatic';
106
+ if (mode === 'always') {
107
+ return {
108
+ decision: 'speak',
109
+ needScore: 1,
110
+ confidence: 1,
111
+ reasonCodes: ['participation_always'],
112
+ urgency: 'medium',
113
+ rationale: 'Room participation is configured to always engage.',
114
+ tokenPath: 'gate_skip',
115
+ latencyMs: Date.now() - startedAt,
116
+ turnEpoch,
117
+ };
118
+ }
119
+ if (mode === 'mention_only' && !msg.wasMentioned && !msg.repliedToAgent) {
120
+ return {
121
+ decision: 'stay_silent',
122
+ needScore: 0,
123
+ confidence: 1,
124
+ reasonCodes: ['mention_only'],
125
+ urgency: 'low',
126
+ rationale: 'Room participation requires a structural mention or reply.',
127
+ tokenPath: 'gate_skip',
128
+ latencyMs: Date.now() - startedAt,
129
+ turnEpoch,
130
+ };
131
+ }
132
+ if (mode === 'mention_only') {
133
+ return {
134
+ decision: 'speak',
135
+ needScore: 1,
136
+ confidence: 1,
137
+ reasonCodes: [msg.repliedToAgent ? 'reply_to_agent' : 'addressed'],
138
+ urgency: 'medium',
139
+ rationale: 'The room is mention-only and platform metadata directly addressed the agent.',
140
+ tokenPath: 'gate_skip',
141
+ latencyMs: Date.now() - startedAt,
142
+ turnEpoch,
143
+ };
144
+ }
145
+
146
+ const threadState = getThreadState(userId, agentId, msg.platform, msg.chatId);
147
+ const roomMessages = loadRecentRoomMessages({
148
+ userId,
149
+ agentId,
150
+ platform: msg.platform,
151
+ chatId: msg.chatId,
152
+ limit: config.decisionContextMessageLimit,
153
+ });
154
+ const packet = buildDecisionPacket({
155
+ msg,
156
+ config,
157
+ threadState,
158
+ roomMessages,
159
+ localMemoryHints: memoryHints,
160
+ });
161
+
162
+ let decision;
163
+ try {
164
+ const result = await requestStructuredJson({
165
+ agentEngine,
166
+ userId,
167
+ agentId,
168
+ modelId: config.decisionModelId,
169
+ purpose: config.decisionModelPurpose,
170
+ system: SYSTEM_PROMPT,
171
+ prompt: JSON.stringify(packet),
172
+ signal,
173
+ maxTokens: 220,
174
+ fallback: {
175
+ decision: 'stay_silent',
176
+ needScore: 0,
177
+ confidence: 0.55,
178
+ reasonCodes: ['parse_fallback'],
179
+ urgency: 'low',
180
+ rationale: 'The decision could not be parsed.',
181
+ },
182
+ });
183
+ decision = normalizeDecision(result.parsed || {}, {
184
+ decision: 'stay_silent',
185
+ needScore: 0,
186
+ confidence: 0.55,
187
+ reasonCodes: ['parse_fallback'],
188
+ urgency: 'low',
189
+ rationale: 'Could not parse gate response; holding back.',
190
+ tokenPath: 'gate_only',
191
+ model: result.modelSelectionId || result.model,
192
+ });
193
+ decision.model = result.modelSelectionId || result.model;
194
+ decision.usage = Number(result.usage || 0);
195
+ } catch (error) {
196
+ if (signal?.aborted) throw error;
197
+ decision = localFallbackDecision(packet, config);
198
+ decision.failureCode = 'model_unavailable';
199
+ }
200
+
201
+ // Confidence measures certainty. Need score measures whether speaking is worthwhile.
202
+ if (
203
+ decision.decision === 'speak'
204
+ && Number(decision.needScore || 0) < Number(config.minimumNeedScore ?? 0.72)
205
+ && !packet.event.wasMentioned
206
+ && !packet.event.repliedToAgent
207
+ ) {
208
+ decision = normalizeDecision({
209
+ ...decision,
210
+ decision: 'stay_silent',
211
+ reasonCodes: [...(decision.reasonCodes || []), 'below_need_threshold'],
212
+ rationale: decision.rationale || 'The contribution value is below the room threshold.',
213
+ }, { tokenPath: decision.tokenPath || 'gate_only', model: decision.model });
214
+ }
215
+
216
+ setThreadState(userId, agentId, msg.platform, msg.chatId, {
217
+ lastDecision: decision.decision,
218
+ lastDecisionAt: new Date().toISOString(),
219
+ recentSilenceCount: decision.decision === 'stay_silent'
220
+ ? Number(threadState.recentSilenceCount || 0) + 1
221
+ : 0,
222
+ });
223
+
224
+ return {
225
+ ...decision,
226
+ latencyMs: Date.now() - startedAt,
227
+ turnEpoch,
228
+ };
229
+ }
230
+
231
+ module.exports = {
232
+ id: 'turn_taking',
233
+ decide: shouldEngage,
234
+ shouldEngage,
235
+ normalizeDecision,
236
+ localFallbackDecision,
237
+ };
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ const { resolveBehaviorConfig, isModuleEnabled } = require('./config');
4
+ const {
5
+ bumpTurnEpoch,
6
+ getThreadState,
7
+ isTurnCurrent,
8
+ markSpoke,
9
+ } = require('./state');
10
+ const { createBehaviorRegistry } = require('./registry');
11
+ const { BEHAVIOR_MODULES } = require('./modules');
12
+ const { createServiceLogger } = require('../../utils/logger');
13
+
14
+ const logger = createServiceLogger('Behavior');
15
+
16
+ function createBehaviorPipeline(deps = {}) {
17
+ const memoryManager = deps.memoryManager || null;
18
+ const agentEngine = deps.agentEngine || null;
19
+ const io = deps.io || null;
20
+ const registry = createBehaviorRegistry(BEHAVIOR_MODULES);
21
+
22
+ function effectiveConfig(userId, agentId, msg) {
23
+ const config = resolveBehaviorConfig(userId, agentId, {
24
+ platform: msg.platform,
25
+ chatId: msg.chatId,
26
+ isGroup: Boolean(msg.isGroup),
27
+ });
28
+ if (
29
+ msg.isGroup
30
+ && config.participationModeSource === 'default'
31
+ && msg.accessPolicyRequireMention === true
32
+ ) {
33
+ config.participationMode = 'mention_only';
34
+ }
35
+ return config;
36
+ }
37
+
38
+ function noteInbound({ userId, agentId, msg }) {
39
+ const state = bumpTurnEpoch(userId, agentId, msg.platform, msg.chatId);
40
+ msg.behaviorTurnEpoch = state.turnEpoch;
41
+ return state.turnEpoch;
42
+ }
43
+
44
+ function scheduleBackground(baseCtx) {
45
+ const task = async (backgroundSignal) => {
46
+ const ctx = { ...baseCtx, signal: backgroundSignal };
47
+ await registry.run('afterTurn', ctx);
48
+ };
49
+ const key = [
50
+ 'social-background',
51
+ baseCtx.userId,
52
+ baseCtx.agentId || 'main',
53
+ baseCtx.msg.platform,
54
+ baseCtx.msg.chatId,
55
+ ].join(':');
56
+ const promise = agentEngine?.trackBackgroundTask
57
+ ? agentEngine.trackBackgroundTask(task, { key, coalesce: true, signal: baseCtx.signal })
58
+ : Promise.resolve().then(() => task(baseCtx.signal));
59
+ promise.catch((error) => {
60
+ if (!baseCtx.signal?.aborted) logger.warn('background analysis failed:', error?.message || error);
61
+ });
62
+ }
63
+
64
+ async function handleInbound({ userId, agentId, msg, signal = null }) {
65
+ const config = effectiveConfig(userId, agentId, msg);
66
+ const turnEpoch = Number(msg.behaviorTurnEpoch)
67
+ || noteInbound({ userId, agentId, msg });
68
+ if (config.enabled === false) {
69
+ return {
70
+ engage: true,
71
+ decision: {
72
+ decision: 'speak',
73
+ needScore: 1,
74
+ confidence: 1,
75
+ reasonCodes: ['behavior_disabled'],
76
+ urgency: 'medium',
77
+ rationale: 'Behavior modules are disabled; using the standard response path.',
78
+ tokenPath: 'gate_skip',
79
+ latencyMs: 0,
80
+ turnEpoch,
81
+ },
82
+ config,
83
+ promptBlocks: [],
84
+ observeResult: null,
85
+ };
86
+ }
87
+
88
+ const baseCtx = {
89
+ userId,
90
+ agentId,
91
+ msg,
92
+ config,
93
+ signal,
94
+ memoryManager,
95
+ agentEngine,
96
+ turnEpoch,
97
+ isModuleEnabled: (moduleId) => isModuleEnabled(config, moduleId),
98
+ };
99
+
100
+ const observations = await registry.run('observe', baseCtx);
101
+ const observeResult = observations.find((item) => item.moduleId === 'social_memory')?.value || null;
102
+ if (msg.isGroup) scheduleBackground(baseCtx);
103
+
104
+ const memoryHints = [];
105
+ if (observeResult?.scopeId) memoryHints.push(`channel:${observeResult.scopeId}`);
106
+
107
+ let decision;
108
+ if (!isModuleEnabled(config, 'turn_taking')) {
109
+ decision = {
110
+ decision: 'speak',
111
+ needScore: 1,
112
+ confidence: 1,
113
+ reasonCodes: ['turn_taking_disabled'],
114
+ urgency: 'medium',
115
+ rationale: 'Turn-taking is disabled; using the standard response path.',
116
+ tokenPath: 'gate_skip',
117
+ latencyMs: 0,
118
+ turnEpoch,
119
+ };
120
+ } else {
121
+ decision = (await registry.run('decide', {
122
+ ...baseCtx,
123
+ memoryHints,
124
+ })).find((item) => item.moduleId === 'turn_taking')?.value;
125
+ }
126
+ if (!decision) {
127
+ throw new Error('The turn-taking module did not return a decision.');
128
+ }
129
+
130
+ if (io && userId) {
131
+ io.to(`user:${userId}`).emit('behavior:decision', {
132
+ platform: msg.platform,
133
+ chatId: msg.chatId,
134
+ agentId,
135
+ isGroup: Boolean(msg.isGroup),
136
+ decision: decision.decision,
137
+ confidence: decision.confidence,
138
+ needScore: decision.needScore,
139
+ reasonCodes: decision.reasonCodes || [],
140
+ urgency: decision.urgency,
141
+ rationale: decision.rationale || '',
142
+ tokenPath: decision.tokenPath || 'gate_only',
143
+ turnEpoch: decision.turnEpoch,
144
+ model: decision.model || null,
145
+ at: new Date().toISOString(),
146
+ });
147
+ }
148
+
149
+ if (decision.decision !== 'speak') {
150
+ return {
151
+ engage: false,
152
+ decision,
153
+ config,
154
+ promptBlocks: [],
155
+ observeResult,
156
+ };
157
+ }
158
+
159
+ const promptBlocks = await registry.composeContext(baseCtx);
160
+
161
+ return {
162
+ engage: true,
163
+ decision,
164
+ config,
165
+ promptBlocks,
166
+ observeResult,
167
+ };
168
+ }
169
+
170
+ async function refineAndMaybeDeliver({
171
+ userId,
172
+ agentId,
173
+ msg,
174
+ config,
175
+ draft,
176
+ messagingManager,
177
+ runId = null,
178
+ signal = null,
179
+ mediaPath = null,
180
+ deliver = false,
181
+ turnEpoch = null,
182
+ }) {
183
+ const expectedEpoch = Number(turnEpoch || msg.behaviorTurnEpoch || 0);
184
+ if (msg.isGroup && !isTurnCurrent(
185
+ userId,
186
+ agentId,
187
+ msg.platform,
188
+ msg.chatId,
189
+ expectedEpoch,
190
+ )) {
191
+ return {
192
+ action: 'suppress',
193
+ content: '[NO RESPONSE]',
194
+ delivered: false,
195
+ suppressed: true,
196
+ reasonCodes: ['stale_turn'],
197
+ };
198
+ }
199
+ const combinedGroupReview = msg.isGroup
200
+ && isModuleEnabled(config, 'persona')
201
+ && isModuleEnabled(config, 'theory_of_mind');
202
+ const persona = combinedGroupReview
203
+ ? {
204
+ action: 'send',
205
+ content: draft,
206
+ reasonCodes: ['persona_refine_combined_with_tom'],
207
+ }
208
+ : await registry.get('persona').refineDraft({
209
+ userId,
210
+ agentId,
211
+ msg,
212
+ config,
213
+ draft,
214
+ signal,
215
+ agentEngine,
216
+ runId,
217
+ });
218
+ const tom = await registry.get('theory_of_mind').refineDraft({
219
+ userId,
220
+ agentId,
221
+ msg,
222
+ config,
223
+ draft: persona.content,
224
+ signal,
225
+ agentEngine,
226
+ runId,
227
+ });
228
+
229
+ const content = tom.content;
230
+ const reasonCodes = [
231
+ ...(persona.reasonCodes || []),
232
+ ...(tom.reasonCodes || []),
233
+ ];
234
+ if (!deliver || !messagingManager) {
235
+ return {
236
+ ...tom,
237
+ delivered: false,
238
+ content,
239
+ reasonCodes,
240
+ personaAction: persona.action,
241
+ };
242
+ }
243
+
244
+ if (!content || content.toUpperCase() === '[NO RESPONSE]') {
245
+ return {
246
+ ...tom,
247
+ delivered: false,
248
+ suppressed: true,
249
+ content,
250
+ reasonCodes,
251
+ personaAction: persona.action,
252
+ };
253
+ }
254
+
255
+ const deliveryConfig = isModuleEnabled(config, 'delivery')
256
+ ? config
257
+ : { ...config, deliveryStyle: 'single' };
258
+ const delivery = await registry.get('delivery').deliver({
259
+ messagingManager,
260
+ userId,
261
+ agentId,
262
+ platform: msg.platform,
263
+ chatId: msg.chatId,
264
+ content,
265
+ config: deliveryConfig,
266
+ runId,
267
+ signal,
268
+ mediaPath,
269
+ turnEpoch: expectedEpoch,
270
+ beforeBubble: () => !msg.isGroup || isTurnCurrent(
271
+ userId,
272
+ agentId,
273
+ msg.platform,
274
+ msg.chatId,
275
+ expectedEpoch,
276
+ ),
277
+ });
278
+
279
+ if (
280
+ (delivery?.success !== false && delivery?.suppressed !== true)
281
+ || Number(delivery?.deliveredBubbles || 0) > 0
282
+ ) {
283
+ markSpoke(userId, agentId, msg.platform, msg.chatId);
284
+ }
285
+
286
+ return {
287
+ ...tom,
288
+ delivered: delivery?.success !== false && delivery?.suppressed !== true,
289
+ suppressed: delivery?.suppressed === true,
290
+ delivery,
291
+ content,
292
+ reasonCodes,
293
+ personaAction: persona.action,
294
+ };
295
+ }
296
+
297
+ function getDiagnostics(userId, agentId, platform, chatId) {
298
+ const config = resolveBehaviorConfig(userId, agentId, {
299
+ platform,
300
+ chatId,
301
+ isGroup: true,
302
+ });
303
+ const state = getThreadState(userId, agentId, platform, chatId);
304
+ return {
305
+ config,
306
+ state,
307
+ modules: Object.fromEntries(
308
+ Object.keys(config.modules || {}).map((id) => [id, isModuleEnabled(config, id)]),
309
+ ),
310
+ };
311
+ }
312
+
313
+ return {
314
+ registry,
315
+ noteInbound,
316
+ handleInbound,
317
+ refineAndMaybeDeliver,
318
+ getDiagnostics,
319
+ };
320
+ }
321
+
322
+ module.exports = {
323
+ createBehaviorPipeline,
324
+ };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const LIFECYCLE_STAGES = Object.freeze([
4
+ 'observe',
5
+ 'decide',
6
+ 'composeContext',
7
+ 'composeSystemPrompt',
8
+ 'refineDraft',
9
+ 'deliver',
10
+ 'afterTurn',
11
+ ]);
12
+
13
+ function createBehaviorRegistry(modules) {
14
+ const byId = new Map();
15
+ for (const module of modules) {
16
+ const id = String(module?.id || '').trim();
17
+ if (!id) throw new Error('Behavior modules require a stable id.');
18
+ if (byId.has(id)) throw new Error(`Duplicate behavior module id: ${id}`);
19
+ byId.set(id, module);
20
+ }
21
+
22
+ function list() {
23
+ return [...byId.values()];
24
+ }
25
+
26
+ function get(id) {
27
+ return byId.get(id) || null;
28
+ }
29
+
30
+ async function run(stage, ctx) {
31
+ if (!LIFECYCLE_STAGES.includes(stage)) {
32
+ throw new Error(`Unknown behavior lifecycle stage: ${stage}`);
33
+ }
34
+ const results = [];
35
+ for (const module of byId.values()) {
36
+ if (ctx.isModuleEnabled && !ctx.isModuleEnabled(module.id)) continue;
37
+ if (typeof module[stage] !== 'function') continue;
38
+ results.push({ moduleId: module.id, value: await module[stage](ctx) });
39
+ }
40
+ return results;
41
+ }
42
+
43
+ async function composeContext(ctx) {
44
+ const contributions = [];
45
+ const keys = new Set();
46
+ for (const { moduleId, value } of await run('composeContext', ctx)) {
47
+ const entries = Array.isArray(value) ? value : [value];
48
+ for (const entry of entries) {
49
+ if (!entry?.content) continue;
50
+ const key = String(entry.key || moduleId).trim();
51
+ if (keys.has(key)) {
52
+ throw new Error(`Duplicate behavior prompt contribution: ${key}`);
53
+ }
54
+ keys.add(key);
55
+ contributions.push({
56
+ key,
57
+ priority: Number(entry.priority || 0),
58
+ content: String(entry.content).trim(),
59
+ });
60
+ }
61
+ }
62
+ return contributions
63
+ .sort((left, right) => right.priority - left.priority)
64
+ .map((entry) => entry.content);
65
+ }
66
+
67
+ return {
68
+ get,
69
+ list,
70
+ run,
71
+ composeContext,
72
+ };
73
+ }
74
+
75
+ module.exports = {
76
+ LIFECYCLE_STAGES,
77
+ createBehaviorRegistry,
78
+ };