nonotify 0.1.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.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # nnt (nonotify)
2
+
3
+ Terminal-first notifier for Telegram.
4
+
5
+ Use it to send yourself a message when tasks are done, including from coding agents.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install
11
+ npm run build
12
+ npm link
13
+ ```
14
+
15
+ After linking, `nnt` is available globally.
16
+
17
+ ## Config location
18
+
19
+ - Default: `~/.nnt/config`
20
+ - Override: set `NNT_CONFIG_DIR`
21
+
22
+ Example:
23
+
24
+ ```bash
25
+ export NNT_CONFIG_DIR="$HOME/.config/nnt"
26
+ ```
27
+
28
+ Config is stored as JSON in `<config-dir>/config`.
29
+
30
+ ## Add profile
31
+
32
+ ```bash
33
+ nnt profile add
34
+ ```
35
+
36
+ Optional explicit provider form:
37
+
38
+ ```bash
39
+ nnt profile add telegram
40
+ ```
41
+
42
+ Flow:
43
+
44
+ 1. Enter profile name.
45
+ 2. Enter Telegram bot token.
46
+ 3. Send any message to your bot in Telegram.
47
+ 4. CLI captures `chat_id`, shows connected Telegram `username`, stores the profile, and sends a confirmation message back to chat.
48
+
49
+ The first profile becomes default profile automatically.
50
+
51
+ ## Manage profiles
52
+
53
+ List profiles:
54
+
55
+ ```bash
56
+ nnt profile list
57
+ ```
58
+
59
+ Show default profile:
60
+
61
+ ```bash
62
+ nnt profile default
63
+ ```
64
+
65
+ Set default profile:
66
+
67
+ ```bash
68
+ nnt profile default important-profile
69
+ ```
70
+
71
+ Edit profile (rename, token/chat update, reconnect):
72
+
73
+ ```bash
74
+ nnt profile edit
75
+ nnt profile edit important-profile
76
+ nnt profile edit important-profile --newName=critical-profile
77
+ nnt profile edit critical-profile --botToken=123:abc
78
+ nnt profile edit critical-profile --reconnect
79
+ ```
80
+
81
+ `nnt profile edit` starts interactive mode and asks you to select a profile first.
82
+
83
+ Delete profile:
84
+
85
+ ```bash
86
+ nnt profile delete critical-profile
87
+ ```
88
+
89
+ By default, profile commands print human-readable output in terminal. For strict machine-friendly output, use format flags:
90
+
91
+ ```bash
92
+ nnt profile list --format json
93
+ nnt profile default --format=md
94
+ ```
95
+
96
+ ## Send messages
97
+
98
+ Send using default profile:
99
+
100
+ ```bash
101
+ nnt "Default message"
102
+ ```
103
+
104
+ Send using specific profile:
105
+
106
+ ```bash
107
+ nnt "some message for user" --profile=important-profile
108
+ ```
109
+
110
+ Equivalent explicit command:
111
+
112
+ ```bash
113
+ nnt send "some message for user" --profile=important-profile
114
+ ```
115
+
116
+ ## Typical agent usage
117
+
118
+ ```bash
119
+ nnt "Task finished: migrations applied and tests passed"
120
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ import { Cli, z } from 'incur';
3
+ import { askConfirm, askRequired, askRequiredWithInitial, askSelect } from './prompt.js';
4
+ import { getConfigPath, loadConfig, saveConfig } from './config.js';
5
+ import { printKeyValueTable, printProfilesTable } from './display.js';
6
+ import { getLatestUpdateOffset, sendTelegramMessage, waitForChatId } from './telegram.js';
7
+ const profileCli = Cli.create('profile', {
8
+ description: 'Manage notification profiles',
9
+ });
10
+ profileCli.command('add', {
11
+ description: 'Add a notification profile',
12
+ outputPolicy: 'agent-only',
13
+ args: z.object({
14
+ provider: z.enum(['telegram']).default('telegram').describe('Profile provider'),
15
+ }),
16
+ async run(c) {
17
+ if (c.args.provider !== 'telegram') {
18
+ throw new Error(`Unsupported provider: ${c.args.provider}`);
19
+ }
20
+ const config = await loadConfig();
21
+ const profileName = await askRequired('Profile name: ');
22
+ if (config.profiles[profileName]) {
23
+ throw new Error(`Profile "${profileName}" already exists.`);
24
+ }
25
+ const botToken = await askRequired('Telegram bot token: ');
26
+ if (shouldRenderPretty(c.agent)) {
27
+ process.stdout.write('\nSend any message to your bot in Telegram.\n');
28
+ process.stdout.write('Waiting for message to detect chat_id (up to 120s)...\n');
29
+ }
30
+ const offset = await getLatestUpdateOffset(botToken);
31
+ const connection = await waitForChatId(botToken, offset, 120);
32
+ const chatId = connection.chatId;
33
+ if (shouldRenderPretty(c.agent)) {
34
+ if (connection.username) {
35
+ process.stdout.write(`Connected Telegram username: @${connection.username}\n`);
36
+ }
37
+ else {
38
+ process.stdout.write('Connected Telegram user has no username set.\n');
39
+ }
40
+ }
41
+ config.profiles[profileName] = {
42
+ type: 'telegram',
43
+ name: profileName,
44
+ botToken,
45
+ chatId,
46
+ createdAt: new Date().toISOString(),
47
+ };
48
+ if (!config.defaultProfile) {
49
+ config.defaultProfile = profileName;
50
+ }
51
+ await saveConfig(config);
52
+ let confirmationSent = false;
53
+ let confirmationWarning = null;
54
+ try {
55
+ await sendTelegramMessage(botToken, chatId, `nnt: profile "${profileName}" connected successfully. You can now send notifications from CLI.`);
56
+ confirmationSent = true;
57
+ }
58
+ catch (error) {
59
+ confirmationWarning = error instanceof Error ? error.message : 'Unknown error while sending confirmation';
60
+ }
61
+ if (shouldRenderPretty(c.agent)) {
62
+ printKeyValueTable('Profile added', [
63
+ { key: 'profile', value: profileName },
64
+ { key: 'provider', value: 'telegram' },
65
+ { key: 'chat_id', value: chatId },
66
+ { key: 'username', value: connection.username ? `@${connection.username}` : '(none)' },
67
+ { key: 'default', value: config.defaultProfile ?? '(none)' },
68
+ ]);
69
+ }
70
+ return {
71
+ added: true,
72
+ profile: profileName,
73
+ provider: 'telegram',
74
+ chatId,
75
+ username: connection.username,
76
+ defaultProfile: config.defaultProfile,
77
+ configPath: getConfigPath(),
78
+ confirmationSent,
79
+ confirmationWarning,
80
+ };
81
+ },
82
+ });
83
+ profileCli.command('list', {
84
+ description: 'List configured profiles',
85
+ outputPolicy: 'agent-only',
86
+ async run(c) {
87
+ const config = await loadConfig();
88
+ const names = Object.keys(config.profiles).sort((a, b) => a.localeCompare(b));
89
+ const profiles = names.map(name => ({
90
+ name,
91
+ provider: config.profiles[name].type,
92
+ isDefault: name === config.defaultProfile,
93
+ }));
94
+ if (shouldRenderPretty(c.agent)) {
95
+ printProfilesTable(profiles);
96
+ }
97
+ return {
98
+ defaultProfile: config.defaultProfile,
99
+ totalProfiles: names.length,
100
+ profiles,
101
+ };
102
+ },
103
+ });
104
+ profileCli.command('default', {
105
+ description: 'Get or set default profile',
106
+ outputPolicy: 'agent-only',
107
+ args: z.object({
108
+ profile: z.string().optional().describe('Profile name to set as default'),
109
+ }),
110
+ async run(c) {
111
+ const config = await loadConfig();
112
+ if (!c.args.profile) {
113
+ if (shouldRenderPretty(c.agent)) {
114
+ printKeyValueTable('Default profile', [
115
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
116
+ ]);
117
+ }
118
+ return {
119
+ defaultProfile: config.defaultProfile,
120
+ };
121
+ }
122
+ if (!config.profiles[c.args.profile]) {
123
+ throw new Error(`Profile "${c.args.profile}" not found.`);
124
+ }
125
+ config.defaultProfile = c.args.profile;
126
+ await saveConfig(config);
127
+ if (shouldRenderPretty(c.agent)) {
128
+ printKeyValueTable('Default profile updated', [
129
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
130
+ ]);
131
+ }
132
+ return {
133
+ updated: true,
134
+ defaultProfile: config.defaultProfile,
135
+ };
136
+ },
137
+ });
138
+ profileCli.command('delete', {
139
+ description: 'Delete a profile',
140
+ outputPolicy: 'agent-only',
141
+ args: z.object({
142
+ profile: z.string().describe('Profile name to delete'),
143
+ }),
144
+ async run(c) {
145
+ const config = await loadConfig();
146
+ const targetName = c.args.profile;
147
+ const profile = config.profiles[targetName];
148
+ if (!profile) {
149
+ throw new Error(`Profile "${targetName}" not found.`);
150
+ }
151
+ delete config.profiles[targetName];
152
+ if (config.defaultProfile === targetName) {
153
+ const remaining = Object.keys(config.profiles).sort((a, b) => a.localeCompare(b));
154
+ config.defaultProfile = remaining[0] ?? null;
155
+ }
156
+ await saveConfig(config);
157
+ if (shouldRenderPretty(c.agent)) {
158
+ printKeyValueTable('Profile deleted', [
159
+ { key: 'profile', value: targetName },
160
+ { key: 'provider', value: profile.type },
161
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
162
+ ]);
163
+ }
164
+ return {
165
+ deleted: true,
166
+ profile: targetName,
167
+ provider: profile.type,
168
+ defaultProfile: config.defaultProfile,
169
+ };
170
+ },
171
+ });
172
+ profileCli.command('edit', {
173
+ description: 'Edit profile data',
174
+ outputPolicy: 'agent-only',
175
+ args: z.object({
176
+ profile: z.string().optional().describe('Existing profile name'),
177
+ }),
178
+ options: z.object({
179
+ newName: z.string().optional().describe('Rename profile to a new name'),
180
+ botToken: z.string().optional().describe('Replace Telegram bot token'),
181
+ chatId: z.string().optional().describe('Replace Telegram chat id'),
182
+ reconnect: z.boolean().optional().describe('Re-detect chat id from next Telegram message'),
183
+ }),
184
+ alias: {
185
+ newName: 'n',
186
+ botToken: 't',
187
+ chatId: 'c',
188
+ reconnect: 'r',
189
+ },
190
+ async run(c) {
191
+ const config = await loadConfig();
192
+ const profileNames = Object.keys(config.profiles).sort((a, b) => a.localeCompare(b));
193
+ if (profileNames.length === 0) {
194
+ throw new Error('No profiles found. Run `nnt profile add` first.');
195
+ }
196
+ const hasDirectEditOptions = Boolean(c.options.newName || c.options.botToken || c.options.chatId || c.options.reconnect);
197
+ const sourceName = await resolveProfileForEdit(c.args.profile, profileNames, hasDirectEditOptions);
198
+ const sourceProfile = config.profiles[sourceName];
199
+ if (!sourceProfile) {
200
+ throw new Error(`Profile "${sourceName}" not found.`);
201
+ }
202
+ const targetName = c.options.newName ?? sourceName;
203
+ if (targetName !== sourceName && config.profiles[targetName]) {
204
+ throw new Error(`Profile "${targetName}" already exists.`);
205
+ }
206
+ const botToken = c.options.botToken ?? sourceProfile.botToken;
207
+ let nextName = targetName;
208
+ let nextBotToken = botToken;
209
+ let chatId = c.options.chatId ?? sourceProfile.chatId;
210
+ let connectedUsername = null;
211
+ if (!hasDirectEditOptions && canPromptInteractively()) {
212
+ nextName = await askRequiredWithInitial('Profile name', sourceName);
213
+ if (nextName !== sourceName && config.profiles[nextName]) {
214
+ throw new Error(`Profile "${nextName}" already exists.`);
215
+ }
216
+ nextBotToken = await askRequiredWithInitial('Telegram bot token', sourceProfile.botToken);
217
+ const shouldReconnect = await askConfirm('Reconnect and detect chat_id from a new message?', false);
218
+ if (shouldReconnect) {
219
+ if (shouldRenderPretty(c.agent)) {
220
+ process.stdout.write('\nSend any message to your bot in Telegram.\n');
221
+ process.stdout.write('Waiting for message to detect chat_id (up to 120s)...\n');
222
+ }
223
+ const offset = await getLatestUpdateOffset(nextBotToken);
224
+ const connection = await waitForChatId(nextBotToken, offset, 120);
225
+ chatId = connection.chatId;
226
+ connectedUsername = connection.username;
227
+ if (shouldRenderPretty(c.agent)) {
228
+ if (connection.username) {
229
+ process.stdout.write(`Connected Telegram username: @${connection.username}\n`);
230
+ }
231
+ else {
232
+ process.stdout.write('Connected Telegram user has no username set.\n');
233
+ }
234
+ }
235
+ }
236
+ else {
237
+ chatId = await askRequiredWithInitial('Telegram chat_id', sourceProfile.chatId);
238
+ }
239
+ }
240
+ if (c.options.reconnect) {
241
+ if (shouldRenderPretty(c.agent)) {
242
+ process.stdout.write('\nSend any message to your bot in Telegram.\n');
243
+ process.stdout.write('Waiting for message to detect chat_id (up to 120s)...\n');
244
+ }
245
+ const offset = await getLatestUpdateOffset(nextBotToken);
246
+ const connection = await waitForChatId(nextBotToken, offset, 120);
247
+ chatId = connection.chatId;
248
+ connectedUsername = connection.username;
249
+ if (shouldRenderPretty(c.agent)) {
250
+ if (connection.username) {
251
+ process.stdout.write(`Connected Telegram username: @${connection.username}\n`);
252
+ }
253
+ else {
254
+ process.stdout.write('Connected Telegram user has no username set.\n');
255
+ }
256
+ }
257
+ }
258
+ const updatedProfile = {
259
+ ...sourceProfile,
260
+ name: nextName,
261
+ botToken: nextBotToken,
262
+ chatId,
263
+ };
264
+ if (nextName !== sourceName) {
265
+ delete config.profiles[sourceName];
266
+ }
267
+ config.profiles[nextName] = updatedProfile;
268
+ if (config.defaultProfile === sourceName) {
269
+ config.defaultProfile = nextName;
270
+ }
271
+ const hasChanges = nextName !== sourceName
272
+ || nextBotToken !== sourceProfile.botToken
273
+ || chatId !== sourceProfile.chatId;
274
+ if (!hasChanges) {
275
+ return {
276
+ updated: false,
277
+ profile: sourceName,
278
+ provider: sourceProfile.type,
279
+ defaultProfile: config.defaultProfile,
280
+ connectedUsername,
281
+ };
282
+ }
283
+ await saveConfig(config);
284
+ if (shouldRenderPretty(c.agent)) {
285
+ printKeyValueTable('Profile updated', [
286
+ { key: 'profile', value: nextName },
287
+ { key: 'provider', value: updatedProfile.type },
288
+ { key: 'chat_id', value: chatId },
289
+ { key: 'username', value: connectedUsername ? `@${connectedUsername}` : '(unchanged/none)' },
290
+ { key: 'default', value: config.defaultProfile ?? '(not set)' },
291
+ ]);
292
+ }
293
+ return {
294
+ updated: true,
295
+ previousProfile: sourceName,
296
+ profile: nextName,
297
+ provider: updatedProfile.type,
298
+ defaultProfile: config.defaultProfile,
299
+ connectedUsername,
300
+ };
301
+ },
302
+ });
303
+ const cli = Cli.create('nnt', {
304
+ description: 'Send Telegram notifications from terminal and agents',
305
+ })
306
+ .command('send', {
307
+ description: 'Send a message via a saved profile',
308
+ args: z.object({
309
+ message: z.string().describe('Message text to send'),
310
+ }),
311
+ options: z.object({
312
+ profile: z.string().optional().describe('Profile name from config'),
313
+ }),
314
+ alias: {
315
+ profile: 'p',
316
+ },
317
+ async run(c) {
318
+ const config = await loadConfig();
319
+ const profileName = c.options.profile ?? config.defaultProfile;
320
+ if (!profileName) {
321
+ throw new Error('No default profile found. Run `nnt profile add` first.');
322
+ }
323
+ const profile = config.profiles[profileName];
324
+ if (!profile) {
325
+ throw new Error(`Profile "${profileName}" not found.`);
326
+ }
327
+ await sendTelegramMessage(profile.botToken, profile.chatId, c.args.message);
328
+ return {
329
+ sent: true,
330
+ profile: profileName,
331
+ provider: profile.type,
332
+ };
333
+ },
334
+ })
335
+ .command(profileCli);
336
+ function routeDefaultCommand(argv) {
337
+ if (argv.length === 0) {
338
+ return argv;
339
+ }
340
+ const topLevelCommands = new Set(['profile', 'send', 'skills', 'mcp']);
341
+ const bareGlobalFlags = new Set(['--help', '-h', '--version', '--llms', '--mcp', '--json', '--verbose']);
342
+ let index = 0;
343
+ while (index < argv.length) {
344
+ const token = argv[index];
345
+ if (token === '--format') {
346
+ index += 2;
347
+ continue;
348
+ }
349
+ if (bareGlobalFlags.has(token)) {
350
+ index += 1;
351
+ continue;
352
+ }
353
+ break;
354
+ }
355
+ if (index >= argv.length) {
356
+ return argv;
357
+ }
358
+ if (topLevelCommands.has(argv[index])) {
359
+ return argv;
360
+ }
361
+ return [...argv.slice(0, index), 'send', ...argv.slice(index)];
362
+ }
363
+ function normalizeFormatFlag(argv) {
364
+ const normalized = [];
365
+ for (const token of argv) {
366
+ if (token.startsWith('--format=')) {
367
+ normalized.push('--format', token.slice('--format='.length));
368
+ continue;
369
+ }
370
+ normalized.push(token);
371
+ }
372
+ return normalized;
373
+ }
374
+ function isStrictOutputRequested(argv) {
375
+ for (const token of argv) {
376
+ if (token === '--json' || token === '--verbose' || token === '--format') {
377
+ return true;
378
+ }
379
+ }
380
+ return false;
381
+ }
382
+ function isAgentEnvironment() {
383
+ return [
384
+ process.env.OPENCODE,
385
+ process.env.CLAUDECODE,
386
+ process.env.CURSOR_AGENT,
387
+ process.env.AIDER_SESSION,
388
+ process.env.NNT_AGENT_MODE,
389
+ ].some(Boolean);
390
+ }
391
+ function withAgentDefaultFormat(argv, strictOutputRequested) {
392
+ if (strictOutputRequested || !isAgentEnvironment()) {
393
+ return argv;
394
+ }
395
+ return ['--format', 'toon', ...argv];
396
+ }
397
+ function canPromptInteractively() {
398
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
399
+ }
400
+ async function resolveProfileForEdit(profileFromArgs, profileNames, hasDirectEditOptions) {
401
+ if (profileFromArgs) {
402
+ return profileFromArgs;
403
+ }
404
+ if (hasDirectEditOptions || !canPromptInteractively()) {
405
+ throw new Error('Profile name is required in non-interactive mode.');
406
+ }
407
+ return askSelect('Select profile to edit', profileNames.map(name => ({
408
+ value: name,
409
+ label: name,
410
+ })));
411
+ }
412
+ const normalizedArgv = normalizeFormatFlag(process.argv.slice(2));
413
+ const strictOutputRequested = isStrictOutputRequested(normalizedArgv);
414
+ const argvWithAgentDefaults = withAgentDefaultFormat(normalizedArgv, strictOutputRequested);
415
+ function shouldRenderPretty(agent) {
416
+ return !agent && !strictOutputRequested && !isAgentEnvironment();
417
+ }
418
+ const routedArgv = routeDefaultCommand(argvWithAgentDefaults);
419
+ await cli.serve(routedArgv);
420
+ export default cli;
package/dist/config.js ADDED
@@ -0,0 +1,49 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
4
+ const DEFAULT_CONFIG = {
5
+ defaultProfile: null,
6
+ profiles: {},
7
+ };
8
+ export function getConfigDir() {
9
+ const dir = process.env.NNT_CONFIG_DIR;
10
+ if (dir && dir.trim() !== '') {
11
+ return resolve(dir);
12
+ }
13
+ return join(homedir(), '.nnt');
14
+ }
15
+ export function getConfigPath() {
16
+ return join(getConfigDir(), 'config');
17
+ }
18
+ export async function loadConfig() {
19
+ const path = getConfigPath();
20
+ try {
21
+ const raw = await readFile(path, 'utf8');
22
+ const parsed = JSON.parse(raw);
23
+ return {
24
+ defaultProfile: typeof parsed.defaultProfile === 'string' ? parsed.defaultProfile : null,
25
+ profiles: parsed.profiles ?? {},
26
+ };
27
+ }
28
+ catch (error) {
29
+ if (isNodeError(error) && error.code === 'ENOENT') {
30
+ return { ...DEFAULT_CONFIG };
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+ export async function saveConfig(config) {
36
+ const dir = getConfigDir();
37
+ const path = getConfigPath();
38
+ await mkdir(dir, { recursive: true });
39
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
40
+ try {
41
+ await chmod(path, 0o600);
42
+ }
43
+ catch {
44
+ // Best effort only.
45
+ }
46
+ }
47
+ function isNodeError(error) {
48
+ return typeof error === 'object' && error !== null && 'code' in error;
49
+ }
@@ -0,0 +1,24 @@
1
+ import Table from 'cli-table3';
2
+ export function printProfilesTable(rows) {
3
+ if (rows.length === 0) {
4
+ process.stdout.write('No profiles configured. Run `nnt profile add`.\n');
5
+ return;
6
+ }
7
+ const table = new Table({
8
+ head: ['Profile', 'Provider', 'Default'],
9
+ });
10
+ for (const row of rows) {
11
+ table.push([row.name, row.provider, row.isDefault ? 'yes' : '']);
12
+ }
13
+ process.stdout.write(`${table.toString()}\n`);
14
+ }
15
+ export function printKeyValueTable(title, rows) {
16
+ process.stdout.write(`${title}\n`);
17
+ const table = new Table({
18
+ head: ['Field', 'Value'],
19
+ });
20
+ for (const row of rows) {
21
+ table.push([row.key, row.value]);
22
+ }
23
+ process.stdout.write(`${table.toString()}\n`);
24
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,47 @@
1
+ import { cancel, confirm, isCancel, select, text } from '@clack/prompts';
2
+ export async function askRequired(question) {
3
+ return askRequiredWithInitial(question);
4
+ }
5
+ export async function askRequiredWithInitial(question, initialValue) {
6
+ const message = normalizeQuestion(question);
7
+ const value = await text({
8
+ message,
9
+ initialValue,
10
+ validate(input) {
11
+ if (!input || input.trim() === '') {
12
+ return 'Value cannot be empty';
13
+ }
14
+ return undefined;
15
+ },
16
+ });
17
+ if (isCancel(value)) {
18
+ cancel('Operation cancelled.');
19
+ process.exit(1);
20
+ }
21
+ return value.trim();
22
+ }
23
+ export async function askConfirm(question, initialValue = false) {
24
+ const value = await confirm({
25
+ message: normalizeQuestion(question),
26
+ initialValue,
27
+ });
28
+ if (isCancel(value)) {
29
+ cancel('Operation cancelled.');
30
+ process.exit(1);
31
+ }
32
+ return value;
33
+ }
34
+ export async function askSelect(question, options) {
35
+ const value = await select({
36
+ message: normalizeQuestion(question),
37
+ options,
38
+ });
39
+ if (isCancel(value)) {
40
+ cancel('Operation cancelled.');
41
+ process.exit(1);
42
+ }
43
+ return value;
44
+ }
45
+ function normalizeQuestion(question) {
46
+ return question.trim().replace(/:\s*$/, '');
47
+ }