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,334 @@
1
+ import { chromium } from 'playwright-core';
2
+ const SNAPSHOT_CONTEXT_LIMIT = 8;
3
+ const REFERENCE_CANDIDATE_LIMIT = 256;
4
+ const REF_PREFIX = 'ref';
5
+ export class BrowserReferenceError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.name = 'BrowserReferenceError';
10
+ this.code = code;
11
+ }
12
+ }
13
+ function normalizeRole(value) {
14
+ const normalized = (value ?? '').trim().toLowerCase();
15
+ return normalized || 'generic';
16
+ }
17
+ function normalizeName(value) {
18
+ return (value ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
19
+ }
20
+ function sanitizeSelector(value) {
21
+ return value.trim();
22
+ }
23
+ function normalizeBounds(bounds) {
24
+ const normalize = (value) => Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
25
+ return {
26
+ x: normalize(bounds.x),
27
+ y: normalize(bounds.y),
28
+ width: Math.max(0, normalize(bounds.width)),
29
+ height: Math.max(0, normalize(bounds.height)),
30
+ };
31
+ }
32
+ export function normalizeBrowserReferenceCandidates(candidates) {
33
+ const deduped = new Map();
34
+ for (const candidate of candidates) {
35
+ const selector = sanitizeSelector(candidate.selector);
36
+ if (!selector) {
37
+ continue;
38
+ }
39
+ const entry = {
40
+ selector,
41
+ role: normalizeRole(candidate.role),
42
+ name: normalizeName(candidate.name),
43
+ bounds: normalizeBounds(candidate.bounds),
44
+ };
45
+ const dedupeKey = [
46
+ entry.selector,
47
+ entry.role,
48
+ entry.name,
49
+ entry.bounds.x,
50
+ entry.bounds.y,
51
+ entry.bounds.width,
52
+ entry.bounds.height,
53
+ ].join('|');
54
+ if (!deduped.has(dedupeKey)) {
55
+ deduped.set(dedupeKey, entry);
56
+ }
57
+ }
58
+ const sorted = [...deduped.values()].sort((left, right) => left.bounds.y - right.bounds.y ||
59
+ left.bounds.x - right.bounds.x ||
60
+ left.selector.localeCompare(right.selector) ||
61
+ left.role.localeCompare(right.role) ||
62
+ left.name.localeCompare(right.name));
63
+ return sorted.map((entry, index) => ({
64
+ ...entry,
65
+ ref: `${REF_PREFIX}-${String(index + 1).padStart(3, '0')}`,
66
+ }));
67
+ }
68
+ export class BrowserService {
69
+ browser = null;
70
+ context = null;
71
+ page = null;
72
+ snapshotContexts = [];
73
+ snapshotCounter = 0;
74
+ async init() {
75
+ if (!this.browser) {
76
+ this.browser = await chromium.launch({ headless: true });
77
+ this.context = await this.browser.newContext();
78
+ this.page = await this.context.newPage();
79
+ }
80
+ }
81
+ async navigate(url) {
82
+ if (!this.page)
83
+ await this.init();
84
+ await this.page.goto(url, { waitUntil: 'networkidle' });
85
+ }
86
+ async getAccessibilityTree() {
87
+ if (!this.page)
88
+ await this.init();
89
+ const bodyLocator = this.page.locator('body');
90
+ if (typeof bodyLocator.ariaSnapshot === 'function') {
91
+ return await bodyLocator.ariaSnapshot();
92
+ }
93
+ return await this.page.content();
94
+ }
95
+ async captureSnapshotReferenceContext() {
96
+ if (!this.page)
97
+ await this.init();
98
+ const rawCandidates = await this.page.evaluate((limit) => {
99
+ const normalizeText = (value) => {
100
+ if (typeof value !== 'string') {
101
+ return null;
102
+ }
103
+ const trimmed = value.replace(/\s+/g, ' ').trim();
104
+ return trimmed.length > 0 ? trimmed : null;
105
+ };
106
+ const inferRole = (element) => {
107
+ const explicitRole = normalizeText(element.getAttribute('role'));
108
+ if (explicitRole) {
109
+ return explicitRole.toLowerCase();
110
+ }
111
+ const tag = element.tagName.toLowerCase();
112
+ if (tag === 'a' && element.hasAttribute('href'))
113
+ return 'link';
114
+ if (tag === 'button')
115
+ return 'button';
116
+ if (tag === 'select')
117
+ return 'combobox';
118
+ if (tag === 'textarea')
119
+ return 'textbox';
120
+ if (tag === 'summary')
121
+ return 'button';
122
+ if (tag === 'option')
123
+ return 'option';
124
+ if (tag === 'input') {
125
+ const inputType = normalizeText(element.getAttribute('type'))?.toLowerCase() ?? 'text';
126
+ if (inputType === 'button' || inputType === 'submit' || inputType === 'reset')
127
+ return 'button';
128
+ if (inputType === 'checkbox')
129
+ return 'checkbox';
130
+ if (inputType === 'radio')
131
+ return 'radio';
132
+ if (inputType === 'range')
133
+ return 'slider';
134
+ if (inputType === 'search')
135
+ return 'searchbox';
136
+ return 'textbox';
137
+ }
138
+ return null;
139
+ };
140
+ const inferName = (element) => {
141
+ const ariaLabel = normalizeText(element.getAttribute('aria-label'));
142
+ if (ariaLabel)
143
+ return ariaLabel;
144
+ const labelledBy = normalizeText(element.getAttribute('aria-labelledby'));
145
+ if (labelledBy) {
146
+ const label = labelledBy
147
+ .split(/\s+/g)
148
+ .map((id) => normalizeText(document.getElementById(id)?.textContent))
149
+ .filter((value) => !!value)
150
+ .join(' ');
151
+ if (label) {
152
+ return label;
153
+ }
154
+ }
155
+ const title = normalizeText(element.getAttribute('title'));
156
+ if (title)
157
+ return title;
158
+ const placeholder = normalizeText(element.getAttribute('placeholder'));
159
+ if (placeholder)
160
+ return placeholder;
161
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
162
+ const value = normalizeText(element.value);
163
+ if (value)
164
+ return value;
165
+ }
166
+ return normalizeText(element.textContent);
167
+ };
168
+ const isVisible = (element) => {
169
+ const rect = element.getBoundingClientRect();
170
+ const style = window.getComputedStyle(element);
171
+ return (rect.width > 0 &&
172
+ rect.height > 0 &&
173
+ style.display !== 'none' &&
174
+ style.visibility !== 'hidden' &&
175
+ style.opacity !== '0');
176
+ };
177
+ const cssPath = (element) => {
178
+ const simpleIdPattern = /^[A-Za-z][A-Za-z0-9\-_:.\u00A0-\uFFFF]*$/;
179
+ if (element.id && simpleIdPattern.test(element.id)) {
180
+ return `#${element.id}`;
181
+ }
182
+ const segments = [];
183
+ let current = element;
184
+ while (current && current !== document.body) {
185
+ const parent = current.parentElement;
186
+ const tag = current.tagName.toLowerCase();
187
+ if (!parent) {
188
+ segments.unshift(tag);
189
+ break;
190
+ }
191
+ const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
192
+ const index = Math.max(1, siblings.indexOf(current) + 1);
193
+ segments.unshift(`${tag}:nth-of-type(${index})`);
194
+ current = parent;
195
+ }
196
+ if (segments.length === 0) {
197
+ return 'body';
198
+ }
199
+ return `body > ${segments.join(' > ')}`;
200
+ };
201
+ const selectors = 'a[href],button,input,select,textarea,[role],summary,[tabindex]';
202
+ const nodes = Array.from(document.querySelectorAll(selectors));
203
+ const candidates = [];
204
+ for (const node of nodes) {
205
+ if (candidates.length >= limit) {
206
+ break;
207
+ }
208
+ if (!(node instanceof HTMLElement)) {
209
+ continue;
210
+ }
211
+ if (!isVisible(node)) {
212
+ continue;
213
+ }
214
+ const rect = node.getBoundingClientRect();
215
+ if (rect.width <= 1 || rect.height <= 1) {
216
+ continue;
217
+ }
218
+ candidates.push({
219
+ selector: cssPath(node),
220
+ role: inferRole(node),
221
+ name: inferName(node),
222
+ bounds: {
223
+ x: rect.x,
224
+ y: rect.y,
225
+ width: rect.width,
226
+ height: rect.height,
227
+ },
228
+ });
229
+ }
230
+ return candidates;
231
+ }, REFERENCE_CANDIDATE_LIMIT);
232
+ const references = normalizeBrowserReferenceCandidates(rawCandidates);
233
+ const snapshotContext = {
234
+ snapshotId: this.nextSnapshotId(),
235
+ createdAt: new Date().toISOString(),
236
+ references,
237
+ };
238
+ this.snapshotContexts.push(snapshotContext);
239
+ if (this.snapshotContexts.length > SNAPSHOT_CONTEXT_LIMIT) {
240
+ this.snapshotContexts.splice(0, this.snapshotContexts.length - SNAPSHOT_CONTEXT_LIMIT);
241
+ }
242
+ return snapshotContext;
243
+ }
244
+ async takeScreenshot(path) {
245
+ if (!this.page)
246
+ await this.init();
247
+ await this.page.screenshot({ path });
248
+ }
249
+ async takeScreenshotForVlm(filePath, fullPage = true) {
250
+ if (!this.page)
251
+ await this.init();
252
+ await this.page.screenshot({ path: filePath, fullPage });
253
+ const viewport = this.page.viewportSize() ?? { width: 1280, height: 720 };
254
+ return {
255
+ path: filePath,
256
+ viewport,
257
+ };
258
+ }
259
+ async close() {
260
+ if (this.browser) {
261
+ await this.browser.close();
262
+ this.browser = null;
263
+ this.context = null;
264
+ this.page = null;
265
+ this.snapshotContexts = [];
266
+ this.snapshotCounter = 0;
267
+ }
268
+ }
269
+ async click(selector) {
270
+ if (!this.page)
271
+ await this.init();
272
+ await this.page.click(selector);
273
+ }
274
+ async clickAt(point) {
275
+ if (!this.page)
276
+ await this.init();
277
+ await this.page.mouse.click(point.x, point.y);
278
+ }
279
+ async clickByReference(input) {
280
+ if (!this.page)
281
+ await this.init();
282
+ const snapshot = input.snapshotId
283
+ ? this.snapshotContexts.find((context) => context.snapshotId === input.snapshotId)
284
+ : this.snapshotContexts.at(-1);
285
+ if (!snapshot && input.snapshotId) {
286
+ throw new BrowserReferenceError('snapshot_context_stale', `Snapshot '${input.snapshotId}' is no longer available. Capture a fresh snapshot and retry.`);
287
+ }
288
+ if (!snapshot) {
289
+ throw new BrowserReferenceError('snapshot_context_missing', 'No snapshot reference context is available. Capture /browser/snapshot before clicking by ref.');
290
+ }
291
+ const reference = snapshot.references.find((entry) => entry.ref === input.ref);
292
+ if (!reference) {
293
+ throw new BrowserReferenceError('reference_not_found', `Reference '${input.ref}' was not found in snapshot '${snapshot.snapshotId}'.`);
294
+ }
295
+ try {
296
+ await this.page.locator(reference.selector).first().click();
297
+ }
298
+ catch (error) {
299
+ const detail = error instanceof Error ? error.message : String(error);
300
+ throw new BrowserReferenceError('reference_unresolved', `Reference '${input.ref}' could not be resolved to a clickable element. ${detail}`);
301
+ }
302
+ return {
303
+ snapshotId: snapshot.snapshotId,
304
+ reference,
305
+ };
306
+ }
307
+ async clickFromViewportPercentage(xRatio, yRatio) {
308
+ if (!this.page)
309
+ await this.init();
310
+ const viewport = this.page.viewportSize() ?? { width: 1280, height: 720 };
311
+ const boundedX = Math.max(0, Math.min(1, xRatio));
312
+ const boundedY = Math.max(0, Math.min(1, yRatio));
313
+ const point = {
314
+ x: Math.round(viewport.width * boundedX),
315
+ y: Math.round(viewport.height * boundedY),
316
+ };
317
+ await this.clickAt(point);
318
+ return point;
319
+ }
320
+ async getViewportInfo() {
321
+ if (!this.page)
322
+ await this.init();
323
+ return this.page.viewportSize() ?? { width: 1280, height: 720 };
324
+ }
325
+ async type(selector, text) {
326
+ if (!this.page)
327
+ await this.init();
328
+ await this.page.type(selector, text);
329
+ }
330
+ nextSnapshotId() {
331
+ this.snapshotCounter += 1;
332
+ return `snapshot-${Date.now()}-${String(this.snapshotCounter).padStart(4, '0')}`;
333
+ }
334
+ }
@@ -0,0 +1,314 @@
1
+ import { getConfigValue } from '../config/config-loader.js';
2
+ const CHARS_PER_TOKEN = 4;
3
+ const DEFAULT_CONTEXT_BUDGET_CONFIG = {
4
+ totalBudgetTokens: 6_000,
5
+ systemBudgetTokens: 1_800,
6
+ historyBudgetTokens: 1_600,
7
+ memoryBudgetTokens: 1_100,
8
+ delegationBudgetTokens: 900,
9
+ lifecycleBudgetTokens: 600,
10
+ hotMessageLimit: 16,
11
+ warmMessageLimit: 28,
12
+ memoryTopKMin: 2,
13
+ memoryTopKMax: 8,
14
+ tokensPerMemoryItem: 130,
15
+ maxSummaryEntries: 12,
16
+ summarySnippetChars: 180,
17
+ };
18
+ export function estimateTokenCount(value) {
19
+ const normalized = value.trim();
20
+ if (!normalized) {
21
+ return 0;
22
+ }
23
+ return Math.max(1, Math.ceil(normalized.length / CHARS_PER_TOKEN));
24
+ }
25
+ export function resolveContextBudgetConfig(overrides = {}) {
26
+ const configured = {
27
+ totalBudgetTokens: readIntEnv('CONTEXT_BUDGET_TOTAL_TOKENS', DEFAULT_CONTEXT_BUDGET_CONFIG.totalBudgetTokens),
28
+ systemBudgetTokens: readIntEnv('CONTEXT_BUDGET_SYSTEM_TOKENS', DEFAULT_CONTEXT_BUDGET_CONFIG.systemBudgetTokens),
29
+ historyBudgetTokens: readIntEnv('CONTEXT_BUDGET_HISTORY_TOKENS', DEFAULT_CONTEXT_BUDGET_CONFIG.historyBudgetTokens),
30
+ memoryBudgetTokens: readIntEnv('CONTEXT_BUDGET_MEMORY_TOKENS', DEFAULT_CONTEXT_BUDGET_CONFIG.memoryBudgetTokens),
31
+ delegationBudgetTokens: readIntEnv('CONTEXT_BUDGET_DELEGATION_TOKENS', DEFAULT_CONTEXT_BUDGET_CONFIG.delegationBudgetTokens),
32
+ lifecycleBudgetTokens: readIntEnv('CONTEXT_BUDGET_LIFECYCLE_TOKENS', DEFAULT_CONTEXT_BUDGET_CONFIG.lifecycleBudgetTokens),
33
+ hotMessageLimit: readIntEnv('CONTEXT_HOT_MESSAGE_LIMIT', DEFAULT_CONTEXT_BUDGET_CONFIG.hotMessageLimit),
34
+ warmMessageLimit: readIntEnv('CONTEXT_WARM_MESSAGE_LIMIT', DEFAULT_CONTEXT_BUDGET_CONFIG.warmMessageLimit),
35
+ memoryTopKMin: readIntEnv('CONTEXT_MEMORY_TOPK_MIN', DEFAULT_CONTEXT_BUDGET_CONFIG.memoryTopKMin),
36
+ memoryTopKMax: readIntEnv('CONTEXT_MEMORY_TOPK_MAX', DEFAULT_CONTEXT_BUDGET_CONFIG.memoryTopKMax),
37
+ tokensPerMemoryItem: readIntEnv('CONTEXT_MEMORY_TOKENS_PER_ITEM', DEFAULT_CONTEXT_BUDGET_CONFIG.tokensPerMemoryItem),
38
+ maxSummaryEntries: readIntEnv('CONTEXT_SUMMARY_MAX_ENTRIES', DEFAULT_CONTEXT_BUDGET_CONFIG.maxSummaryEntries),
39
+ summarySnippetChars: readIntEnv('CONTEXT_SUMMARY_SNIPPET_CHARS', DEFAULT_CONTEXT_BUDGET_CONFIG.summarySnippetChars),
40
+ };
41
+ const merged = {
42
+ ...DEFAULT_CONTEXT_BUDGET_CONFIG,
43
+ ...configured,
44
+ ...overrides,
45
+ };
46
+ const memoryTopKMin = Math.max(1, merged.memoryTopKMin);
47
+ const memoryTopKMax = Math.max(memoryTopKMin, merged.memoryTopKMax);
48
+ return {
49
+ ...merged,
50
+ totalBudgetTokens: Math.max(1_000, merged.totalBudgetTokens),
51
+ systemBudgetTokens: Math.max(200, merged.systemBudgetTokens),
52
+ historyBudgetTokens: Math.max(200, merged.historyBudgetTokens),
53
+ memoryBudgetTokens: Math.max(100, merged.memoryBudgetTokens),
54
+ delegationBudgetTokens: Math.max(100, merged.delegationBudgetTokens),
55
+ lifecycleBudgetTokens: Math.max(80, merged.lifecycleBudgetTokens),
56
+ hotMessageLimit: Math.max(1, merged.hotMessageLimit),
57
+ warmMessageLimit: Math.max(0, merged.warmMessageLimit),
58
+ memoryTopKMin,
59
+ memoryTopKMax,
60
+ tokensPerMemoryItem: Math.max(20, merged.tokensPerMemoryItem),
61
+ maxSummaryEntries: Math.max(1, merged.maxSummaryEntries),
62
+ summarySnippetChars: Math.max(40, merged.summarySnippetChars),
63
+ };
64
+ }
65
+ export class ContextLifecycleOrchestrator {
66
+ #config;
67
+ constructor(overrides = {}) {
68
+ this.#config = resolveContextBudgetConfig(overrides);
69
+ }
70
+ get config() {
71
+ return this.#config;
72
+ }
73
+ estimateTokens(value) {
74
+ return estimateTokenCount(value);
75
+ }
76
+ planHistoryWindow(conversationHistory) {
77
+ const indexed = conversationHistory.map((message, index) => ({
78
+ index: index + 1,
79
+ message,
80
+ }));
81
+ const hotStart = Math.max(0, indexed.length - this.#config.hotMessageLimit);
82
+ const warmStart = Math.max(0, hotStart - this.#config.warmMessageLimit);
83
+ const hotCandidates = indexed.slice(hotStart);
84
+ const warmCandidates = indexed.slice(warmStart, hotStart);
85
+ const archivedCandidates = indexed.slice(0, warmStart);
86
+ const selectedHot = [];
87
+ const overflowToWarm = [];
88
+ let hotTokens = 0;
89
+ for (let pointer = hotCandidates.length - 1; pointer >= 0; pointer -= 1) {
90
+ const candidate = hotCandidates[pointer];
91
+ if (!candidate) {
92
+ continue;
93
+ }
94
+ const candidateTokens = this.#estimateMessageTokens(candidate.message);
95
+ const withinBudget = hotTokens + candidateTokens <= this.#config.historyBudgetTokens;
96
+ if (selectedHot.length === 0 || withinBudget) {
97
+ selectedHot.push(candidate);
98
+ hotTokens += candidateTokens;
99
+ }
100
+ else {
101
+ overflowToWarm.push(candidate);
102
+ }
103
+ }
104
+ selectedHot.reverse();
105
+ overflowToWarm.reverse();
106
+ const warmPool = [...warmCandidates, ...overflowToWarm].sort((a, b) => a.index - b.index);
107
+ const hotHistory = selectedHot.map((entry) => entry.message);
108
+ const warmSummary = this.#buildTierSummary('warm', warmPool);
109
+ const archivedSummary = this.#buildArchivedSummary(archivedCandidates);
110
+ const unusedHistoryTokens = Math.max(0, this.#config.historyBudgetTokens - hotTokens);
111
+ const memoryBudgetTokens = this.#config.memoryBudgetTokens + Math.floor(unusedHistoryTokens / 2);
112
+ const derivedTopK = Math.floor(memoryBudgetTokens / this.#config.tokensPerMemoryItem);
113
+ const memoryTopK = clamp(derivedTopK, this.#config.memoryTopKMin, this.#config.memoryTopKMax);
114
+ const diagnostics = [];
115
+ if (overflowToWarm.length > 0) {
116
+ diagnostics.push(`Shifted ${overflowToWarm.length} recent message(s) from hot tier to warm summary due to history budget limits.`);
117
+ }
118
+ if (archivedCandidates.length > 0) {
119
+ diagnostics.push(`Archived ${archivedCandidates.length} older message(s) into compact provenance summaries.`);
120
+ }
121
+ diagnostics.push(`Adaptive memory retrieval depth selected topK=${memoryTopK} using memory budget ${memoryBudgetTokens} tokens.`);
122
+ const warmSummaryTokens = this.estimateTokens(warmSummary);
123
+ const archivedSummaryTokens = this.estimateTokens(archivedSummary);
124
+ return {
125
+ hotHistory,
126
+ warmSummary,
127
+ archivedSummary,
128
+ memoryTopK,
129
+ diagnostics,
130
+ stats: {
131
+ totalHistoryMessages: indexed.length,
132
+ hotMessages: hotHistory.length,
133
+ warmMessages: warmPool.length,
134
+ archivedMessages: archivedCandidates.length,
135
+ hotTokens,
136
+ warmSummaryTokens,
137
+ archivedSummaryTokens,
138
+ historyBudgetTokens: this.#config.historyBudgetTokens,
139
+ memoryBudgetTokens,
140
+ memoryTopK,
141
+ wasCompacted: overflowToWarm.length > 0 || archivedCandidates.length > 0,
142
+ },
143
+ };
144
+ }
145
+ planRuntimeContext(input) {
146
+ const memory = this.#compactSection('memory context', input.memoryContext, this.#config.memoryBudgetTokens);
147
+ const delegation = this.#compactSection('delegation context', input.delegationContext, this.#config.delegationBudgetTokens);
148
+ const lifecycleBudgetTokens = this.#config.lifecycleBudgetTokens;
149
+ let warmBudgetTokens = Math.floor(lifecycleBudgetTokens * 0.65);
150
+ let archivedBudgetTokens = lifecycleBudgetTokens - warmBudgetTokens;
151
+ if (warmBudgetTokens < 40) {
152
+ warmBudgetTokens = 40;
153
+ archivedBudgetTokens = Math.max(0, lifecycleBudgetTokens - warmBudgetTokens);
154
+ }
155
+ if (archivedBudgetTokens < 30) {
156
+ archivedBudgetTokens = Math.min(30, lifecycleBudgetTokens);
157
+ warmBudgetTokens = Math.max(0, lifecycleBudgetTokens - archivedBudgetTokens);
158
+ }
159
+ const warm = this.#compactSection('warm tier summary', input.warmSummary, warmBudgetTokens);
160
+ const archived = this.#compactSection('archived tier summary', input.archivedSummary, archivedBudgetTokens);
161
+ const sections = [];
162
+ if (memory.content) {
163
+ sections.push(`### RETRIEVED MEMORIES\n${memory.content}`);
164
+ }
165
+ if (delegation.content) {
166
+ sections.push(`### DELEGATION CONTEXT\n${delegation.content}`);
167
+ }
168
+ if (warm.content) {
169
+ sections.push(`### WARM MEMORY SUMMARY\n${warm.content}`);
170
+ }
171
+ if (archived.content) {
172
+ sections.push(`### ARCHIVED MEMORY SUMMARY\n${archived.content}`);
173
+ }
174
+ const diagnostics = [memory, delegation, warm, archived]
175
+ .filter((section) => section.wasCompacted || section.wasOmitted)
176
+ .map((section) => section.note ?? `${section.label} was compacted.`);
177
+ const totalTokens = memory.usedTokens + delegation.usedTokens + warm.usedTokens + archived.usedTokens;
178
+ return {
179
+ runtimeContext: sections.join('\n\n'),
180
+ diagnostics,
181
+ sections: {
182
+ memory,
183
+ delegation,
184
+ warm,
185
+ archived,
186
+ },
187
+ stats: {
188
+ memoryTokens: memory.usedTokens,
189
+ delegationTokens: delegation.usedTokens,
190
+ warmTokens: warm.usedTokens,
191
+ archivedTokens: archived.usedTokens,
192
+ totalTokens,
193
+ wasCompacted: [memory, delegation, warm, archived].some((section) => section.wasCompacted || section.wasOmitted),
194
+ },
195
+ };
196
+ }
197
+ compactSystemPrompt(systemPrompt) {
198
+ return this.#compactSection('system prompt', systemPrompt, this.#config.systemBudgetTokens);
199
+ }
200
+ #compactSection(label, source, budgetTokens) {
201
+ const normalized = source.trim();
202
+ const originalTokens = this.estimateTokens(normalized);
203
+ if (!normalized || originalTokens === 0) {
204
+ return {
205
+ label,
206
+ content: '',
207
+ originalTokens: 0,
208
+ usedTokens: 0,
209
+ wasCompacted: false,
210
+ wasOmitted: false,
211
+ };
212
+ }
213
+ if (originalTokens <= budgetTokens) {
214
+ return {
215
+ label,
216
+ content: normalized,
217
+ originalTokens,
218
+ usedTokens: originalTokens,
219
+ wasCompacted: false,
220
+ wasOmitted: false,
221
+ };
222
+ }
223
+ const budgetChars = Math.max(120, budgetTokens * CHARS_PER_TOKEN);
224
+ if (budgetChars < 160) {
225
+ const omitted = `[${label} omitted: insufficient budget (${budgetTokens} tokens).]`;
226
+ return {
227
+ label,
228
+ content: omitted,
229
+ originalTokens,
230
+ usedTokens: this.estimateTokens(omitted),
231
+ wasCompacted: true,
232
+ wasOmitted: true,
233
+ note: `${label} omitted because budget ${budgetTokens} tokens was below safe compaction threshold.`,
234
+ };
235
+ }
236
+ const headChars = Math.max(48, Math.floor(budgetChars * 0.72));
237
+ const tailChars = Math.max(32, Math.floor(budgetChars * 0.18));
238
+ const head = normalized.slice(0, headChars).trimEnd();
239
+ const tail = normalized.slice(-tailChars).trimStart();
240
+ const compacted = `${head}\n...[${label} compacted ${originalTokens}→~${budgetTokens} tokens]...\n${tail}`;
241
+ return {
242
+ label,
243
+ content: compacted,
244
+ originalTokens,
245
+ usedTokens: this.estimateTokens(compacted),
246
+ wasCompacted: true,
247
+ wasOmitted: false,
248
+ note: `${label} compacted from ${originalTokens} tokens to fit budget ${budgetTokens}.`,
249
+ };
250
+ }
251
+ #buildTierSummary(label, entries) {
252
+ if (entries.length === 0) {
253
+ return '';
254
+ }
255
+ const selected = entries.slice(-this.#config.maxSummaryEntries);
256
+ const omittedCount = Math.max(0, entries.length - selected.length);
257
+ const lines = selected.map((entry) => {
258
+ const role = entry.message.role.toUpperCase();
259
+ const snippet = this.#snippet(entry.message.content ?? '[no text]');
260
+ return `- [#${entry.index}] ${role}: ${snippet}`;
261
+ });
262
+ if (omittedCount > 0) {
263
+ lines.unshift(`- ${omittedCount} additional ${label} turn(s) omitted for compactness.`);
264
+ }
265
+ return [`${label.toUpperCase()} tier summary (${entries.length} turn(s))`, ...lines].join('\n');
266
+ }
267
+ #buildArchivedSummary(entries) {
268
+ if (entries.length === 0) {
269
+ return '';
270
+ }
271
+ const head = entries.slice(0, Math.min(2, entries.length));
272
+ const tail = entries.slice(Math.max(2, entries.length - 2));
273
+ const sampled = [...head, ...tail]
274
+ .sort((a, b) => a.index - b.index)
275
+ .filter((entry, index, arr) => index === 0 || arr[index - 1]?.index !== entry.index);
276
+ const lines = sampled.map((entry) => {
277
+ const role = entry.message.role.toUpperCase();
278
+ const snippet = this.#snippet(entry.message.content ?? '[no text]');
279
+ return `- [#${entry.index}] ${role}: ${snippet}`;
280
+ });
281
+ return [
282
+ `ARCHIVED tier summary (${entries.length} turn(s) outside hot/warm retention windows)`,
283
+ ...lines,
284
+ ].join('\n');
285
+ }
286
+ #snippet(value) {
287
+ const normalized = value.replace(/\s+/g, ' ').trim();
288
+ if (!normalized) {
289
+ return '[empty]';
290
+ }
291
+ if (normalized.length <= this.#config.summarySnippetChars) {
292
+ return normalized;
293
+ }
294
+ return `${normalized.slice(0, this.#config.summarySnippetChars - 3)}...`;
295
+ }
296
+ #estimateMessageTokens(message) {
297
+ const content = message.content ?? '';
298
+ return this.estimateTokens(`${message.role.toUpperCase()}: ${content}`);
299
+ }
300
+ }
301
+ function clamp(value, min, max) {
302
+ return Math.min(max, Math.max(min, value));
303
+ }
304
+ function readIntEnv(name, fallback) {
305
+ const raw = getConfigValue(name);
306
+ if (!raw) {
307
+ return fallback;
308
+ }
309
+ const parsed = Number(raw);
310
+ if (!Number.isFinite(parsed)) {
311
+ return fallback;
312
+ }
313
+ return Math.floor(parsed);
314
+ }