twinclaw 1.0.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 (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,416 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createSession, getSessionMessages, saveMessage } from '../services/db.js';
3
+ import { ModelRouter } from '../services/model-router.js';
4
+ import { indexConversationTurn, retrieveEvidenceAwareMemoryContext, } from '../services/semantic-memory.js';
5
+ import { OrchestrationService } from '../services/orchestration-service.js';
6
+ import { assembleContext } from './context-assembly.js';
7
+ import { LaneExecutor } from './lane-executor.js';
8
+ import { PolicyEngine } from '../services/policy-engine.js';
9
+ import { logThought } from '../utils/logger.js';
10
+ import { ContextLifecycleOrchestrator } from '../services/context-lifecycle.js';
11
+ const DEFAULT_MAX_TOOL_ROUNDS = 6;
12
+ const DEFAULT_IDENTICAL_TOOL_CALL_LIMIT = 3;
13
+ const DEFAULT_DELEGATION_MIN_SCORE = 2;
14
+ const DELEGATION_KEYWORDS = [
15
+ 'complex',
16
+ 'analyze',
17
+ 'analysis',
18
+ 'reasoning',
19
+ 'investigate',
20
+ 'architecture',
21
+ 'tradeoff',
22
+ 'plan',
23
+ 'design',
24
+ 'multi-step',
25
+ 'parallel',
26
+ ];
27
+ function isConversationRole(value) {
28
+ return value === 'user' || value === 'assistant' || value === 'tool';
29
+ }
30
+ function toConversationHistory(rows) {
31
+ return rows
32
+ .filter((row) => isConversationRole(row.role))
33
+ .map((row) => ({
34
+ role: row.role,
35
+ content: row.content,
36
+ }));
37
+ }
38
+ function skillToTool(skill) {
39
+ return {
40
+ name: skill.name,
41
+ description: skill.description,
42
+ parameters: skill.parameters ?? {
43
+ type: 'object',
44
+ properties: {},
45
+ required: [],
46
+ additionalProperties: true,
47
+ },
48
+ execute: async (args) => {
49
+ const result = await skill.execute(args);
50
+ return result.output;
51
+ },
52
+ };
53
+ }
54
+ function normalizeToolSelectors(values) {
55
+ if (!values || values.length === 0) {
56
+ return [];
57
+ }
58
+ return values
59
+ .map((value) => value.trim().toLowerCase())
60
+ .filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
61
+ }
62
+ function matchesToolSelector(skill, selector) {
63
+ const normalizedName = skill.name.toLowerCase();
64
+ if (selector === normalizedName) {
65
+ return true;
66
+ }
67
+ if (selector.startsWith('group:')) {
68
+ return skill.group?.toLowerCase() === selector;
69
+ }
70
+ if (selector.startsWith('source:')) {
71
+ return `${skill.source ?? 'builtin'}`.toLowerCase() === selector.slice('source:'.length);
72
+ }
73
+ if (selector.startsWith('mcp:')) {
74
+ return (skill.serverId ?? '').toLowerCase() === selector.slice('mcp:'.length);
75
+ }
76
+ return false;
77
+ }
78
+ export class Gateway {
79
+ #router;
80
+ #orchestration;
81
+ #laneExecutor;
82
+ #policyEngine;
83
+ #registry;
84
+ #tools = [];
85
+ #maxToolRounds;
86
+ #identicalToolCallLimit;
87
+ #enableDelegation;
88
+ #delegationMinScore;
89
+ #contextLifecycle;
90
+ #toolPolicy;
91
+ #degradationCounts = new Map();
92
+ constructor(registry, options = {}) {
93
+ this.#router = options.router ?? new ModelRouter();
94
+ this.#orchestration = options.orchestration ?? new OrchestrationService();
95
+ this.#policyEngine = options.policyEngine ?? new PolicyEngine();
96
+ this.#registry = registry;
97
+ this.#laneExecutor = new LaneExecutor();
98
+ this.#maxToolRounds =
99
+ Number.isFinite(options.maxToolRounds) && (options.maxToolRounds ?? 0) > 0
100
+ ? Number(options.maxToolRounds)
101
+ : DEFAULT_MAX_TOOL_ROUNDS;
102
+ this.#identicalToolCallLimit =
103
+ Number.isFinite(options.identicalToolCallLimit) && (options.identicalToolCallLimit ?? 0) > 1
104
+ ? Math.floor(Number(options.identicalToolCallLimit))
105
+ : DEFAULT_IDENTICAL_TOOL_CALL_LIMIT;
106
+ this.#enableDelegation = options.enableDelegation ?? true;
107
+ this.#delegationMinScore = Math.max(1, Number(options.delegationMinScore ?? DEFAULT_DELEGATION_MIN_SCORE));
108
+ this.#contextLifecycle = new ContextLifecycleOrchestrator(options.contextBudgetConfig);
109
+ this.#toolPolicy = {
110
+ allow: normalizeToolSelectors(options.toolPolicy?.allow),
111
+ deny: normalizeToolSelectors(options.toolPolicy?.deny),
112
+ };
113
+ this.refreshTools();
114
+ }
115
+ #filterSkillsByToolPolicy(skills) {
116
+ if (this.#toolPolicy.allow.length === 0 && this.#toolPolicy.deny.length === 0) {
117
+ return skills;
118
+ }
119
+ const allowFiltered = this.#toolPolicy.allow.length > 0
120
+ ? skills.filter((skill) => this.#toolPolicy.allow.some((selector) => matchesToolSelector(skill, selector)))
121
+ : skills;
122
+ if (this.#toolPolicy.deny.length === 0) {
123
+ return allowFiltered;
124
+ }
125
+ return allowFiltered.filter((skill) => !this.#toolPolicy.deny.some((selector) => matchesToolSelector(skill, selector)));
126
+ }
127
+ #buildToolCallSignature(toolCalls) {
128
+ return toolCalls
129
+ .map((toolCall) => `${toolCall.function.name}:${toolCall.function.arguments?.trim() ?? ''}`)
130
+ .join('|');
131
+ }
132
+ /** Sync the gateway's tool definitions with the current state of the skill registry. */
133
+ refreshTools() {
134
+ const skills = this.#registry.list();
135
+ const filteredSkills = this.#filterSkillsByToolPolicy(skills);
136
+ this.#tools = filteredSkills.map(skillToTool);
137
+ this.#laneExecutor.syncSkills(filteredSkills);
138
+ }
139
+ getContextDegradationSnapshot(limit = 8) {
140
+ const ranked = [...this.#degradationCounts.entries()]
141
+ .filter(([, count]) => count > 0)
142
+ .sort((a, b) => b[1] - a[1]);
143
+ const sessions = ranked.slice(0, Math.max(1, limit)).map(([sessionId, consecutiveDegradation]) => ({
144
+ sessionId,
145
+ consecutiveDegradation,
146
+ }));
147
+ return {
148
+ degradedSessions: ranked.length,
149
+ maxConsecutiveDegradation: ranked[0]?.[1] ?? 0,
150
+ sessions,
151
+ };
152
+ }
153
+ resetContextDegradation(sessionId) {
154
+ if (sessionId) {
155
+ this.#degradationCounts.delete(sessionId);
156
+ return;
157
+ }
158
+ this.#degradationCounts.clear();
159
+ }
160
+ async processMessage(message) {
161
+ const normalizedText = message.text?.trim();
162
+ if (!normalizedText) {
163
+ return 'I could not find any text content to process.';
164
+ }
165
+ const sessionId = `${message.platform}:${message.senderId}`;
166
+ return this.processText(sessionId, normalizedText);
167
+ }
168
+ async processText(sessionId, text) {
169
+ const normalizedText = text.trim();
170
+ if (!normalizedText) {
171
+ return 'Please provide a non-empty prompt.';
172
+ }
173
+ createSession(sessionId);
174
+ const historyRows = getSessionMessages(sessionId);
175
+ const conversationHistory = toConversationHistory(historyRows);
176
+ const historyPlan = this.#contextLifecycle.planHistoryWindow(conversationHistory);
177
+ await this.#persistTurn(sessionId, 'user', normalizedText);
178
+ const memoryRetrieval = await retrieveEvidenceAwareMemoryContext(sessionId, normalizedText, historyPlan.memoryTopK);
179
+ const memoryContext = memoryRetrieval.context;
180
+ const delegationContext = await this.#runDelegationIfNeeded(sessionId, normalizedText, historyPlan.hotHistory, memoryContext);
181
+ const runtimePlan = this.#contextLifecycle.planRuntimeContext({
182
+ memoryContext,
183
+ delegationContext,
184
+ warmSummary: historyPlan.warmSummary,
185
+ archivedSummary: historyPlan.archivedSummary,
186
+ });
187
+ const systemPrompt = await assembleContext(runtimePlan.runtimeContext);
188
+ const compactSystemPrompt = this.#contextLifecycle.compactSystemPrompt(systemPrompt);
189
+ this.#recordContextBudgetDiagnostics(sessionId, historyPlan, runtimePlan, compactSystemPrompt, memoryRetrieval.diagnostics);
190
+ const messages = [
191
+ { role: 'system', content: compactSystemPrompt.content },
192
+ ...historyPlan.hotHistory,
193
+ { role: 'user', content: normalizedText },
194
+ ];
195
+ return this.#runConversationLoop(sessionId, messages);
196
+ }
197
+ async #runConversationLoop(sessionId, messages) {
198
+ // Refresh tools at the start of each loop to catch newly connected MCP servers
199
+ this.refreshTools();
200
+ let previousToolCallSignature = null;
201
+ let repeatedToolCallCount = 0;
202
+ for (let round = 0; round < this.#maxToolRounds; round++) {
203
+ const assistantMessage = (await this.#router.createChatCompletion(messages, this.#tools, { sessionId }));
204
+ const assistantContent = assistantMessage.content ?? '';
205
+ messages.push({
206
+ role: 'assistant',
207
+ content: assistantContent,
208
+ tool_calls: assistantMessage.tool_calls,
209
+ });
210
+ await this.#persistTurn(sessionId, 'assistant', assistantContent || '[assistant returned tool calls without text content]');
211
+ if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
212
+ return assistantContent || 'Done.';
213
+ }
214
+ const toolSignature = this.#buildToolCallSignature(assistantMessage.tool_calls);
215
+ if (toolSignature === previousToolCallSignature) {
216
+ repeatedToolCallCount += 1;
217
+ }
218
+ else {
219
+ previousToolCallSignature = toolSignature;
220
+ repeatedToolCallCount = 1;
221
+ }
222
+ if (repeatedToolCallCount >= this.#identicalToolCallLimit) {
223
+ const diagnostic = `Tool-call loop guard triggered after ${repeatedToolCallCount} repeated identical ` +
224
+ `tool batches. Last signature: ${toolSignature}`;
225
+ await this.#persistTurn(sessionId, 'tool', diagnostic);
226
+ return `${diagnostic}. Stopping execution to prevent an infinite loop.`;
227
+ }
228
+ const toolResults = await this.#laneExecutor.executeToolCalls(assistantMessage, sessionId, this.#policyEngine);
229
+ for (const toolMessage of toolResults) {
230
+ messages.push(toolMessage);
231
+ await this.#persistTurn(sessionId, 'tool', toolMessage.content ?? '');
232
+ }
233
+ }
234
+ return `Stopped after ${this.#maxToolRounds} tool-execution rounds without a final text response.`;
235
+ }
236
+ async #persistTurn(sessionId, role, content) {
237
+ saveMessage(randomUUID(), sessionId, role, content);
238
+ if (role !== 'tool') {
239
+ await indexConversationTurn(sessionId, role, content);
240
+ }
241
+ }
242
+ #recordContextBudgetDiagnostics(sessionId, historyPlan, runtimePlan, systemPromptSection, retrievalDiagnostics) {
243
+ const degraded = historyPlan.stats.wasCompacted ||
244
+ runtimePlan.stats.wasCompacted ||
245
+ systemPromptSection.wasCompacted ||
246
+ systemPromptSection.wasOmitted;
247
+ const previousCount = this.#degradationCounts.get(sessionId) ?? 0;
248
+ const nextCount = degraded ? previousCount + 1 : 0;
249
+ this.#degradationCounts.set(sessionId, nextCount);
250
+ const diagnostics = [
251
+ ...retrievalDiagnostics,
252
+ ...historyPlan.diagnostics,
253
+ ...runtimePlan.diagnostics,
254
+ systemPromptSection.note ?? '',
255
+ ]
256
+ .filter(Boolean)
257
+ .join(' | ');
258
+ const alertMessage = degraded && nextCount >= 3
259
+ ? ` ALERT: sustained context degradation detected for ${nextCount} consecutive turn(s).`
260
+ : '';
261
+ const report = `[ContextBudget] session=${sessionId} ` +
262
+ `history hot/warm/archived=${historyPlan.stats.hotMessages}/${historyPlan.stats.warmMessages}/${historyPlan.stats.archivedMessages} ` +
263
+ `memoryTopK=${historyPlan.memoryTopK} ` +
264
+ `runtimeTokens(memory/delegation/warm/archive)=${runtimePlan.stats.memoryTokens}/${runtimePlan.stats.delegationTokens}/${runtimePlan.stats.warmTokens}/${runtimePlan.stats.archivedTokens} ` +
265
+ `systemTokens=${systemPromptSection.usedTokens}/${this.#contextLifecycle.config.systemBudgetTokens}. ` +
266
+ `${diagnostics}${alertMessage}`;
267
+ void logThought(report.trim());
268
+ }
269
+ async #runDelegationIfNeeded(sessionId, userText, history, memoryContext) {
270
+ if (!this.#enableDelegation) {
271
+ return '';
272
+ }
273
+ const request = this.#planDelegationRequest(sessionId, userText, history, memoryContext);
274
+ if (!request) {
275
+ return '';
276
+ }
277
+ try {
278
+ const result = await this.#orchestration.runDelegation(request, async ({ request: delegationRequest, job, signal }) => this.#executeDelegatedJob(delegationRequest, job, signal));
279
+ const report = `Delegation report\n${result.summary}`;
280
+ await this.#persistTurn(sessionId, 'tool', report);
281
+ return report;
282
+ }
283
+ catch (error) {
284
+ const message = error instanceof Error ? error.message : String(error);
285
+ const degraded = `Delegation failed and was skipped: ${message}`;
286
+ await this.#persistTurn(sessionId, 'tool', degraded);
287
+ return degraded;
288
+ }
289
+ }
290
+ #planDelegationRequest(sessionId, userText, history, memoryContext) {
291
+ const complexityScore = this.#scorePromptComplexity(userText);
292
+ if (complexityScore < this.#delegationMinScore) {
293
+ return null;
294
+ }
295
+ const recentMessages = history
296
+ .slice(-6)
297
+ .filter((message) => isConversationRole(message.role))
298
+ .map((message) => ({
299
+ role: message.role,
300
+ content: message.content ?? '',
301
+ }));
302
+ const recentConversationBlock = recentMessages
303
+ .map((message, index) => `${index + 1}. ${message.role.toUpperCase()}: ${message.content}`)
304
+ .join('\n');
305
+ const scopedContextParts = [
306
+ memoryContext ? `Retrieved memory context:\n${memoryContext}` : '',
307
+ recentConversationBlock ? `Recent conversation:\n${recentConversationBlock}` : '',
308
+ ].filter(Boolean);
309
+ const scopedContext = scopedContextParts.join('\n\n');
310
+ const briefs = [
311
+ {
312
+ id: 'decompose',
313
+ dependsOn: [],
314
+ title: 'Problem decomposition',
315
+ objective: 'Break down the parent request into the smallest reliable execution steps and identify critical dependencies.',
316
+ scopedContext,
317
+ expectedOutput: 'Return concise numbered steps and explicitly call out blockers or unknowns.',
318
+ constraints: {
319
+ toolBudget: 0,
320
+ timeoutMs: 12_000,
321
+ maxTurns: 1,
322
+ },
323
+ },
324
+ {
325
+ id: 'risk-analysis',
326
+ dependsOn: ['decompose'],
327
+ title: 'Failure modes and safeguards',
328
+ objective: 'Identify likely failure paths, edge cases, and reliability safeguards needed before execution.',
329
+ scopedContext,
330
+ expectedOutput: 'Return bullet points grouped into risks, mitigations, and monitoring signals.',
331
+ constraints: {
332
+ toolBudget: 0,
333
+ timeoutMs: 12_000,
334
+ maxTurns: 1,
335
+ },
336
+ },
337
+ ];
338
+ if (userText.length > 420) {
339
+ briefs.push({
340
+ id: 'synthesis',
341
+ dependsOn: ['decompose', 'risk-analysis'],
342
+ title: 'Integration synthesis',
343
+ objective: 'Produce a merged implementation outline balancing speed, correctness, and rollback safety.',
344
+ scopedContext,
345
+ expectedOutput: 'Return a practical execution sequence with explicit checkpoints and fallback behavior.',
346
+ constraints: {
347
+ toolBudget: 0,
348
+ timeoutMs: 14_000,
349
+ maxTurns: 1,
350
+ },
351
+ });
352
+ }
353
+ return {
354
+ sessionId,
355
+ parentMessage: userText,
356
+ scope: {
357
+ sessionId,
358
+ memoryContext,
359
+ recentMessages,
360
+ },
361
+ briefs,
362
+ };
363
+ }
364
+ #scorePromptComplexity(prompt) {
365
+ const lower = prompt.toLowerCase();
366
+ const tokenCount = prompt.split(/\s+/).filter(Boolean).length;
367
+ let score = 0;
368
+ if (tokenCount >= 55) {
369
+ score += 1;
370
+ }
371
+ if (/\b(and|then|after that|while)\b/.test(lower)) {
372
+ score += 1;
373
+ }
374
+ for (const keyword of DELEGATION_KEYWORDS) {
375
+ if (lower.includes(keyword)) {
376
+ score += 1;
377
+ }
378
+ }
379
+ return score;
380
+ }
381
+ async #executeDelegatedJob(request, job, signal) {
382
+ if (signal.aborted) {
383
+ throw new Error(`Delegated job '${job.id}' was cancelled before execution.`);
384
+ }
385
+ const scopedMessages = request.scope.recentMessages
386
+ .map((item, index) => `${index + 1}. ${item.role.toUpperCase()}: ${item.content}`)
387
+ .join('\n');
388
+ const systemPrompt = [
389
+ 'You are a focused TwinBot sub-agent.',
390
+ 'Do not ask follow-up questions.',
391
+ 'Do not invoke tools.',
392
+ `Expected output contract: ${job.brief.expectedOutput}`,
393
+ ].join('\n');
394
+ const userPrompt = [
395
+ `Parent objective: ${request.parentMessage}`,
396
+ `Delegated brief: ${job.brief.title}`,
397
+ `Sub-task objective: ${job.brief.objective}`,
398
+ `Scoped context:\n${job.brief.scopedContext}`,
399
+ scopedMessages ? `Scoped message history:\n${scopedMessages}` : '',
400
+ ]
401
+ .filter(Boolean)
402
+ .join('\n\n');
403
+ const response = (await this.#router.createChatCompletion([
404
+ { role: 'system', content: systemPrompt },
405
+ { role: 'user', content: userPrompt },
406
+ ], undefined, { sessionId: request.sessionId }));
407
+ if (signal.aborted) {
408
+ throw new Error(`Delegated job '${job.id}' was cancelled during execution.`);
409
+ }
410
+ const content = response.content?.trim();
411
+ if (!content) {
412
+ throw new Error(`Delegated job '${job.id}' returned empty content.`);
413
+ }
414
+ return content;
415
+ }
416
+ }
@@ -0,0 +1,54 @@
1
+ import { JobScheduler } from '../services/job-scheduler.js';
2
+ import { logThought } from '../utils/logger.js';
3
+ import { getConfigValue } from '../config/config-loader.js';
4
+ const DEFAULT_CRON = '0 9 * * *';
5
+ const DEFAULT_MESSAGE = 'TwinBot heartbeat: daily proactive check-in.';
6
+ const HEARTBEAT_JOB_ID = 'twinbot-heartbeat';
7
+ /**
8
+ * Proactive heartbeat service.
9
+ *
10
+ * Now delegates to the centralized {@link JobScheduler} so heartbeat intervals
11
+ * are managed alongside other repeating background jobs. The external API
12
+ * remains identical to the original implementation for backward compatibility.
13
+ */
14
+ export class HeartbeatService {
15
+ #scheduler;
16
+ #onHeartbeat;
17
+ #cronExpression;
18
+ #message;
19
+ /**
20
+ * @param onHeartbeat - callback invoked on every heartbeat tick.
21
+ * @param config - optional cron and message overrides.
22
+ * @param scheduler - optional external scheduler instance (for shared management).
23
+ * If omitted, a private scheduler is created internally.
24
+ */
25
+ constructor(onHeartbeat, config = {}, scheduler) {
26
+ this.#onHeartbeat = onHeartbeat;
27
+ this.#cronExpression = config.cronExpression ?? getConfigValue('HEARTBEAT_CRON') ?? DEFAULT_CRON;
28
+ this.#message = config.message ?? getConfigValue('HEARTBEAT_MESSAGE') ?? DEFAULT_MESSAGE;
29
+ this.#scheduler = scheduler ?? new JobScheduler();
30
+ }
31
+ /** Expose the underlying scheduler so callers can register additional jobs. */
32
+ get scheduler() {
33
+ return this.#scheduler;
34
+ }
35
+ start() {
36
+ // Guard against double-start
37
+ if (this.#scheduler.getJob(HEARTBEAT_JOB_ID)) {
38
+ return;
39
+ }
40
+ this.#scheduler.register({
41
+ id: HEARTBEAT_JOB_ID,
42
+ cronExpression: this.#cronExpression,
43
+ description: this.#message,
44
+ handler: async () => {
45
+ await logThought(`Heartbeat fired with schedule: ${this.#cronExpression}`);
46
+ await this.#onHeartbeat(this.#message);
47
+ },
48
+ autoStart: true,
49
+ });
50
+ }
51
+ stop() {
52
+ this.#scheduler.unregister(HEARTBEAT_JOB_ID);
53
+ }
54
+ }