discoclaw 0.5.7 → 0.5.8
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 +2 -7
- package/dist/config.js +1 -0
- package/dist/discord/actions-forge.js +1 -1
- package/dist/discord/actions-messaging.js +37 -4
- package/dist/discord/actions-messaging.test.js +219 -4
- package/dist/discord/durable-memory.js +47 -4
- package/dist/discord/durable-memory.test.js +137 -1
- package/dist/discord/forge-commands.js +71 -15
- package/dist/discord/forge-commands.test.js +195 -45
- package/dist/discord/message-coordinator.js +9 -3
- package/dist/discord/prompt-common.js +1 -1
- package/dist/discord/reaction-handler.js +2 -1
- package/dist/discord/summarizer.js +18 -0
- package/dist/discord/summarizer.test.js +50 -1
- package/dist/discord/update-command.js +1 -2
- package/dist/index.js +5 -0
- package/dist/npm-managed.js +0 -1
- package/dist/npm-managed.test.js +0 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -221,17 +221,12 @@ Full step-by-step guide: [docs/discord-bot-setup.md](docs/discord-bot-setup.md)
|
|
|
221
221
|
npm install -g discoclaw
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
> **Fedora 43+ / GCC 14+ — `@discordjs/opus` build failure**
|
|
224
|
+
> **Fedora 43+ / GCC 14+ — `@discordjs/opus` build failure (resolved)**
|
|
225
225
|
>
|
|
226
|
-
>
|
|
227
|
-
> ```
|
|
228
|
-
> error: incompatible pointer types passing ...
|
|
229
|
-
> ```
|
|
230
|
-
> **Workaround** — set the flag before installing:
|
|
226
|
+
> This was fixed upstream in `@discordjs/opus` 0.10.0. If you are pinned to an older version, set the flag before installing:
|
|
231
227
|
> ```bash
|
|
232
228
|
> CFLAGS="-Wno-error=incompatible-pointer-types" npm install -g discoclaw
|
|
233
229
|
> ```
|
|
234
|
-
> This is a known upstream issue in the `@discordjs/opus` native addon. It only requires the flag override at install time; runtime behavior is unaffected.
|
|
235
230
|
|
|
236
231
|
2. **Run the interactive setup wizard** (creates `.env` and scaffolds your workspace):
|
|
237
232
|
```bash
|
package/dist/config.js
CHANGED
|
@@ -377,6 +377,7 @@ export function parseConfig(env) {
|
|
|
377
377
|
summaryMaxChars: parseNonNegativeInt(env, 'DISCOCLAW_SUMMARY_MAX_CHARS', 2000),
|
|
378
378
|
summaryEveryNTurns: parsePositiveInt(env, 'DISCOCLAW_SUMMARY_EVERY_N_TURNS', 5),
|
|
379
379
|
summaryDataDirOverride: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_DATA_DIR'),
|
|
380
|
+
summaryArchiveDirOverride: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_ARCHIVE_DIR'),
|
|
380
381
|
durableMemoryEnabled: parseBoolean(env, 'DISCOCLAW_DURABLE_MEMORY_ENABLED', true),
|
|
381
382
|
durableDataDirOverride: parseTrimmedString(env, 'DISCOCLAW_DURABLE_DATA_DIR'),
|
|
382
383
|
durableInjectMaxChars: parsePositiveInt(env, 'DISCOCLAW_DURABLE_INJECT_MAX_CHARS', 2000),
|
|
@@ -96,7 +96,7 @@ export async function executeForgeAction(action, ctx, forgeCtx) {
|
|
|
96
96
|
if (!orch?.isRunning) {
|
|
97
97
|
return { ok: false, error: 'No forge is currently running.' };
|
|
98
98
|
}
|
|
99
|
-
orch.requestCancel();
|
|
99
|
+
orch.requestCancel('forgeCancel action');
|
|
100
100
|
const activeId = getActiveForgeId();
|
|
101
101
|
return { ok: true, summary: `Cancel requested${activeId ? ` for ${activeId}` : ''}` };
|
|
102
102
|
}
|
|
@@ -3,6 +3,29 @@ import * as fs from 'node:fs/promises';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { resolveChannel, fmtTime, findChannelRaw, describeChannelType } from './action-utils.js';
|
|
5
5
|
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
6
|
+
/** Serialize Discord embeds into a compact text representation. */
|
|
7
|
+
function formatEmbeds(embeds, truncate) {
|
|
8
|
+
if (!embeds?.length)
|
|
9
|
+
return '';
|
|
10
|
+
const parts = [];
|
|
11
|
+
for (const e of embeds) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
if (e.title)
|
|
14
|
+
lines.push(`Title: ${e.title}`);
|
|
15
|
+
if (e.description)
|
|
16
|
+
lines.push(`Description: ${e.description}`);
|
|
17
|
+
if (e.url)
|
|
18
|
+
lines.push(`URL: ${e.url}`);
|
|
19
|
+
for (const f of e.fields) {
|
|
20
|
+
lines.push(`${f.name}: ${f.value}`);
|
|
21
|
+
}
|
|
22
|
+
if (e.footer?.text)
|
|
23
|
+
lines.push(`Footer: ${e.footer.text}`);
|
|
24
|
+
parts.push(lines.join('\n'));
|
|
25
|
+
}
|
|
26
|
+
const full = parts.join('\n---\n');
|
|
27
|
+
return truncate ? full.slice(0, truncate) : full;
|
|
28
|
+
}
|
|
6
29
|
const MESSAGING_TYPE_MAP = {
|
|
7
30
|
sendMessage: true, react: true, unreact: true, readMessages: true, fetchMessage: true,
|
|
8
31
|
editMessage: true, deleteMessage: true, bulkDelete: true, crosspost: true, threadCreate: true,
|
|
@@ -138,7 +161,10 @@ export async function executeMessagingAction(action, ctx) {
|
|
|
138
161
|
const lines = sorted.map((m) => {
|
|
139
162
|
const author = m.author?.username ?? 'Unknown';
|
|
140
163
|
const time = fmtTime(m.createdAt);
|
|
141
|
-
const
|
|
164
|
+
const content = m.content || '';
|
|
165
|
+
const embed = formatEmbeds(m.embeds, 200);
|
|
166
|
+
const combined = content || embed || '(no text)';
|
|
167
|
+
const text = (content && embed ? `${content} [Embed: ${embed}]` : combined).slice(0, 300);
|
|
142
168
|
return `[${author}] ${text} (${time}, id:${m.id})`;
|
|
143
169
|
});
|
|
144
170
|
return { ok: true, summary: `Messages in #${channel.name}:\n${lines.join('\n')}` };
|
|
@@ -155,7 +181,11 @@ export async function executeMessagingAction(action, ctx) {
|
|
|
155
181
|
const message = await messageChannel.messages.fetch(action.messageId);
|
|
156
182
|
const author = message.author?.username ?? 'Unknown';
|
|
157
183
|
const time = fmtTime(message.createdAt);
|
|
158
|
-
const
|
|
184
|
+
const contentText = message.content || '';
|
|
185
|
+
const embedText = formatEmbeds(message.embeds, action.full ? undefined : 2000);
|
|
186
|
+
const body = contentText || embedText || '(no text)';
|
|
187
|
+
const combined = contentText && embedText ? `${contentText}\n[Embeds]\n${embedText}` : body;
|
|
188
|
+
const text = action.full ? combined : combined.slice(0, 2000);
|
|
159
189
|
return { ok: true, summary: `[${author}]: ${text} (${time}, #${messageChannel.name}, id:${message.id})` };
|
|
160
190
|
}
|
|
161
191
|
case 'editMessage': {
|
|
@@ -296,7 +326,10 @@ export async function executeMessagingAction(action, ctx) {
|
|
|
296
326
|
}
|
|
297
327
|
const lines = [...pinned.values()].map((m) => {
|
|
298
328
|
const author = m.author?.username ?? 'Unknown';
|
|
299
|
-
const
|
|
329
|
+
const content = m.content || '';
|
|
330
|
+
const embed = formatEmbeds(m.embeds, 200);
|
|
331
|
+
const combined = content || embed || '(no text)';
|
|
332
|
+
const text = (content && embed ? `${content} [Embed: ${embed}]` : combined).slice(0, 300);
|
|
300
333
|
return `[${author}] ${text} (id:${m.id})`;
|
|
301
334
|
});
|
|
302
335
|
return { ok: true, summary: `Pinned messages in #${channel.name}:\n${lines.join('\n')}` };
|
|
@@ -400,7 +433,7 @@ export function messagingActionsPromptSection() {
|
|
|
400
433
|
\`\`\`
|
|
401
434
|
<discord-action>{"type":"fetchMessage","channelId":"123","messageId":"456","full":true}</discord-action>
|
|
402
435
|
\`\`\`
|
|
403
|
-
- \`full\` (optional): When true, returns the complete message content without truncation. Default: false (content truncated to
|
|
436
|
+
- \`full\` (optional): When true, returns the complete message content without truncation. Default: false (content truncated to 2000 chars).
|
|
404
437
|
|
|
405
438
|
**editMessage** — Edit a bot message:
|
|
406
439
|
\`\`\`
|
|
@@ -35,11 +35,21 @@ function makeMockChannel(overrides = {}) {
|
|
|
35
35
|
...(overrides.extraProps ?? {}),
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
function makeEmbed(overrides = {}) {
|
|
39
|
+
return {
|
|
40
|
+
title: overrides.title ?? null,
|
|
41
|
+
description: overrides.description ?? null,
|
|
42
|
+
url: overrides.url ?? null,
|
|
43
|
+
fields: overrides.fields ?? [],
|
|
44
|
+
footer: overrides.footer ?? null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
38
47
|
function makeMockMessage(id, overrides = {}) {
|
|
39
48
|
const { author: authorName, ...rest } = overrides;
|
|
40
49
|
return {
|
|
41
50
|
id,
|
|
42
51
|
content: rest.content ?? 'Hello',
|
|
52
|
+
embeds: rest.embeds ?? [],
|
|
43
53
|
author: { username: authorName ?? 'testuser' },
|
|
44
54
|
createdAt: new Date('2025-01-15T12:00:00Z'),
|
|
45
55
|
createdTimestamp: new Date('2025-01-15T12:00:00Z').getTime(),
|
|
@@ -391,6 +401,37 @@ describe('readMessages', () => {
|
|
|
391
401
|
expect(result.error).toContain('forum channel');
|
|
392
402
|
expect(result.error).not.toContain('not found');
|
|
393
403
|
});
|
|
404
|
+
it('shows embed text instead of "(no text)" for embed-only messages', async () => {
|
|
405
|
+
const msg = makeMockMessage('m1', {
|
|
406
|
+
content: '',
|
|
407
|
+
author: 'alice',
|
|
408
|
+
embeds: [makeEmbed({ title: 'Cron Prompt', description: 'Check the weather' })],
|
|
409
|
+
});
|
|
410
|
+
const fetchedMessages = new Map([['m1', msg]]);
|
|
411
|
+
const ch = makeMockChannel({ name: 'general', fetchedMessages });
|
|
412
|
+
const ctx = makeCtx([ch]);
|
|
413
|
+
const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
|
|
414
|
+
expect(result.ok).toBe(true);
|
|
415
|
+
const summary = result.summary;
|
|
416
|
+
expect(summary).toContain('Title: Cron Prompt');
|
|
417
|
+
expect(summary).toContain('Description: Check the weather');
|
|
418
|
+
expect(summary).not.toContain('(no text)');
|
|
419
|
+
});
|
|
420
|
+
it('shows content with [Embed: ...] when both content and embeds present', async () => {
|
|
421
|
+
const msg = makeMockMessage('m1', {
|
|
422
|
+
content: 'Hello world',
|
|
423
|
+
author: 'alice',
|
|
424
|
+
embeds: [makeEmbed({ title: 'Link Preview', description: 'A website' })],
|
|
425
|
+
});
|
|
426
|
+
const fetchedMessages = new Map([['m1', msg]]);
|
|
427
|
+
const ch = makeMockChannel({ name: 'general', fetchedMessages });
|
|
428
|
+
const ctx = makeCtx([ch]);
|
|
429
|
+
const result = await executeMessagingAction({ type: 'readMessages', channel: '#general', limit: 5 }, ctx);
|
|
430
|
+
expect(result.ok).toBe(true);
|
|
431
|
+
const summary = result.summary;
|
|
432
|
+
expect(summary).toContain('Hello world [Embed:');
|
|
433
|
+
expect(summary).toContain('Title: Link Preview');
|
|
434
|
+
});
|
|
394
435
|
it('clamps limit to 20', async () => {
|
|
395
436
|
const ch = makeMockChannel({ name: 'general', fetchedMessages: new Map() });
|
|
396
437
|
const ctx = makeCtx([ch]);
|
|
@@ -419,16 +460,16 @@ describe('fetchMessage', () => {
|
|
|
419
460
|
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: '' }, ctx);
|
|
420
461
|
expect(result).toEqual({ ok: false, error: 'fetchMessage requires a non-empty messageId' });
|
|
421
462
|
});
|
|
422
|
-
it('truncates content to
|
|
423
|
-
const longContent = 'x'.repeat(
|
|
463
|
+
it('truncates content to 2000 chars by default', async () => {
|
|
464
|
+
const longContent = 'x'.repeat(2500);
|
|
424
465
|
const msg = makeMockMessage('msg1', { content: longContent, author: 'alice' });
|
|
425
466
|
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
426
467
|
ch.messages.fetch = vi.fn(async () => msg);
|
|
427
468
|
const ctx = makeCtx([ch]);
|
|
428
469
|
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
|
|
429
470
|
expect(result.ok).toBe(true);
|
|
430
|
-
expect(result.summary).not.toContain('x'.repeat(
|
|
431
|
-
expect(result.summary).toContain('x'.repeat(
|
|
471
|
+
expect(result.summary).not.toContain('x'.repeat(2500));
|
|
472
|
+
expect(result.summary).toContain('x'.repeat(2000));
|
|
432
473
|
});
|
|
433
474
|
it('returns full content when full flag is true', async () => {
|
|
434
475
|
const longContent = 'x'.repeat(600);
|
|
@@ -440,6 +481,148 @@ describe('fetchMessage', () => {
|
|
|
440
481
|
expect(result.ok).toBe(true);
|
|
441
482
|
expect(result.summary).toContain(longContent);
|
|
442
483
|
});
|
|
484
|
+
it('surfaces embed text for embed-only message', async () => {
|
|
485
|
+
const msg = makeMockMessage('msg1', {
|
|
486
|
+
content: '',
|
|
487
|
+
author: 'alice',
|
|
488
|
+
embeds: [makeEmbed({ title: 'Cron Prompt', description: 'Check the weather' })],
|
|
489
|
+
});
|
|
490
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
491
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
492
|
+
const ctx = makeCtx([ch]);
|
|
493
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
|
|
494
|
+
expect(result.ok).toBe(true);
|
|
495
|
+
const summary = result.summary;
|
|
496
|
+
expect(summary).toContain('Title: Cron Prompt');
|
|
497
|
+
expect(summary).toContain('Description: Check the weather');
|
|
498
|
+
expect(summary).not.toContain('(no text)');
|
|
499
|
+
});
|
|
500
|
+
it('shows content with [Embeds] section when both content and embeds present', async () => {
|
|
501
|
+
const msg = makeMockMessage('msg1', {
|
|
502
|
+
content: 'Hello world',
|
|
503
|
+
author: 'alice',
|
|
504
|
+
embeds: [makeEmbed({ title: 'Link Preview' })],
|
|
505
|
+
});
|
|
506
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
507
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
508
|
+
const ctx = makeCtx([ch]);
|
|
509
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
|
|
510
|
+
expect(result.ok).toBe(true);
|
|
511
|
+
const summary = result.summary;
|
|
512
|
+
expect(summary).toContain('Hello world');
|
|
513
|
+
expect(summary).toContain('[Embeds]');
|
|
514
|
+
expect(summary).toContain('Title: Link Preview');
|
|
515
|
+
});
|
|
516
|
+
it('returns untruncated embed text when full is true', async () => {
|
|
517
|
+
const longDesc = 'x'.repeat(3000);
|
|
518
|
+
const msg = makeMockMessage('msg1', {
|
|
519
|
+
content: '',
|
|
520
|
+
author: 'alice',
|
|
521
|
+
embeds: [makeEmbed({ title: 'Big Embed', description: longDesc })],
|
|
522
|
+
});
|
|
523
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
524
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
525
|
+
const ctx = makeCtx([ch]);
|
|
526
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1', full: true }, ctx);
|
|
527
|
+
expect(result.ok).toBe(true);
|
|
528
|
+
const summary = result.summary;
|
|
529
|
+
expect(summary).toContain(longDesc);
|
|
530
|
+
});
|
|
531
|
+
it('formats embed with title and description', async () => {
|
|
532
|
+
const msg = makeMockMessage('msg1', {
|
|
533
|
+
content: '',
|
|
534
|
+
author: 'alice',
|
|
535
|
+
embeds: [makeEmbed({ title: 'My Title', description: 'My Description' })],
|
|
536
|
+
});
|
|
537
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
538
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
539
|
+
const ctx = makeCtx([ch]);
|
|
540
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1', full: true }, ctx);
|
|
541
|
+
const summary = result.summary;
|
|
542
|
+
expect(summary).toContain('Title: My Title');
|
|
543
|
+
expect(summary).toContain('Description: My Description');
|
|
544
|
+
});
|
|
545
|
+
it('formats embed fields', async () => {
|
|
546
|
+
const msg = makeMockMessage('msg1', {
|
|
547
|
+
content: '',
|
|
548
|
+
author: 'alice',
|
|
549
|
+
embeds: [makeEmbed({
|
|
550
|
+
fields: [
|
|
551
|
+
{ name: 'Status', value: 'Active' },
|
|
552
|
+
{ name: 'Priority', value: 'High' },
|
|
553
|
+
],
|
|
554
|
+
})],
|
|
555
|
+
});
|
|
556
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
557
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
558
|
+
const ctx = makeCtx([ch]);
|
|
559
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1', full: true }, ctx);
|
|
560
|
+
const summary = result.summary;
|
|
561
|
+
expect(summary).toContain('Status: Active');
|
|
562
|
+
expect(summary).toContain('Priority: High');
|
|
563
|
+
});
|
|
564
|
+
it('formats embed footer and URL', async () => {
|
|
565
|
+
const msg = makeMockMessage('msg1', {
|
|
566
|
+
content: '',
|
|
567
|
+
author: 'alice',
|
|
568
|
+
embeds: [makeEmbed({
|
|
569
|
+
url: 'https://example.com',
|
|
570
|
+
footer: { text: 'Footer text' },
|
|
571
|
+
})],
|
|
572
|
+
});
|
|
573
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
574
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
575
|
+
const ctx = makeCtx([ch]);
|
|
576
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1', full: true }, ctx);
|
|
577
|
+
const summary = result.summary;
|
|
578
|
+
expect(summary).toContain('URL: https://example.com');
|
|
579
|
+
expect(summary).toContain('Footer: Footer text');
|
|
580
|
+
});
|
|
581
|
+
it('separates multiple embeds with ---', async () => {
|
|
582
|
+
const msg = makeMockMessage('msg1', {
|
|
583
|
+
content: '',
|
|
584
|
+
author: 'alice',
|
|
585
|
+
embeds: [
|
|
586
|
+
makeEmbed({ title: 'First' }),
|
|
587
|
+
makeEmbed({ title: 'Second' }),
|
|
588
|
+
],
|
|
589
|
+
});
|
|
590
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
591
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
592
|
+
const ctx = makeCtx([ch]);
|
|
593
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1', full: true }, ctx);
|
|
594
|
+
const summary = result.summary;
|
|
595
|
+
expect(summary).toContain('Title: First');
|
|
596
|
+
expect(summary).toContain('---');
|
|
597
|
+
expect(summary).toContain('Title: Second');
|
|
598
|
+
});
|
|
599
|
+
it('shows "(no text)" for message with no content and no embeds', async () => {
|
|
600
|
+
const msg = makeMockMessage('msg1', {
|
|
601
|
+
content: '',
|
|
602
|
+
author: 'alice',
|
|
603
|
+
embeds: [],
|
|
604
|
+
});
|
|
605
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
606
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
607
|
+
const ctx = makeCtx([ch]);
|
|
608
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
|
|
609
|
+
const summary = result.summary;
|
|
610
|
+
expect(summary).toContain('(no text)');
|
|
611
|
+
});
|
|
612
|
+
it('truncates embed text when full is false', async () => {
|
|
613
|
+
const longDesc = 'x'.repeat(3000);
|
|
614
|
+
const msg = makeMockMessage('msg1', {
|
|
615
|
+
content: '',
|
|
616
|
+
author: 'alice',
|
|
617
|
+
embeds: [makeEmbed({ description: longDesc })],
|
|
618
|
+
});
|
|
619
|
+
const ch = makeMockChannel({ id: 'ch1', name: 'general' });
|
|
620
|
+
ch.messages.fetch = vi.fn(async () => msg);
|
|
621
|
+
const ctx = makeCtx([ch]);
|
|
622
|
+
const result = await executeMessagingAction({ type: 'fetchMessage', channelId: 'ch1', messageId: 'msg1' }, ctx);
|
|
623
|
+
const summary = result.summary;
|
|
624
|
+
expect(summary).not.toContain('x'.repeat(3000));
|
|
625
|
+
});
|
|
443
626
|
});
|
|
444
627
|
describe('editMessage', () => {
|
|
445
628
|
it('edits a message', async () => {
|
|
@@ -661,6 +844,38 @@ describe('listPins', () => {
|
|
|
661
844
|
expect(result.error).toContain('forum channel');
|
|
662
845
|
expect(result.error).not.toContain('not found');
|
|
663
846
|
});
|
|
847
|
+
it('surfaces embed text for pinned embed-only message', async () => {
|
|
848
|
+
const pinned = new Map([
|
|
849
|
+
['p1', makeMockMessage('p1', {
|
|
850
|
+
content: '',
|
|
851
|
+
author: 'alice',
|
|
852
|
+
embeds: [makeEmbed({ title: 'Pinned Embed', description: 'Important info' })],
|
|
853
|
+
})],
|
|
854
|
+
]);
|
|
855
|
+
const ch = makeMockChannel({ name: 'general', pinnedMessages: pinned });
|
|
856
|
+
const ctx = makeCtx([ch]);
|
|
857
|
+
const result = await executeMessagingAction({ type: 'listPins', channel: '#general' }, ctx);
|
|
858
|
+
expect(result.ok).toBe(true);
|
|
859
|
+
const summary = result.summary;
|
|
860
|
+
expect(summary).toContain('Title: Pinned Embed');
|
|
861
|
+
expect(summary).not.toContain('(no text)');
|
|
862
|
+
});
|
|
863
|
+
it('shows combined content and embed for pinned message with both', async () => {
|
|
864
|
+
const pinned = new Map([
|
|
865
|
+
['p1', makeMockMessage('p1', {
|
|
866
|
+
content: 'Check this out',
|
|
867
|
+
author: 'alice',
|
|
868
|
+
embeds: [makeEmbed({ title: 'Link Preview' })],
|
|
869
|
+
})],
|
|
870
|
+
]);
|
|
871
|
+
const ch = makeMockChannel({ name: 'general', pinnedMessages: pinned });
|
|
872
|
+
const ctx = makeCtx([ch]);
|
|
873
|
+
const result = await executeMessagingAction({ type: 'listPins', channel: '#general' }, ctx);
|
|
874
|
+
expect(result.ok).toBe(true);
|
|
875
|
+
const summary = result.summary;
|
|
876
|
+
expect(summary).toContain('Check this out [Embed:');
|
|
877
|
+
expect(summary).toContain('Title: Link Preview');
|
|
878
|
+
});
|
|
664
879
|
it('returns empty message when no pins', async () => {
|
|
665
880
|
const ch = makeMockChannel({ name: 'general' });
|
|
666
881
|
const ctx = makeCtx([ch]);
|
|
@@ -157,11 +157,54 @@ export function deprecateItems(store, substring) {
|
|
|
157
157
|
store.updatedAt = now;
|
|
158
158
|
return { store, deprecatedCount };
|
|
159
159
|
}
|
|
160
|
-
|
|
160
|
+
const STOP_WORDS = new Set([
|
|
161
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
162
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
163
|
+
'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
|
|
164
|
+
'on', 'with', 'at', 'by', 'from', 'as', 'into', 'about', 'but',
|
|
165
|
+
'not', 'or', 'and', 'if', 'then', 'so', 'that', 'this', 'it',
|
|
166
|
+
'its', 'my', 'your', 'our', 'their', 'his', 'her', 'what', 'which',
|
|
167
|
+
'who', 'how', 'when', 'where', 'why',
|
|
168
|
+
]);
|
|
169
|
+
export function tokenize(text) {
|
|
170
|
+
const tokens = text
|
|
171
|
+
.toLowerCase()
|
|
172
|
+
.split(/[^a-z0-9]+/)
|
|
173
|
+
.filter((t) => t.length >= 3 && !STOP_WORDS.has(t));
|
|
174
|
+
return new Set(tokens);
|
|
175
|
+
}
|
|
176
|
+
export function keywordRelevance(queryTokens, itemText, itemTags) {
|
|
177
|
+
if (queryTokens.size === 0)
|
|
178
|
+
return 0;
|
|
179
|
+
const itemTokens = tokenize(itemText + ' ' + itemTags.join(' '));
|
|
180
|
+
let matches = 0;
|
|
181
|
+
for (const token of queryTokens) {
|
|
182
|
+
if (itemTokens.has(token))
|
|
183
|
+
matches++;
|
|
184
|
+
}
|
|
185
|
+
return matches / queryTokens.size;
|
|
186
|
+
}
|
|
187
|
+
const QUERY_BOOST_WEIGHT = 2.0;
|
|
188
|
+
export function selectItemsForInjection(store, maxChars, query) {
|
|
161
189
|
const now = Date.now();
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
190
|
+
const queryTokens = query ? tokenize(query) : new Set();
|
|
191
|
+
const activeItems = store.items.filter((item) => item.status === 'active');
|
|
192
|
+
// Pre-compute blended scores and find max for normalization.
|
|
193
|
+
const blendedScores = new Map();
|
|
194
|
+
let maxBlended = 0;
|
|
195
|
+
for (const item of activeItems) {
|
|
196
|
+
const score = blendedInjectionScore(item, now);
|
|
197
|
+
blendedScores.set(item.id, score);
|
|
198
|
+
if (score > maxBlended)
|
|
199
|
+
maxBlended = score;
|
|
200
|
+
}
|
|
201
|
+
const active = activeItems.sort((a, b) => {
|
|
202
|
+
const normA = maxBlended > 0 ? (blendedScores.get(a.id) ?? 0) / maxBlended : 0;
|
|
203
|
+
const normB = maxBlended > 0 ? (blendedScores.get(b.id) ?? 0) / maxBlended : 0;
|
|
204
|
+
const scoreA = normA + QUERY_BOOST_WEIGHT * keywordRelevance(queryTokens, a.text, a.tags);
|
|
205
|
+
const scoreB = normB + QUERY_BOOST_WEIGHT * keywordRelevance(queryTokens, b.text, b.tags);
|
|
206
|
+
return scoreB - scoreA;
|
|
207
|
+
});
|
|
165
208
|
const selected = [];
|
|
166
209
|
let chars = 0;
|
|
167
210
|
for (const item of active) {
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { loadDurableMemory, saveDurableMemory, deriveItemId, addItem, deprecateItems, selectItemsForInjection, formatDurableSection, scoreItem, blendedInjectionScore, recordHits, CURRENT_VERSION, } from './durable-memory.js';
|
|
5
|
+
import { loadDurableMemory, saveDurableMemory, deriveItemId, addItem, deprecateItems, selectItemsForInjection, formatDurableSection, scoreItem, blendedInjectionScore, recordHits, tokenize, keywordRelevance, CURRENT_VERSION, } from './durable-memory.js';
|
|
6
6
|
async function makeTmpDir() {
|
|
7
7
|
return fs.mkdtemp(path.join(os.tmpdir(), 'durable-memory-test-'));
|
|
8
8
|
}
|
|
@@ -378,3 +378,139 @@ describe('recordHits', () => {
|
|
|
378
378
|
expect(store.items[1].lastHitAt).toBe(beforeLastHit);
|
|
379
379
|
});
|
|
380
380
|
});
|
|
381
|
+
describe('tokenize', () => {
|
|
382
|
+
it('lowercases and splits on non-alphanumeric boundaries', () => {
|
|
383
|
+
const tokens = tokenize('Hello World! TypeScript is great.');
|
|
384
|
+
expect(tokens.has('hello')).toBe(true);
|
|
385
|
+
expect(tokens.has('world')).toBe(true);
|
|
386
|
+
expect(tokens.has('typescript')).toBe(true);
|
|
387
|
+
expect(tokens.has('great')).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
it('filters out stop words', () => {
|
|
390
|
+
const tokens = tokenize('the quick brown fox is not a dog');
|
|
391
|
+
expect(tokens.has('the')).toBe(false);
|
|
392
|
+
expect(tokens.has('not')).toBe(false);
|
|
393
|
+
expect(tokens.has('quick')).toBe(true);
|
|
394
|
+
expect(tokens.has('brown')).toBe(true);
|
|
395
|
+
expect(tokens.has('fox')).toBe(true);
|
|
396
|
+
expect(tokens.has('dog')).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
it('filters out tokens shorter than 3 characters', () => {
|
|
399
|
+
const tokens = tokenize('I am ok go run typescript');
|
|
400
|
+
expect(tokens.has('ok')).toBe(false);
|
|
401
|
+
expect(tokens.has('go')).toBe(false);
|
|
402
|
+
expect(tokens.has('am')).toBe(false);
|
|
403
|
+
expect(tokens.has('run')).toBe(true);
|
|
404
|
+
expect(tokens.has('typescript')).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
it('returns empty set for empty input', () => {
|
|
407
|
+
expect(tokenize('').size).toBe(0);
|
|
408
|
+
});
|
|
409
|
+
it('deduplicates tokens', () => {
|
|
410
|
+
const tokens = tokenize('rust rust rust');
|
|
411
|
+
expect(tokens.size).toBe(1);
|
|
412
|
+
expect(tokens.has('rust')).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
describe('keywordRelevance', () => {
|
|
416
|
+
it('returns 1.0 for perfect overlap', () => {
|
|
417
|
+
const queryTokens = tokenize('typescript projects');
|
|
418
|
+
const score = keywordRelevance(queryTokens, 'User loves TypeScript projects', []);
|
|
419
|
+
expect(score).toBe(1.0);
|
|
420
|
+
});
|
|
421
|
+
it('returns 0 for no overlap', () => {
|
|
422
|
+
const queryTokens = tokenize('python machine learning');
|
|
423
|
+
const score = keywordRelevance(queryTokens, 'User prefers Rust for systems', []);
|
|
424
|
+
expect(score).toBe(0);
|
|
425
|
+
});
|
|
426
|
+
it('returns partial score for partial overlap', () => {
|
|
427
|
+
const queryTokens = tokenize('typescript react projects');
|
|
428
|
+
const score = keywordRelevance(queryTokens, 'User likes TypeScript and Vue', []);
|
|
429
|
+
expect(score).toBeGreaterThan(0);
|
|
430
|
+
expect(score).toBeLessThan(1);
|
|
431
|
+
// 1 out of 3 non-stop tokens match: typescript
|
|
432
|
+
expect(score).toBeCloseTo(1 / 3);
|
|
433
|
+
});
|
|
434
|
+
it('returns 0 when query has no meaningful tokens', () => {
|
|
435
|
+
const queryTokens = tokenize('is a the');
|
|
436
|
+
const score = keywordRelevance(queryTokens, 'some text here', []);
|
|
437
|
+
expect(score).toBe(0);
|
|
438
|
+
});
|
|
439
|
+
it('includes tags in matching', () => {
|
|
440
|
+
const queryTokens = tokenize('kubernetes deployment');
|
|
441
|
+
const score = keywordRelevance(queryTokens, 'Uses containers for services', ['kubernetes', 'deployment']);
|
|
442
|
+
expect(score).toBe(1.0);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
describe('selectItemsForInjection — query-aware boosting', () => {
|
|
446
|
+
it('boosts items with keyword overlap above non-matching items', () => {
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
const store = emptyStore();
|
|
449
|
+
store.items.push(makeItem({
|
|
450
|
+
id: 'irrelevant',
|
|
451
|
+
text: 'User prefers dark mode in all editors',
|
|
452
|
+
status: 'active',
|
|
453
|
+
updatedAt: now - 1000,
|
|
454
|
+
hitCount: 10,
|
|
455
|
+
lastHitAt: now - 1000,
|
|
456
|
+
}), makeItem({
|
|
457
|
+
id: 'relevant',
|
|
458
|
+
text: 'User uses TypeScript for all projects',
|
|
459
|
+
status: 'active',
|
|
460
|
+
updatedAt: now - 30 * 24 * 60 * 60 * 1000,
|
|
461
|
+
hitCount: 1,
|
|
462
|
+
lastHitAt: now - 30 * 24 * 60 * 60 * 1000,
|
|
463
|
+
}));
|
|
464
|
+
// Without query, 'irrelevant' (higher blended score) would rank first
|
|
465
|
+
const withoutQuery = selectItemsForInjection(store, 10000);
|
|
466
|
+
expect(withoutQuery[0].id).toBe('irrelevant');
|
|
467
|
+
// With query about TypeScript, 'relevant' should be boosted above 'irrelevant'
|
|
468
|
+
const withQuery = selectItemsForInjection(store, 10000, 'Tell me about TypeScript');
|
|
469
|
+
expect(withQuery[0].id).toBe('relevant');
|
|
470
|
+
expect(withQuery[1].id).toBe('irrelevant');
|
|
471
|
+
});
|
|
472
|
+
it('falls back to blended score when query has no meaningful tokens', () => {
|
|
473
|
+
const now = Date.now();
|
|
474
|
+
const store = emptyStore();
|
|
475
|
+
store.items.push(makeItem({ id: 'a', text: 'first item', status: 'active', updatedAt: now - 1000, hitCount: 5, lastHitAt: now - 500 }), makeItem({ id: 'b', text: 'second item', status: 'active', updatedAt: now - 60 * 24 * 60 * 60 * 1000, hitCount: 0, lastHitAt: 0 }));
|
|
476
|
+
const items = selectItemsForInjection(store, 10000, 'is the a');
|
|
477
|
+
expect(items[0].id).toBe('a');
|
|
478
|
+
});
|
|
479
|
+
it('preserves existing behavior when query is undefined', () => {
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
const store = emptyStore();
|
|
482
|
+
store.items.push(makeItem({ id: 'old-hot', text: 'old but popular', status: 'active', updatedAt: now - 30 * 24 * 60 * 60 * 1000, hitCount: 20, lastHitAt: now - 1000 }), makeItem({ id: 'new-cold', text: 'new but ignored', status: 'active', updatedAt: now - 1000, hitCount: 0, lastHitAt: 0 }));
|
|
483
|
+
const items = selectItemsForInjection(store, 10000);
|
|
484
|
+
expect(items[0].id).toBe('old-hot');
|
|
485
|
+
});
|
|
486
|
+
it('boosts items matching via tags', () => {
|
|
487
|
+
const now = Date.now();
|
|
488
|
+
const store = emptyStore();
|
|
489
|
+
store.items.push(makeItem({
|
|
490
|
+
id: 'no-tag-match',
|
|
491
|
+
text: 'User enjoys hiking on weekends',
|
|
492
|
+
status: 'active',
|
|
493
|
+
updatedAt: now - 1000,
|
|
494
|
+
hitCount: 5,
|
|
495
|
+
lastHitAt: now - 1000,
|
|
496
|
+
}), makeItem({
|
|
497
|
+
id: 'tag-match',
|
|
498
|
+
text: 'Deploys services regularly',
|
|
499
|
+
tags: ['kubernetes', 'docker'],
|
|
500
|
+
status: 'active',
|
|
501
|
+
updatedAt: now - 60 * 24 * 60 * 60 * 1000,
|
|
502
|
+
hitCount: 0,
|
|
503
|
+
lastHitAt: 0,
|
|
504
|
+
}));
|
|
505
|
+
// Query has 2 tokens matching tags (kubernetes, docker) out of 3 total
|
|
506
|
+
const items = selectItemsForInjection(store, 10000, 'kubernetes docker containers');
|
|
507
|
+
expect(items[0].id).toBe('tag-match');
|
|
508
|
+
});
|
|
509
|
+
it('still respects char budget with query', () => {
|
|
510
|
+
const store = emptyStore();
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
store.items.push(makeItem({ id: 'a', text: 'TypeScript project config', status: 'active', updatedAt: now - 1000 }), makeItem({ id: 'b', text: 'TypeScript compiler options reference', status: 'active', updatedAt: now - 2000 }));
|
|
513
|
+
const items = selectItemsForInjection(store, 80, 'TypeScript');
|
|
514
|
+
expect(items).toHaveLength(1);
|
|
515
|
+
});
|
|
516
|
+
});
|