@clawchatsai/connector 0.0.59 → 0.0.61
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.
- package/dist/index.js +11 -26
- package/package.json +1 -1
- package/server.js +115 -161
package/dist/index.js
CHANGED
|
@@ -764,7 +764,7 @@ async function handleSetup(token) {
|
|
|
764
764
|
const msg = JSON.parse(raw.toString());
|
|
765
765
|
if (msg.type === 'setup-complete') {
|
|
766
766
|
clearTimeout(timeout);
|
|
767
|
-
console.log(` User: ${msg.userId}`);
|
|
767
|
+
console.log(` User: ${msg.email || msg.userId}`);
|
|
768
768
|
console.log(' Registering gateway... ✅');
|
|
769
769
|
// Save initial config (schemaVersion 1 — will upgrade to 2 after TOTP enrollment)
|
|
770
770
|
const config = {
|
|
@@ -792,21 +792,8 @@ async function handleSetup(token) {
|
|
|
792
792
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
793
793
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
794
794
|
ws.close();
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
if (config.schemaVersion === 1) {
|
|
798
|
-
const rlSetup = (await import('node:readline')).createInterface({ input: process.stdin, output: process.stdout });
|
|
799
|
-
const askSetup = (q) => new Promise(r => rlSetup.question(q, r));
|
|
800
|
-
console.log('');
|
|
801
|
-
console.log(' 💡 Already have ClawChats on another gateway?');
|
|
802
|
-
console.log(' Run \`openclaw clawchats show-totp\` on that machine and paste the secret below.');
|
|
803
|
-
const reuseAnswer = await askSetup(' Paste existing TOTP secret to reuse it (or press Enter to set up new): ');
|
|
804
|
-
rlSetup.close();
|
|
805
|
-
if (reuseAnswer.trim())
|
|
806
|
-
reuseSecret = reuseAnswer.trim();
|
|
807
|
-
}
|
|
808
|
-
// Enroll TOTP (interactive — requires stdin)
|
|
809
|
-
const totpOk = await enrollTotp(config, reuseSecret);
|
|
795
|
+
// Enroll TOTP (interactive — single readline for the whole flow)
|
|
796
|
+
const totpOk = await enrollTotp(config);
|
|
810
797
|
if (!totpOk) {
|
|
811
798
|
console.log('');
|
|
812
799
|
console.log(' ⚠️ TOTP not configured. You can set it up later with: openclaw clawchats reauth');
|
|
@@ -839,11 +826,17 @@ async function handleSetup(token) {
|
|
|
839
826
|
});
|
|
840
827
|
});
|
|
841
828
|
}
|
|
842
|
-
async function enrollTotp(config
|
|
829
|
+
async function enrollTotp(config) {
|
|
843
830
|
const readline = await import('node:readline');
|
|
844
831
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
845
832
|
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
846
833
|
try {
|
|
834
|
+
// Ask once if the user wants to reuse a TOTP secret from another gateway
|
|
835
|
+
console.log('');
|
|
836
|
+
console.log(' 💡 Already have ClawChats on another gateway?');
|
|
837
|
+
console.log(' Run `openclaw clawchats show-totp` on that machine, then paste the secret below.');
|
|
838
|
+
const reuseAnswer = await ask(' Paste existing TOTP secret (or press Enter to set up new): ');
|
|
839
|
+
const existingSecret = reuseAnswer.trim() || undefined;
|
|
847
840
|
let totpSecret;
|
|
848
841
|
if (existingSecret) {
|
|
849
842
|
// Reusing secret from another gateway — strip spaces, uppercase
|
|
@@ -953,15 +946,7 @@ async function handleReauth() {
|
|
|
953
946
|
console.log(' ⚠️ This will invalidate all existing sessions.');
|
|
954
947
|
console.log(' All connected browsers will need to re-authenticate.');
|
|
955
948
|
console.log('');
|
|
956
|
-
const
|
|
957
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
958
|
-
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
959
|
-
let existingSecret;
|
|
960
|
-
const reuseAnswer = await ask(' Reuse TOTP from another gateway? Paste secret (or press Enter to generate new): ');
|
|
961
|
-
rl.close();
|
|
962
|
-
if (reuseAnswer.trim())
|
|
963
|
-
existingSecret = reuseAnswer.trim();
|
|
964
|
-
const success = await enrollTotp(config, existingSecret);
|
|
949
|
+
const success = await enrollTotp(config);
|
|
965
950
|
if (success) {
|
|
966
951
|
console.log(' All previous sessions have been invalidated.');
|
|
967
952
|
console.log(' Restart the gateway for changes to take effect: systemctl --user restart openclaw-gateway');
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -3047,54 +3047,7 @@ class GatewayClient {
|
|
|
3047
3047
|
}
|
|
3048
3048
|
|
|
3049
3049
|
_writeActivityToDb(runId, log) {
|
|
3050
|
-
|
|
3051
|
-
log._parsed = parseSessionKey(log.sessionKey);
|
|
3052
|
-
}
|
|
3053
|
-
const parsed = log._parsed;
|
|
3054
|
-
if (!parsed) return;
|
|
3055
|
-
|
|
3056
|
-
const db = getDb(parsed.workspace);
|
|
3057
|
-
if (!db) return;
|
|
3058
|
-
|
|
3059
|
-
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
3060
|
-
const summary = this.generateActivitySummary(log.steps);
|
|
3061
|
-
const now = Date.now();
|
|
3062
|
-
|
|
3063
|
-
if (!log._messageId) {
|
|
3064
|
-
// First write — INSERT the assistant message row
|
|
3065
|
-
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
3066
|
-
if (!thread) return;
|
|
3067
|
-
|
|
3068
|
-
const messageId = `gw-activity-${runId}`;
|
|
3069
|
-
const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
|
|
3070
|
-
|
|
3071
|
-
db.prepare(`
|
|
3072
|
-
INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
|
|
3073
|
-
VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
|
|
3074
|
-
`).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
|
|
3075
|
-
|
|
3076
|
-
log._messageId = messageId;
|
|
3077
|
-
|
|
3078
|
-
// First event — broadcast message-saved so browser creates the message element
|
|
3079
|
-
this.broadcastToBrowsers(JSON.stringify({
|
|
3080
|
-
type: 'clawchats',
|
|
3081
|
-
event: 'message-saved',
|
|
3082
|
-
threadId: parsed.threadId,
|
|
3083
|
-
workspace: parsed.workspace,
|
|
3084
|
-
messageId,
|
|
3085
|
-
timestamp: now
|
|
3086
|
-
}));
|
|
3087
|
-
} else {
|
|
3088
|
-
// Subsequent writes — UPDATE metadata on existing row
|
|
3089
|
-
const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
|
|
3090
|
-
const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
|
|
3091
|
-
metadata.activityLog = cleanSteps;
|
|
3092
|
-
metadata.activitySummary = summary;
|
|
3093
|
-
metadata.pending = true;
|
|
3094
|
-
|
|
3095
|
-
db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
|
|
3096
|
-
.run(JSON.stringify(metadata), log._messageId);
|
|
3097
|
-
}
|
|
3050
|
+
writeActivityToDb(getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
3098
3051
|
}
|
|
3099
3052
|
|
|
3100
3053
|
_broadcastActivityUpdate(runId, log) {
|
|
@@ -3115,59 +3068,7 @@ class GatewayClient {
|
|
|
3115
3068
|
}
|
|
3116
3069
|
|
|
3117
3070
|
generateActivitySummary(steps) {
|
|
3118
|
-
|
|
3119
|
-
const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
|
|
3120
|
-
const hasNarration = steps.some(s => s.type === 'assistant' && s.text?.trim());
|
|
3121
|
-
if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
|
|
3122
|
-
if (toolSteps.length === 0 && hasThinking) return 'Reasoned through the problem';
|
|
3123
|
-
if (toolSteps.length === 0 && hasNarration) return 'Processed in multiple steps';
|
|
3124
|
-
|
|
3125
|
-
// Count by tool name
|
|
3126
|
-
const counts = {};
|
|
3127
|
-
for (const s of toolSteps) {
|
|
3128
|
-
const name = s.name || 'unknown';
|
|
3129
|
-
counts[name] = (counts[name] || 0) + 1;
|
|
3130
|
-
}
|
|
3131
|
-
|
|
3132
|
-
// Build description
|
|
3133
|
-
const parts = [];
|
|
3134
|
-
const toolNames = {
|
|
3135
|
-
'web_search': 'searched the web',
|
|
3136
|
-
'web_fetch': 'fetched web pages',
|
|
3137
|
-
'Read': 'read files',
|
|
3138
|
-
'read': 'read files',
|
|
3139
|
-
'Write': 'wrote files',
|
|
3140
|
-
'write': 'wrote files',
|
|
3141
|
-
'Edit': 'edited files',
|
|
3142
|
-
'edit': 'edited files',
|
|
3143
|
-
'exec': 'ran commands',
|
|
3144
|
-
'Bash': 'ran commands',
|
|
3145
|
-
'browser': 'browsed the web',
|
|
3146
|
-
'memory_search': 'searched memory',
|
|
3147
|
-
'memory_store': 'saved to memory',
|
|
3148
|
-
'image': 'analyzed images',
|
|
3149
|
-
'message': 'sent messages',
|
|
3150
|
-
'sessions_spawn': 'spawned sub-agents',
|
|
3151
|
-
'cron': 'managed cron jobs',
|
|
3152
|
-
'Grep': 'searched code',
|
|
3153
|
-
'grep': 'searched code',
|
|
3154
|
-
'Glob': 'found files',
|
|
3155
|
-
'glob': 'found files'
|
|
3156
|
-
};
|
|
3157
|
-
|
|
3158
|
-
for (const [name, count] of Object.entries(counts)) {
|
|
3159
|
-
const friendly = toolNames[name];
|
|
3160
|
-
if (friendly) {
|
|
3161
|
-
parts.push(count > 1 ? `${friendly} (${count}×)` : friendly);
|
|
3162
|
-
} else {
|
|
3163
|
-
parts.push(count > 1 ? `used ${name} (${count}×)` : `used ${name}`);
|
|
3164
|
-
}
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
if (parts.length === 0) return null;
|
|
3168
|
-
if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
|
3169
|
-
const last = parts.pop();
|
|
3170
|
-
return (parts.join(', ') + ' and ' + last).replace(/^./, c => c.toUpperCase());
|
|
3071
|
+
return generateActivitySummary(steps);
|
|
3171
3072
|
}
|
|
3172
3073
|
|
|
3173
3074
|
broadcastToBrowsers(data) {
|
|
@@ -3306,6 +3207,117 @@ function extractContent(message) {
|
|
|
3306
3207
|
return '';
|
|
3307
3208
|
}
|
|
3308
3209
|
|
|
3210
|
+
// ─── Shared activity log helpers ─────────────────────────────────────────────
|
|
3211
|
+
// Pure functions extracted from GatewayClient / _GatewayClient so both classes
|
|
3212
|
+
// share a single implementation. Pass the workspace-scoped DB getter and a
|
|
3213
|
+
// bound broadcastToBrowsers fn as arguments.
|
|
3214
|
+
|
|
3215
|
+
function generateActivitySummary(steps) {
|
|
3216
|
+
const toolSteps = steps.filter(s => s.type === 'tool' && s.phase !== 'result' && s.phase !== 'update');
|
|
3217
|
+
const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
|
|
3218
|
+
const hasNarration = steps.some(s => s.type === 'assistant' && s.text?.trim());
|
|
3219
|
+
if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
|
|
3220
|
+
if (toolSteps.length === 0 && hasThinking) return 'Reasoned through the problem';
|
|
3221
|
+
if (toolSteps.length === 0 && hasNarration) return 'Processed in multiple steps';
|
|
3222
|
+
|
|
3223
|
+
const counts = {};
|
|
3224
|
+
for (const s of toolSteps) {
|
|
3225
|
+
const name = s.name || 'unknown';
|
|
3226
|
+
counts[name] = (counts[name] || 0) + 1;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
const parts = [];
|
|
3230
|
+
const toolNames = {
|
|
3231
|
+
'web_search': 'searched the web',
|
|
3232
|
+
'web_fetch': 'fetched web pages',
|
|
3233
|
+
'Read': 'read files',
|
|
3234
|
+
'read': 'read files',
|
|
3235
|
+
'Write': 'wrote files',
|
|
3236
|
+
'write': 'wrote files',
|
|
3237
|
+
'Edit': 'edited files',
|
|
3238
|
+
'edit': 'edited files',
|
|
3239
|
+
'exec': 'ran commands',
|
|
3240
|
+
'Bash': 'ran commands',
|
|
3241
|
+
'browser': 'browsed the web',
|
|
3242
|
+
'memory_search': 'searched memory',
|
|
3243
|
+
'memory_store': 'saved to memory',
|
|
3244
|
+
'image': 'analyzed images',
|
|
3245
|
+
'message': 'sent messages',
|
|
3246
|
+
'sessions_spawn': 'spawned sub-agents',
|
|
3247
|
+
'cron': 'managed cron jobs',
|
|
3248
|
+
'Grep': 'searched code',
|
|
3249
|
+
'grep': 'searched code',
|
|
3250
|
+
'Glob': 'found files',
|
|
3251
|
+
'glob': 'found files'
|
|
3252
|
+
};
|
|
3253
|
+
|
|
3254
|
+
for (const [name, count] of Object.entries(counts)) {
|
|
3255
|
+
const friendly = toolNames[name];
|
|
3256
|
+
if (friendly) {
|
|
3257
|
+
parts.push(count > 1 ? `${friendly} (${count}×)` : friendly);
|
|
3258
|
+
} else {
|
|
3259
|
+
parts.push(count > 1 ? `used ${name} (${count}×)` : `used ${name}`);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
if (parts.length === 0) return null;
|
|
3264
|
+
if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
|
3265
|
+
const last = parts.pop();
|
|
3266
|
+
return (parts.join(', ') + ' and ' + last).replace(/^./, c => c.toUpperCase());
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
function writeActivityToDb(getDbFn, broadcastFn, runId, log) {
|
|
3270
|
+
if (!log._parsed) {
|
|
3271
|
+
log._parsed = parseSessionKey(log.sessionKey);
|
|
3272
|
+
}
|
|
3273
|
+
const parsed = log._parsed;
|
|
3274
|
+
if (!parsed) return;
|
|
3275
|
+
|
|
3276
|
+
const db = getDbFn(parsed.workspace);
|
|
3277
|
+
if (!db) return;
|
|
3278
|
+
|
|
3279
|
+
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
3280
|
+
const summary = generateActivitySummary(log.steps);
|
|
3281
|
+
const now = Date.now();
|
|
3282
|
+
|
|
3283
|
+
if (!log._messageId) {
|
|
3284
|
+
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
3285
|
+
if (!thread) return;
|
|
3286
|
+
|
|
3287
|
+
const messageId = `gw-activity-${runId}`;
|
|
3288
|
+
const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
|
|
3289
|
+
|
|
3290
|
+
try {
|
|
3291
|
+
db.prepare(`
|
|
3292
|
+
INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
|
|
3293
|
+
VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
|
|
3294
|
+
`).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
|
|
3295
|
+
|
|
3296
|
+
log._messageId = messageId;
|
|
3297
|
+
|
|
3298
|
+
broadcastFn(JSON.stringify({
|
|
3299
|
+
type: 'clawchats',
|
|
3300
|
+
event: 'message-saved',
|
|
3301
|
+
threadId: parsed.threadId,
|
|
3302
|
+
workspace: parsed.workspace,
|
|
3303
|
+
messageId,
|
|
3304
|
+
timestamp: now
|
|
3305
|
+
}));
|
|
3306
|
+
} catch (err) {
|
|
3307
|
+
console.error(`[activity] Failed to write activity ${messageId}:`, err.message);
|
|
3308
|
+
}
|
|
3309
|
+
} else {
|
|
3310
|
+
const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
|
|
3311
|
+
const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
|
|
3312
|
+
metadata.activityLog = cleanSteps;
|
|
3313
|
+
metadata.activitySummary = summary;
|
|
3314
|
+
metadata.pending = true;
|
|
3315
|
+
|
|
3316
|
+
db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
|
|
3317
|
+
.run(JSON.stringify(metadata), log._messageId);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3309
3321
|
const gatewayClient = new GatewayClient();
|
|
3310
3322
|
|
|
3311
3323
|
// ─── createApp Factory ───────────────────────────────────────────────────────
|
|
@@ -4264,51 +4276,7 @@ export function createApp(config = {}) {
|
|
|
4264
4276
|
}
|
|
4265
4277
|
|
|
4266
4278
|
_writeActivityToDb(runId, log) {
|
|
4267
|
-
|
|
4268
|
-
log._parsed = parseSessionKey(log.sessionKey);
|
|
4269
|
-
}
|
|
4270
|
-
const parsed = log._parsed;
|
|
4271
|
-
if (!parsed) return;
|
|
4272
|
-
|
|
4273
|
-
const db = _getDb(parsed.workspace);
|
|
4274
|
-
if (!db) return;
|
|
4275
|
-
|
|
4276
|
-
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
4277
|
-
const summary = this.generateActivitySummary(log.steps);
|
|
4278
|
-
const now = Date.now();
|
|
4279
|
-
|
|
4280
|
-
if (!log._messageId) {
|
|
4281
|
-
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
4282
|
-
if (!thread) return;
|
|
4283
|
-
|
|
4284
|
-
const messageId = `gw-activity-${runId}`;
|
|
4285
|
-
const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
|
|
4286
|
-
|
|
4287
|
-
db.prepare(`
|
|
4288
|
-
INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
|
|
4289
|
-
VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
|
|
4290
|
-
`).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
|
|
4291
|
-
|
|
4292
|
-
log._messageId = messageId;
|
|
4293
|
-
|
|
4294
|
-
this.broadcastToBrowsers(JSON.stringify({
|
|
4295
|
-
type: 'clawchats',
|
|
4296
|
-
event: 'message-saved',
|
|
4297
|
-
threadId: parsed.threadId,
|
|
4298
|
-
workspace: parsed.workspace,
|
|
4299
|
-
messageId,
|
|
4300
|
-
timestamp: now
|
|
4301
|
-
}));
|
|
4302
|
-
} else {
|
|
4303
|
-
const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
|
|
4304
|
-
const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
|
|
4305
|
-
metadata.activityLog = cleanSteps;
|
|
4306
|
-
metadata.activitySummary = summary;
|
|
4307
|
-
metadata.pending = true;
|
|
4308
|
-
|
|
4309
|
-
db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
|
|
4310
|
-
.run(JSON.stringify(metadata), log._messageId);
|
|
4311
|
-
}
|
|
4279
|
+
writeActivityToDb(_getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
4312
4280
|
}
|
|
4313
4281
|
|
|
4314
4282
|
_broadcastActivityUpdate(runId, log) {
|
|
@@ -4329,21 +4297,7 @@ export function createApp(config = {}) {
|
|
|
4329
4297
|
}
|
|
4330
4298
|
|
|
4331
4299
|
generateActivitySummary(steps) {
|
|
4332
|
-
|
|
4333
|
-
const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
|
|
4334
|
-
const hasNarration = steps.some(s => s.type === 'assistant' && s.text?.trim());
|
|
4335
|
-
if (toolSteps.length === 0 && !hasThinking && !hasNarration) return null;
|
|
4336
|
-
if (toolSteps.length === 0 && hasThinking) return 'Reasoned through the problem';
|
|
4337
|
-
if (toolSteps.length === 0 && hasNarration) return 'Processed in multiple steps';
|
|
4338
|
-
const counts = {};
|
|
4339
|
-
for (const s of toolSteps) { const name = s.name || 'unknown'; counts[name] = (counts[name] || 0) + 1; }
|
|
4340
|
-
const parts = [];
|
|
4341
|
-
const toolNames = { 'web_search': 'searched the web', 'web_fetch': 'fetched web pages', 'Read': 'read files', 'read': 'read files', 'Write': 'wrote files', 'write': 'wrote files', 'Edit': 'edited files', 'edit': 'edited files', 'exec': 'ran commands', 'Bash': 'ran commands', 'browser': 'browsed the web', 'memory_search': 'searched memory', 'memory_store': 'saved to memory', 'image': 'analyzed images', 'message': 'sent messages', 'sessions_spawn': 'spawned sub-agents', 'cron': 'managed cron jobs', 'Grep': 'searched code', 'grep': 'searched code', 'Glob': 'found files', 'glob': 'found files' };
|
|
4342
|
-
for (const [name, count] of Object.entries(counts)) { const friendly = toolNames[name]; if (friendly) parts.push(count > 1 ? `${friendly} (${count}x)` : friendly); else parts.push(count > 1 ? `used ${name} (${count}x)` : `used ${name}`); }
|
|
4343
|
-
if (parts.length === 0) return null;
|
|
4344
|
-
if (parts.length === 1) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
|
4345
|
-
const last = parts.pop();
|
|
4346
|
-
return (parts.join(', ') + ' and ' + last).replace(/^./, c => c.toUpperCase());
|
|
4300
|
+
return generateActivitySummary(steps);
|
|
4347
4301
|
}
|
|
4348
4302
|
|
|
4349
4303
|
addBroadcastTarget(fn) { this._externalBroadcastTargets.push(fn); }
|