ai-control-center 1.15.2

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +584 -0
  3. package/bin/aicc.js +772 -0
  4. package/lib/actions/approve.js +71 -0
  5. package/lib/actions/assign-project.js +132 -0
  6. package/lib/actions/browser-test.js +64 -0
  7. package/lib/actions/cleanup.js +174 -0
  8. package/lib/actions/debug.js +298 -0
  9. package/lib/actions/deploy.js +1229 -0
  10. package/lib/actions/fix-bug.js +134 -0
  11. package/lib/actions/new-feature.js +255 -0
  12. package/lib/actions/reject.js +307 -0
  13. package/lib/actions/review.js +706 -0
  14. package/lib/actions/status.js +47 -0
  15. package/lib/agents/browser-qa-agent.js +611 -0
  16. package/lib/agents/payment-agent.js +116 -0
  17. package/lib/agents/suggestion-agent.js +88 -0
  18. package/lib/cli.js +303 -0
  19. package/lib/config.js +243 -0
  20. package/lib/hub/hub-server.js +440 -0
  21. package/lib/hub/project-poller.js +75 -0
  22. package/lib/hub/skill-registry.js +89 -0
  23. package/lib/hub/state-aggregator.js +204 -0
  24. package/lib/index.js +471 -0
  25. package/lib/init/doctor.js +523 -0
  26. package/lib/init/presets.js +222 -0
  27. package/lib/init/skill-fetcher.js +77 -0
  28. package/lib/init/wizard.js +973 -0
  29. package/lib/integrations/codex-runner.js +128 -0
  30. package/lib/integrations/github-actions.js +248 -0
  31. package/lib/integrations/github-reporter.js +229 -0
  32. package/lib/integrations/screenshot-store.js +102 -0
  33. package/lib/openclaw/bridge.js +650 -0
  34. package/lib/openclaw/generate-skill.js +235 -0
  35. package/lib/openclaw/openclaw.json +64 -0
  36. package/lib/orchestrator/autonomous-loop.js +429 -0
  37. package/lib/orchestrator/thread-triggers.js +63 -0
  38. package/lib/roleplay/agent-messenger.js +75 -0
  39. package/lib/roleplay/discussion-threads.js +303 -0
  40. package/lib/roleplay/health-monitor.js +121 -0
  41. package/lib/roleplay/pm-agent.js +513 -0
  42. package/lib/roleplay/roleplay-config.js +25 -0
  43. package/lib/roleplay/room.js +164 -0
  44. package/lib/shared/action-runner.js +2330 -0
  45. package/lib/shared/event-bus.js +185 -0
  46. package/lib/slack/bot.js +378 -0
  47. package/lib/telegram/bot.js +416 -0
  48. package/lib/telegram/commands.js +1267 -0
  49. package/lib/telegram/keyboards.js +113 -0
  50. package/lib/telegram/notifications.js +247 -0
  51. package/lib/twitch/bot.js +354 -0
  52. package/lib/twitch/commands.js +302 -0
  53. package/lib/twitch/notifications.js +63 -0
  54. package/lib/utils/achievements.js +191 -0
  55. package/lib/utils/activity-log.js +182 -0
  56. package/lib/utils/agent-leaderboard.js +119 -0
  57. package/lib/utils/audit-logger.js +232 -0
  58. package/lib/utils/codebase-context.js +288 -0
  59. package/lib/utils/codebase-indexer.js +381 -0
  60. package/lib/utils/config-schema.js +230 -0
  61. package/lib/utils/context-compressor.js +172 -0
  62. package/lib/utils/correlation.js +63 -0
  63. package/lib/utils/cost-tracker.js +423 -0
  64. package/lib/utils/cron-scheduler.js +53 -0
  65. package/lib/utils/db-adapter.js +293 -0
  66. package/lib/utils/display.js +272 -0
  67. package/lib/utils/errors.js +116 -0
  68. package/lib/utils/format.js +134 -0
  69. package/lib/utils/intent-engine.js +464 -0
  70. package/lib/utils/mcp-client.js +238 -0
  71. package/lib/utils/model-ab-test.js +164 -0
  72. package/lib/utils/notify.js +122 -0
  73. package/lib/utils/persona-loader.js +80 -0
  74. package/lib/utils/pipeline-lock.js +73 -0
  75. package/lib/utils/pipeline.js +214 -0
  76. package/lib/utils/plugin-runner.js +234 -0
  77. package/lib/utils/rate-limiter.js +84 -0
  78. package/lib/utils/rbac.js +74 -0
  79. package/lib/utils/runner.js +1809 -0
  80. package/lib/utils/security.js +191 -0
  81. package/lib/utils/self-healer.js +144 -0
  82. package/lib/utils/skill-loader.js +255 -0
  83. package/lib/utils/spinner.js +132 -0
  84. package/lib/utils/stage-queue.js +50 -0
  85. package/lib/utils/state-machine.js +89 -0
  86. package/lib/utils/status-bar.js +327 -0
  87. package/lib/utils/token-estimator.js +101 -0
  88. package/lib/utils/ux-analyzer.js +101 -0
  89. package/lib/utils/webhook-emitter.js +83 -0
  90. package/lib/web/public/css/styles.css +417 -0
  91. package/lib/web/public/dark-mode.js +44 -0
  92. package/lib/web/public/hub/kanban.html +206 -0
  93. package/lib/web/public/index.html +45 -0
  94. package/lib/web/public/js/app.js +71 -0
  95. package/lib/web/public/js/ask.js +110 -0
  96. package/lib/web/public/js/dashboard.js +165 -0
  97. package/lib/web/public/js/deploy.js +72 -0
  98. package/lib/web/public/js/feature.js +79 -0
  99. package/lib/web/public/js/health.js +65 -0
  100. package/lib/web/public/js/logs.js +93 -0
  101. package/lib/web/public/js/review.js +123 -0
  102. package/lib/web/public/js/ws-client.js +82 -0
  103. package/lib/web/public/office/css/office.css +678 -0
  104. package/lib/web/public/office/index.html +148 -0
  105. package/lib/web/public/office/js/achievements-ui.js +117 -0
  106. package/lib/web/public/office/js/character.js +1056 -0
  107. package/lib/web/public/office/js/chat-bubbles.js +177 -0
  108. package/lib/web/public/office/js/cost-overlay.js +123 -0
  109. package/lib/web/public/office/js/day-night.js +68 -0
  110. package/lib/web/public/office/js/effects.js +632 -0
  111. package/lib/web/public/office/js/engine.js +146 -0
  112. package/lib/web/public/office/js/feature-ticket.js +216 -0
  113. package/lib/web/public/office/js/hub-client.js +60 -0
  114. package/lib/web/public/office/js/main.js +1757 -0
  115. package/lib/web/public/office/js/office-layout.js +1524 -0
  116. package/lib/web/public/office/js/pathfinding.js +144 -0
  117. package/lib/web/public/office/js/pixel-sprites.js +1454 -0
  118. package/lib/web/public/office/js/progress-bars.js +117 -0
  119. package/lib/web/public/office/js/replay.js +191 -0
  120. package/lib/web/public/office/js/sound-effects.js +91 -0
  121. package/lib/web/public/office/js/sprite-renderer.js +211 -0
  122. package/lib/web/public/office/js/stamina-system.js +89 -0
  123. package/lib/web/public/office/js/ui.js +107 -0
  124. package/lib/web/public/onboarding/index.html +243 -0
  125. package/lib/web/public/timeline/index.html +195 -0
  126. package/lib/web/routes/api.js +499 -0
  127. package/lib/web/routes/logs.js +20 -0
  128. package/lib/web/routes/metrics.js +99 -0
  129. package/lib/web/server.js +183 -0
  130. package/lib/web/ws/handler.js +65 -0
  131. package/package.json +67 -0
  132. package/templates/agent-architect.md +69 -0
  133. package/templates/agent-gemini-pm.md +49 -0
  134. package/templates/agent-gemini-reviewer.md +52 -0
  135. package/templates/copilot-instructions.md +36 -0
  136. package/templates/pipelines/mobile.json +27 -0
  137. package/templates/pipelines/nodejs-api.json +27 -0
  138. package/templates/pipelines/python.json +27 -0
  139. package/templates/pipelines/react.json +27 -0
  140. package/templates/pipelines/salesforce.json +27 -0
  141. package/templates/role-gemini.md +97 -0
  142. package/templates/skill-architect.md +114 -0
  143. package/templates/skill-browser-qa.md +50 -0
  144. package/templates/skill-bug-from-qa.md +58 -0
  145. package/templates/skill-chatbot.md +93 -0
  146. package/templates/skill-implement.md +78 -0
  147. package/templates/skill-openclaw.md +174 -0
  148. package/templates/skill-payment.md +110 -0
  149. package/templates/skill-pm-spec.md +77 -0
  150. package/templates/skill-requirement-capture.md +97 -0
  151. package/templates/skill-review.md +108 -0
  152. package/templates/skill-reviewer-qa.md +44 -0
  153. package/templates/skill-suggestion.md +45 -0
  154. package/templates/skill-template.md +142 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Project Poller — periodically fetches /api/status and /api/health
3
+ * from each registered project instance.
4
+ */
5
+
6
+ export class ProjectPoller {
7
+ /**
8
+ * @param {Array<{name:string, url:string, color?:string}>} projects
9
+ * @param {import('./state-aggregator.js').StateAggregator} aggregator
10
+ * @param {number} intervalMs — polling interval (default 3000ms)
11
+ */
12
+ constructor(projects, aggregator, intervalMs = 3000) {
13
+ this._projects = projects;
14
+ this._aggregator = aggregator;
15
+ this._intervalMs = intervalMs;
16
+ this._timer = null;
17
+ }
18
+
19
+ /** Start polling all projects */
20
+ start() {
21
+ this._poll(); // immediate first poll
22
+ this._timer = setInterval(() => this._poll(), this._intervalMs);
23
+ console.log(` [Hub] Polling ${this._projects.length} project(s) every ${this._intervalMs / 1000}s`);
24
+ }
25
+
26
+ /** Stop polling */
27
+ stop() {
28
+ if (this._timer) { clearInterval(this._timer); this._timer = null; }
29
+ }
30
+
31
+ /** Immediately re-poll a single project by name (called on push-event) */
32
+ pollProject(name) {
33
+ const project = this._projects.find(p => p.name === name);
34
+ if (project) this._pollOne(project).catch(() => {});
35
+ }
36
+
37
+ /** Poll all projects in parallel */
38
+ async _poll() {
39
+ await Promise.allSettled(
40
+ this._projects.map(p => this._pollOne(p))
41
+ );
42
+ }
43
+
44
+ /** Poll a single project */
45
+ async _pollOne(project) {
46
+ const { name, url } = project;
47
+ try {
48
+ const controller = new AbortController();
49
+ const timeout = setTimeout(() => controller.abort(), 5000);
50
+
51
+ const [statusRes, healthRes] = await Promise.allSettled([
52
+ fetch(`${url}/api/status`, { signal: controller.signal }),
53
+ fetch(`${url}/api/health`, { signal: controller.signal }),
54
+ ]);
55
+
56
+ clearTimeout(timeout);
57
+
58
+ const status = statusRes.status === 'fulfilled' && statusRes.value.ok
59
+ ? await statusRes.value.json()
60
+ : null;
61
+
62
+ const health = healthRes.status === 'fulfilled' && healthRes.value.ok
63
+ ? await healthRes.value.json()
64
+ : null;
65
+
66
+ if (status || health) {
67
+ this._aggregator.update(name, status, health);
68
+ } else {
69
+ this._aggregator.markOffline(name);
70
+ }
71
+ } catch {
72
+ this._aggregator.markOffline(name);
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,89 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { getWorkflowDir } from '../utils/pipeline.js';
4
+
5
+ const REGISTRY_FILENAME = 'skills_index.json';
6
+
7
+ const DEFAULT_REGISTRY = [
8
+ { name: 'security-review', stage: 'review', description: 'Security-focused code review with OWASP checklist', tags: ['security', 'review', 'owasp'], source: 'built-in' },
9
+ { name: 'api-design', stage: 'arch', description: 'REST API design best practices and validation', tags: ['api', 'rest', 'architecture'], source: 'built-in' },
10
+ { name: 'unit-testing', stage: 'test', description: 'Unit test generation and coverage analysis', tags: ['testing', 'unit-tests', 'coverage'], source: 'built-in' },
11
+ { name: 'integration-testing', stage: 'test', description: 'Integration and end-to-end test patterns', tags: ['testing', 'integration', 'e2e'], source: 'built-in' },
12
+ { name: 'performance-audit', stage: 'review', description: 'Performance profiling and optimization recommendations', tags: ['performance', 'profiling', 'optimization'], source: 'built-in' },
13
+ { name: 'accessibility-check', stage: 'review', description: 'WCAG compliance and accessibility audit', tags: ['accessibility', 'a11y', 'wcag'], source: 'built-in' },
14
+ { name: 'doc-generator', stage: 'docs', description: 'Auto-generate API and module documentation', tags: ['documentation', 'jsdoc', 'markdown'], source: 'built-in' },
15
+ { name: 'db-schema-review', stage: 'arch', description: 'Database schema design and migration review', tags: ['database', 'schema', 'migrations'], source: 'built-in' },
16
+ { name: 'error-handling', stage: 'review', description: 'Error handling patterns and resilience checks', tags: ['error-handling', 'resilience', 'exceptions'], source: 'built-in' },
17
+ { name: 'logging-standards', stage: 'review', description: 'Structured logging and observability best practices', tags: ['logging', 'observability', 'monitoring'], source: 'built-in' },
18
+ { name: 'ci-cd-pipeline', stage: 'deploy', description: 'CI/CD pipeline configuration and optimization', tags: ['ci-cd', 'github-actions', 'deployment'], source: 'built-in' },
19
+ { name: 'containerization', stage: 'deploy', description: 'Dockerfile and container best practices', tags: ['docker', 'containerization', 'kubernetes'], source: 'built-in' },
20
+ { name: 'frontend-patterns', stage: 'arch', description: 'Frontend architecture patterns and component design', tags: ['frontend', 'components', 'state-management'], source: 'built-in' },
21
+ { name: 'mobile-dev', stage: 'arch', description: 'Mobile development patterns and platform guidelines', tags: ['mobile', 'ios', 'android'], source: 'built-in' },
22
+ { name: 'code-quality', stage: 'review', description: 'Code quality metrics, linting, and style enforcement', tags: ['code-quality', 'linting', 'style'], source: 'built-in' },
23
+ { name: 'refactoring', stage: 'review', description: 'Refactoring patterns and code smell detection', tags: ['refactoring', 'code-smells', 'clean-code'], source: 'built-in' },
24
+ { name: 'dependency-audit', stage: 'review', description: 'Dependency vulnerability scanning and license checks', tags: ['security', 'dependencies', 'vulnerabilities'], source: 'built-in' },
25
+ { name: 'api-testing', stage: 'test', description: 'API contract testing and response validation', tags: ['testing', 'api', 'contract-testing'], source: 'built-in' },
26
+ ];
27
+
28
+ export function getRegistryPath() {
29
+ return resolve(getWorkflowDir(), REGISTRY_FILENAME);
30
+ }
31
+
32
+ export function getDefaultRegistry() {
33
+ return structuredClone(DEFAULT_REGISTRY);
34
+ }
35
+
36
+ export function getRegistry() {
37
+ const registryPath = getRegistryPath();
38
+ if (!existsSync(registryPath)) {
39
+ return getDefaultRegistry();
40
+ }
41
+ try {
42
+ const raw = readFileSync(registryPath, 'utf-8');
43
+ return JSON.parse(raw);
44
+ } catch {
45
+ return getDefaultRegistry();
46
+ }
47
+ }
48
+
49
+ export function searchSkills(query) {
50
+ const registry = getRegistry();
51
+ const q = query.toLowerCase();
52
+ return registry.filter((skill) => {
53
+ const haystack = [
54
+ skill.name,
55
+ skill.description,
56
+ ...(skill.tags || []),
57
+ ].join(' ').toLowerCase();
58
+ return haystack.includes(q);
59
+ });
60
+ }
61
+
62
+ export function filterByStage(stage) {
63
+ const registry = getRegistry();
64
+ const s = stage.toLowerCase();
65
+ return registry.filter((skill) => (skill.stage || '').toLowerCase() === s);
66
+ }
67
+
68
+ export function addToRegistry(skillMeta) {
69
+ const registry = getRegistry();
70
+ const existing = registry.findIndex((s) => s.name === skillMeta.name);
71
+ if (existing !== -1) {
72
+ registry[existing] = { ...registry[existing], ...skillMeta };
73
+ } else {
74
+ registry.push(skillMeta);
75
+ }
76
+ const registryPath = getRegistryPath();
77
+ mkdirSync(dirname(registryPath), { recursive: true });
78
+ writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8');
79
+ return registry;
80
+ }
81
+
82
+ export function removeFromRegistry(skillName) {
83
+ const registry = getRegistry();
84
+ const filtered = registry.filter((s) => s.name !== skillName);
85
+ const registryPath = getRegistryPath();
86
+ mkdirSync(dirname(registryPath), { recursive: true });
87
+ writeFileSync(registryPath, JSON.stringify(filtered, null, 2), 'utf-8');
88
+ return filtered;
89
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * State Aggregator — merges status from multiple AICC project instances.
3
+ *
4
+ * Provides functions to aggregate, diff, and format multi-project state.
5
+ * Also exports the StateAggregator class for internal hub use.
6
+ */
7
+
8
+ // ─── StateAggregator class (used by project-poller.js) ─────────────────────────
9
+
10
+ /**
11
+ * Maintains an in-memory Map of project states, updated by the poller.
12
+ * Broadcasts changes via a callback.
13
+ */
14
+ export class StateAggregator {
15
+ constructor() {
16
+ /** @type {Map<string, object>} */
17
+ this._states = new Map();
18
+ this._listeners = [];
19
+ }
20
+
21
+ /** Register a listener called on any state change */
22
+ onChange(fn) { this._listeners.push(fn); }
23
+
24
+ /** Update a single project's state */
25
+ update(projectName, status, health) {
26
+ const prev = this._states.get(projectName);
27
+ const next = {
28
+ name: projectName,
29
+ status: status || {},
30
+ health: health || {},
31
+ online: !!status,
32
+ lastPoll: Date.now(),
33
+ };
34
+
35
+ const changed = !prev
36
+ || prev.status?.stage !== next.status?.stage
37
+ || prev.status?.current_feature !== next.status?.current_feature
38
+ || prev.online !== next.online
39
+ || JSON.stringify(prev.status?._bannedModels) !== JSON.stringify(next.status?._bannedModels);
40
+
41
+ this._states.set(projectName, next);
42
+
43
+ if (changed) {
44
+ for (const fn of this._listeners) {
45
+ try { fn(projectName, next); } catch (e) { console.error('[Hub] listener error:', e.message); }
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Mark a project as offline */
51
+ markOffline(projectName) {
52
+ const prev = this._states.get(projectName);
53
+ if (prev && prev.online) {
54
+ prev.online = false;
55
+ prev.lastPoll = Date.now();
56
+ for (const fn of this._listeners) {
57
+ try { fn(projectName, prev); } catch (e) { console.error('[Hub] listener error:', e.message); }
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Get all project states as array */
63
+ getAll() {
64
+ return Array.from(this._states.values());
65
+ }
66
+
67
+ /** Get single project state */
68
+ get(name) {
69
+ return this._states.get(name) || null;
70
+ }
71
+ }
72
+
73
+ // ─── Functional API ────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Merge state from multiple projects into a single aggregated object.
77
+ *
78
+ * @param {Array<{name:string, url:string, color?:string, icon?:string}>} projectConfigs
79
+ * @param {Array<{name:string, status:object|null, health:object|null, online:boolean}>} responses
80
+ * @returns {object} Aggregated state
81
+ */
82
+ export function aggregateStates(projectConfigs, responses) {
83
+ const projects = {};
84
+ let active = 0;
85
+ let idle = 0;
86
+ let offline = 0;
87
+
88
+ for (const cfg of projectConfigs) {
89
+ const resp = responses.find(r => r.name === cfg.name) || { status: null, health: null, online: false };
90
+ const isOnline = resp.online;
91
+
92
+ projects[cfg.name] = {
93
+ name: cfg.name,
94
+ url: cfg.url,
95
+ color: cfg.color || '#888888',
96
+ icon: cfg.icon || '📦',
97
+ status: resp.status || {},
98
+ health: resp.health || {},
99
+ online: isOnline,
100
+ };
101
+
102
+ if (!isOnline) {
103
+ offline++;
104
+ } else {
105
+ const stage = resp.status?.stage || '';
106
+ const activeStages = ['reviewing', 'implementing', 'deploying', 'testing', 'fixing'];
107
+ if (activeStages.some(s => stage.toLowerCase().includes(s))) {
108
+ active++;
109
+ } else {
110
+ idle++;
111
+ }
112
+ }
113
+ }
114
+
115
+ return {
116
+ timestamp: Date.now(),
117
+ projects,
118
+ summary: {
119
+ total: projectConfigs.length,
120
+ active,
121
+ idle,
122
+ offline,
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Compare two aggregated states and return an array of change events.
129
+ *
130
+ * @param {object} oldState — previous aggregated state
131
+ * @param {object} newState — current aggregated state
132
+ * @returns {Array<{project:string, type:string, from:*, to:*}>}
133
+ */
134
+ export function detectChanges(oldState, newState) {
135
+ const changes = [];
136
+ if (!oldState || !newState) return changes;
137
+
138
+ for (const [name, newProject] of Object.entries(newState.projects)) {
139
+ const oldProject = oldState.projects?.[name];
140
+
141
+ if (!oldProject) {
142
+ changes.push({ project: name, type: 'added', from: null, to: newProject });
143
+ continue;
144
+ }
145
+
146
+ // Online/offline transitions
147
+ if (oldProject.online !== newProject.online) {
148
+ changes.push({
149
+ project: name,
150
+ type: 'connectivity',
151
+ from: oldProject.online ? 'online' : 'offline',
152
+ to: newProject.online ? 'online' : 'offline',
153
+ });
154
+ }
155
+
156
+ // Stage transitions
157
+ const oldStage = oldProject.status?.stage;
158
+ const newStage = newProject.status?.stage;
159
+ if (oldStage !== newStage) {
160
+ changes.push({ project: name, type: 'stage', from: oldStage, to: newStage });
161
+ }
162
+
163
+ // Feature changes
164
+ const oldFeature = oldProject.status?.current_feature;
165
+ const newFeature = newProject.status?.current_feature;
166
+ if (oldFeature !== newFeature) {
167
+ changes.push({ project: name, type: 'feature', from: oldFeature, to: newFeature });
168
+ }
169
+ }
170
+
171
+ // Detect removals
172
+ for (const name of Object.keys(oldState.projects || {})) {
173
+ if (!newState.projects[name]) {
174
+ changes.push({ project: name, type: 'removed', from: oldState.projects[name], to: null });
175
+ }
176
+ }
177
+
178
+ return changes;
179
+ }
180
+
181
+ /**
182
+ * Format aggregated state as a human-readable string.
183
+ *
184
+ * @param {object} aggregated — output of aggregateStates()
185
+ * @returns {string}
186
+ */
187
+ export function formatHubStatus(aggregated) {
188
+ if (!aggregated) return 'Hub: no data';
189
+
190
+ const { summary, projects } = aggregated;
191
+ const lines = [
192
+ `🏢 Hub Status — ${summary.total} project(s): ${summary.active} active, ${summary.idle} idle, ${summary.offline} offline`,
193
+ '─'.repeat(50),
194
+ ];
195
+
196
+ for (const p of Object.values(projects)) {
197
+ const statusIcon = p.online ? '🟢' : '🔴';
198
+ const stage = p.status?.stage || 'unknown';
199
+ const feature = p.status?.current_feature || '—';
200
+ lines.push(` ${statusIcon} ${p.icon} ${p.name} │ ${stage} │ ${feature}`);
201
+ }
202
+
203
+ return lines.join('\n');
204
+ }