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 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
- > GCC 14 promotes `-Wincompatible-pointer-types` to a hard error by default. The upstream opus C source triggers this, causing `npm install` to fail with an error like:
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 text = (m.content || '(no text)').slice(0, 200);
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 text = action.full ? (message.content || '(no text)') : (message.content || '(no text)').slice(0, 500);
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 text = (m.content || '(no text)').slice(0, 200);
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 500 chars).
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 500 chars by default', async () => {
423
- const longContent = 'x'.repeat(600);
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(600));
431
- expect(result.summary).toContain('x'.repeat(500));
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
- export function selectItemsForInjection(store, maxChars) {
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 active = store.items
163
- .filter((item) => item.status === 'active')
164
- .sort((a, b) => blendedInjectionScore(b, now) - blendedInjectionScore(a, now));
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
+ });