@hoverlover/cc-discord 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.
Files changed (46) hide show
  1. package/.claude/settings.template.json +94 -0
  2. package/.env.example +41 -0
  3. package/.env.relay.example +46 -0
  4. package/.env.worker.example +40 -0
  5. package/README.md +313 -0
  6. package/hooks/check-discord-messages.ts +204 -0
  7. package/hooks/cleanup-attachment.ts +47 -0
  8. package/hooks/safe-bash.ts +157 -0
  9. package/hooks/steer-send.ts +108 -0
  10. package/hooks/track-activity.ts +220 -0
  11. package/memory/README.md +60 -0
  12. package/memory/core/MemoryCoordinator.ts +703 -0
  13. package/memory/core/MemoryStore.ts +72 -0
  14. package/memory/core/session-key.ts +14 -0
  15. package/memory/core/types.ts +59 -0
  16. package/memory/index.ts +19 -0
  17. package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
  18. package/memory/providers/sqlite/index.ts +1 -0
  19. package/package.json +45 -0
  20. package/prompts/autoreply-system.md +32 -0
  21. package/prompts/channel-system.md +22 -0
  22. package/prompts/orchestrator-system.md +56 -0
  23. package/scripts/channel-agent.sh +159 -0
  24. package/scripts/generate-settings.sh +17 -0
  25. package/scripts/load-env.sh +79 -0
  26. package/scripts/migrate-memory-to-channel-keys.ts +148 -0
  27. package/scripts/orchestrator.sh +325 -0
  28. package/scripts/parse-claude-stream.ts +349 -0
  29. package/scripts/start-orchestrator.sh +82 -0
  30. package/scripts/start-relay.sh +17 -0
  31. package/scripts/start.sh +175 -0
  32. package/server/attachment.ts +182 -0
  33. package/server/busy-notify.ts +69 -0
  34. package/server/config.ts +121 -0
  35. package/server/db.ts +249 -0
  36. package/server/index.ts +311 -0
  37. package/server/memory.ts +88 -0
  38. package/server/messages.ts +111 -0
  39. package/server/trace-thread.ts +340 -0
  40. package/server/typing.ts +101 -0
  41. package/tools/memory-inspect.ts +94 -0
  42. package/tools/memory-smoke.ts +173 -0
  43. package/tools/send-discord +2 -0
  44. package/tools/send-discord.ts +82 -0
  45. package/tools/wait-for-discord-messages +2 -0
  46. package/tools/wait-for-discord-messages.ts +369 -0
@@ -0,0 +1,703 @@
1
+ import { clamp } from "./types.ts";
2
+
3
+ interface MemoryCoordinatorDefaults {
4
+ activeWindowSize: number;
5
+ maxCards: number;
6
+ maxRecallTurns: number;
7
+ maxTurnScan: number;
8
+ }
9
+
10
+ interface MemoryCoordinatorOptions {
11
+ store: any;
12
+ logger?: Pick<Console, "log" | "warn" | "error">;
13
+ defaults?: Partial<MemoryCoordinatorDefaults>;
14
+ }
15
+
16
+ export class MemoryCoordinator {
17
+ store: any;
18
+ logger: Pick<Console, "log" | "warn" | "error">;
19
+ defaults: MemoryCoordinatorDefaults;
20
+
21
+ constructor(options: MemoryCoordinatorOptions) {
22
+ if (!options?.store) throw new Error("MemoryCoordinator requires a store");
23
+
24
+ this.store = options.store;
25
+ this.logger = options.logger || console;
26
+ this.defaults = {
27
+ activeWindowSize: 12,
28
+ maxCards: 6,
29
+ maxRecallTurns: 8,
30
+ maxTurnScan: 400,
31
+ ...(options.defaults || {}),
32
+ };
33
+ }
34
+
35
+ async init() {
36
+ await this.store.init();
37
+ }
38
+
39
+ async appendTurn({
40
+ sessionKey,
41
+ agentId = null,
42
+ role,
43
+ content,
44
+ metadata = null,
45
+ turnIndex = null,
46
+ }: {
47
+ sessionKey: string;
48
+ agentId?: string | null;
49
+ role: string;
50
+ content: string;
51
+ metadata?: any;
52
+ turnIndex?: number | null;
53
+ }) {
54
+ return this.store.writeBatch({
55
+ batchId: makeBatchId("turn"),
56
+ sessionKey,
57
+ agentId,
58
+ turns: [{ role, content, metadata, turnIndex }],
59
+ });
60
+ }
61
+
62
+ async appendTurns({
63
+ sessionKey,
64
+ agentId = null,
65
+ turns = [],
66
+ }: {
67
+ sessionKey: string;
68
+ agentId?: string | null;
69
+ turns?: any[];
70
+ }) {
71
+ return this.store.writeBatch({
72
+ batchId: makeBatchId("turns"),
73
+ sessionKey,
74
+ agentId,
75
+ turns,
76
+ });
77
+ }
78
+
79
+ async ensureRuntimeContext({
80
+ sessionKey,
81
+ runtimeContextId = null,
82
+ runtimeEpoch = null,
83
+ }: {
84
+ sessionKey: string;
85
+ runtimeContextId?: string | null;
86
+ runtimeEpoch?: number | null;
87
+ }) {
88
+ if (!sessionKey) throw new Error("ensureRuntimeContext() requires sessionKey");
89
+
90
+ const existing = await this.store.readRuntimeState(sessionKey);
91
+ if (existing) return existing;
92
+
93
+ return this.store.upsertRuntimeState({
94
+ sessionKey,
95
+ runtimeContextId: runtimeContextId || makeRuntimeContextId("init"),
96
+ runtimeEpoch: Number.isInteger(runtimeEpoch) ? runtimeEpoch : 1,
97
+ });
98
+ }
99
+
100
+ async beginNewRuntimeContext({
101
+ sessionKey,
102
+ runtimeContextId = null,
103
+ }: {
104
+ sessionKey: string;
105
+ runtimeContextId?: string | null;
106
+ }) {
107
+ if (!sessionKey) throw new Error("beginNewRuntimeContext() requires sessionKey");
108
+
109
+ return this.store.bumpRuntimeContext({
110
+ sessionKey,
111
+ runtimeContextId: runtimeContextId || makeRuntimeContextId("sessionstart"),
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Build context-aware memory payload for this turn.
117
+ *
118
+ * Strategy:
119
+ * 1) Determine active window (what model likely already has)
120
+ * 2) Filter out overlapping memories
121
+ * 3) Rank out-of-window cards/turns by relevance + novelty
122
+ */
123
+ async assembleContext(input: any) {
124
+ const {
125
+ sessionKey,
126
+ queryText = "",
127
+ runtimeContextId = null,
128
+ runtimeEpoch = null,
129
+ activeWindowTurnIds = [],
130
+ includeSnapshot = true,
131
+ avoidCurrentRuntime = true,
132
+ activeWindowSize = this.defaults.activeWindowSize,
133
+ maxCards = this.defaults.maxCards,
134
+ maxRecallTurns = this.defaults.maxRecallTurns,
135
+ maxTurnScan = this.defaults.maxTurnScan,
136
+ } = input || {};
137
+
138
+ if (!sessionKey) throw new Error("assembleContext() requires sessionKey");
139
+
140
+ const safeActiveWindowSize = clamp(Number(activeWindowSize) || this.defaults.activeWindowSize, 1, 100);
141
+ const safeMaxCards = clamp(Number(maxCards) || this.defaults.maxCards, 0, 30);
142
+ const safeMaxRecallTurns = clamp(Number(maxRecallTurns) || this.defaults.maxRecallTurns, 0, 30);
143
+ const safeMaxTurnScan = clamp(Number(maxTurnScan) || this.defaults.maxTurnScan, 50, 2000);
144
+
145
+ const resolvedRuntimeState = await resolveRuntimeState({
146
+ store: this.store,
147
+ sessionKey,
148
+ runtimeContextId,
149
+ runtimeEpoch,
150
+ });
151
+
152
+ const snapshot = includeSnapshot ? await this.store.readSessionSnapshot(sessionKey) : null;
153
+
154
+ const compactionState = await this.store.readCompactionState(sessionKey);
155
+ const compactedCutoff = await resolveCompactedCutoff({
156
+ store: this.store,
157
+ sessionKey,
158
+ compactionState,
159
+ });
160
+
161
+ const candidateTurns = await this.store.listRecentTurns({
162
+ sessionKey,
163
+ limit: safeMaxTurnScan,
164
+ });
165
+
166
+ const runtimeActiveTurns = avoidCurrentRuntime
167
+ ? candidateTurns.filter((turn: any) => isTurnInActiveRuntimeWindow(turn, resolvedRuntimeState, compactedCutoff))
168
+ : [];
169
+
170
+ const activeIds = new Set([
171
+ ...activeWindowTurnIds.map(String),
172
+ ...runtimeActiveTurns.map((t: any) => String(t.id)),
173
+ ]);
174
+
175
+ const activeWindowTurns = runtimeActiveTurns.slice(-safeActiveWindowSize);
176
+ const activeText = activeWindowTurns.map((t: any) => `${t.role}: ${t.content}`).join("\n");
177
+ const activeTokens = tokenize(activeText);
178
+ const queryTokens = tokenize(queryText);
179
+
180
+ const allCards = await this.store.queryCards({
181
+ sessionKey,
182
+ includeExpired: false,
183
+ limit: 500,
184
+ });
185
+
186
+ const rankedCards = allCards
187
+ .filter((card: any) => !overlapsActiveWindow(card, activeIds))
188
+ .map((card: any) => {
189
+ const text = `${card.title || ""}\n${card.body || ""}`;
190
+ const tokens = tokenize(text);
191
+ const overlap = tokenOverlapScore(queryTokens, tokens);
192
+ const novelty = 1 - jaccard(tokens, activeTokens);
193
+ const pinnedBoost = card.pinned ? 0.8 : 0;
194
+ const confidenceBoost = clamp(Number(card.confidence ?? 0.5), 0, 1) * 0.4;
195
+ const score = overlap * 1.2 + novelty * 0.5 + pinnedBoost + confidenceBoost;
196
+ return { card, score, overlap, novelty };
197
+ })
198
+ .filter((x: any) => x.score > 0.35 || x.card.pinned)
199
+ .sort((a: any, b: any) => b.score - a.score)
200
+ .slice(0, safeMaxCards)
201
+ .map((x: any) => x.card);
202
+
203
+ // Build turn index map for adjacency lookups
204
+ const turnsByIndex = new Map();
205
+ for (const turn of candidateTurns) {
206
+ turnsByIndex.set(turn.turnIndex, turn);
207
+ }
208
+
209
+ // Identify rare query tokens (appear in few candidate turns)
210
+ const tokenFrequency = new Map();
211
+ const candidateTokenSets = new Map();
212
+ for (const turn of candidateTurns) {
213
+ const tokens = tokenize(`${turn.role}: ${turn.content}`);
214
+ candidateTokenSets.set(turn.id, tokens);
215
+ for (const tok of tokens) {
216
+ tokenFrequency.set(tok, (tokenFrequency.get(tok) || 0) + 1);
217
+ }
218
+ }
219
+ const rareQueryTokens = new Set<string>();
220
+ for (const tok of queryTokens) {
221
+ const freq = tokenFrequency.get(tok) || 0;
222
+ // Token appears in <20% of turns = rare/specific
223
+ if (freq > 0 && freq <= Math.max(2, candidateTurns.length * 0.2)) {
224
+ rareQueryTokens.add(tok);
225
+ }
226
+ }
227
+ // Also check fuzzy matches for rare tokens
228
+ const rareQueryTrigrams = rareQueryTokens.size > 0 ? trigramSet(rareQueryTokens) : new Set<string>();
229
+
230
+ const scoredTurns = candidateTurns
231
+ .filter((turn: any) => !activeIds.has(String(turn.id)))
232
+ .map((turn: any) => {
233
+ const tokens = candidateTokenSets.get(turn.id) || tokenize(`${turn.role}: ${turn.content}`);
234
+ const overlap = tokenOverlapScore(queryTokens, tokens);
235
+ const novelty = 1 - jaccard(tokens, activeTokens);
236
+ const recency = turn.turnIndex / Math.max(1, candidateTurns.length);
237
+ const negationPenalty = isNegationTurn(turn.content) ? 0.3 : 1.0;
238
+
239
+ // Rare keyword boost: if turn contains a rare query term (exact or fuzzy), boost it
240
+ let rareBoost = 0;
241
+ if (rareQueryTokens.size > 0) {
242
+ for (const tok of tokens) {
243
+ if (rareQueryTokens.has(tok)) {
244
+ rareBoost = 0.3;
245
+ break;
246
+ }
247
+ }
248
+ // Fuzzy check via trigrams if no exact match
249
+ if (rareBoost === 0 && rareQueryTrigrams.size > 0) {
250
+ const turnTrigrams = trigramSet(tokens);
251
+ let triIntersect = 0;
252
+ for (const tri of rareQueryTrigrams) {
253
+ if (turnTrigrams.has(tri)) triIntersect++;
254
+ }
255
+ const triUnion = rareQueryTrigrams.size + turnTrigrams.size - triIntersect;
256
+ const sim = triUnion > 0 ? triIntersect / triUnion : 0;
257
+ if (sim > 0.15) rareBoost = 0.2;
258
+ }
259
+ }
260
+
261
+ // Novelty only contributes when there's some keyword signal.
262
+ // Without overlap or rareBoost, novelty alone shouldn't qualify a turn.
263
+ const hasKeywordSignal = overlap > 0.05 || rareBoost > 0;
264
+ const effectiveNovelty = hasKeywordSignal ? novelty * 0.4 : 0;
265
+
266
+ const score = (overlap * 1.0 + effectiveNovelty + recency * 0.1 + rareBoost) * negationPenalty;
267
+ return { turn, score };
268
+ })
269
+ .filter((x: any) => x.score > 0.4)
270
+ .sort((a: any, b: any) => b.score - a.score);
271
+
272
+ // Deduplicate near-identical turns (e.g. user asking the same question repeatedly)
273
+ const deduped: any[] = [];
274
+ const seenContentHashes = new Set();
275
+ for (const scored of scoredTurns) {
276
+ const hash = contentHash(scored.turn.content);
277
+ if (seenContentHashes.has(hash)) continue;
278
+ seenContentHashes.add(hash);
279
+ deduped.push(scored);
280
+ }
281
+
282
+ // Select top turns, then expand with adjacent response turns (conversation threading)
283
+ const selectedIds = new Set();
284
+ const selectedTurns: any[] = [];
285
+
286
+ for (const { turn } of deduped) {
287
+ if (selectedTurns.length >= safeMaxRecallTurns) break;
288
+ if (selectedIds.has(turn.id)) continue;
289
+
290
+ selectedIds.add(turn.id);
291
+ selectedTurns.push(turn);
292
+
293
+ // If this is a user turn, pull the next assistant turn (the actual response)
294
+ if (turn.role === "user" && selectedTurns.length < safeMaxRecallTurns) {
295
+ const nextTurn = turnsByIndex.get(turn.turnIndex + 1);
296
+ if (
297
+ nextTurn &&
298
+ nextTurn.role === "assistant" &&
299
+ !activeIds.has(String(nextTurn.id)) &&
300
+ !selectedIds.has(nextTurn.id)
301
+ ) {
302
+ // Only include if not a negation turn
303
+ if (!isNegationTurn(nextTurn.content)) {
304
+ selectedIds.add(nextTurn.id);
305
+ selectedTurns.push(nextTurn);
306
+ }
307
+ }
308
+ }
309
+
310
+ // If this is an assistant turn, pull the preceding user turn for context
311
+ if (turn.role === "assistant" && selectedTurns.length < safeMaxRecallTurns) {
312
+ const prevTurn = turnsByIndex.get(turn.turnIndex - 1);
313
+ if (
314
+ prevTurn &&
315
+ prevTurn.role === "user" &&
316
+ !activeIds.has(String(prevTurn.id)) &&
317
+ !selectedIds.has(prevTurn.id)
318
+ ) {
319
+ selectedIds.add(prevTurn.id);
320
+ selectedTurns.push(prevTurn);
321
+ }
322
+ }
323
+ }
324
+
325
+ const recalledTurns = selectedTurns.sort((a: any, b: any) => a.turnIndex - b.turnIndex);
326
+
327
+ return {
328
+ sessionKey,
329
+ queryText,
330
+ snapshot,
331
+ activeWindowTurns,
332
+ recalledTurns,
333
+ cards: rankedCards,
334
+ debug: {
335
+ runtimeContextId: resolvedRuntimeState?.runtimeContextId || null,
336
+ runtimeEpoch: resolvedRuntimeState?.runtimeEpoch || null,
337
+ compactedCutoffTurnId: compactionState?.lastCompactedTurnId || null,
338
+ compactedCutoffTurnIndex: compactedCutoff?.turnIndex ?? null,
339
+ totalCardsScanned: allCards.length,
340
+ totalTurnsScanned: candidateTurns.length,
341
+ activeTurnCount: runtimeActiveTurns.length,
342
+ },
343
+ };
344
+ }
345
+
346
+ formatContextPacket(payload: any) {
347
+ const parts: string[] = [];
348
+
349
+ if (payload.snapshot?.summaryText) {
350
+ parts.push(`Session summary:\n${truncate(payload.snapshot.summaryText, 800)}`);
351
+
352
+ if (Array.isArray(payload.snapshot.openTasks) && payload.snapshot.openTasks.length > 0) {
353
+ parts.push(`Open tasks:\n- ${payload.snapshot.openTasks.join("\n- ")}`);
354
+ }
355
+ }
356
+
357
+ if (Array.isArray(payload.cards) && payload.cards.length > 0) {
358
+ const lines = payload.cards.map((card: any) => {
359
+ const label = card.cardType ? `[${card.cardType}]` : "";
360
+ const title = card.title ? `${truncate(card.title, 120)}: ` : "";
361
+ return `- ${label} ${title}${truncate(card.body, 320)}`.trim();
362
+ });
363
+ parts.push(`Relevant long-term memory (outside current window):\n${lines.join("\n")}`);
364
+ }
365
+
366
+ if (Array.isArray(payload.recalledTurns) && payload.recalledTurns.length > 0) {
367
+ const lines = payload.recalledTurns.map((turn: any) => `- (${turn.role}) ${truncate(turn.content, 280)}`);
368
+ parts.push(`Relevant prior turns (outside current window):\n${lines.join("\n")}`);
369
+ }
370
+
371
+ if (parts.length === 0) return "";
372
+ return `MEMORY CONTEXT:\n${parts.join("\n\n")}`;
373
+ }
374
+ }
375
+
376
+ function makeBatchId(prefix: string) {
377
+ const ts = Date.now().toString(36);
378
+ const rand = Math.random().toString(36).slice(2, 10);
379
+ return `${prefix}_${ts}_${rand}`;
380
+ }
381
+
382
+ function makeRuntimeContextId(prefix: string = "runtime") {
383
+ const ts = Date.now().toString(36);
384
+ const rand = Math.random().toString(36).slice(2, 10);
385
+ return `${prefix}_${ts}_${rand}`;
386
+ }
387
+
388
+ function overlapsActiveWindow(card: any, activeIds: Set<string>) {
389
+ if (card.sourceTurnFrom && activeIds.has(String(card.sourceTurnFrom))) return true;
390
+ if (card.sourceTurnTo && activeIds.has(String(card.sourceTurnTo))) return true;
391
+ return false;
392
+ }
393
+
394
+ async function resolveRuntimeState({
395
+ store,
396
+ sessionKey,
397
+ runtimeContextId,
398
+ runtimeEpoch,
399
+ }: {
400
+ store: any;
401
+ sessionKey: string;
402
+ runtimeContextId: string | null;
403
+ runtimeEpoch: number | null;
404
+ }) {
405
+ if (runtimeContextId || runtimeEpoch) {
406
+ return {
407
+ runtimeContextId: runtimeContextId || null,
408
+ runtimeEpoch: Number.isInteger(runtimeEpoch) ? runtimeEpoch : null,
409
+ };
410
+ }
411
+
412
+ if (!store?.readRuntimeState) return null;
413
+
414
+ try {
415
+ return await store.readRuntimeState(sessionKey);
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+
421
+ async function resolveCompactedCutoff({
422
+ store,
423
+ sessionKey,
424
+ compactionState,
425
+ }: {
426
+ store: any;
427
+ sessionKey: string;
428
+ compactionState: any;
429
+ }) {
430
+ const turnId = compactionState?.lastCompactedTurnId;
431
+ if (!turnId) return null;
432
+
433
+ if (!store?.getTurnById) return null;
434
+
435
+ try {
436
+ const turn = await store.getTurnById({ sessionKey, turnId });
437
+ return turn || null;
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
442
+
443
+ function isTurnInActiveRuntimeWindow(turn: any, runtimeState: any, compactedCutoff: any) {
444
+ if (!runtimeState?.runtimeContextId) return false;
445
+
446
+ const turnRuntimeContextId = turn?.metadata?.runtimeContextId;
447
+ if (!turnRuntimeContextId || turnRuntimeContextId !== runtimeState.runtimeContextId) {
448
+ return false;
449
+ }
450
+
451
+ if (compactedCutoff?.turnIndex === undefined || compactedCutoff?.turnIndex === null) {
452
+ // No compaction cutoff available: treat all current-runtime turns as active.
453
+ return true;
454
+ }
455
+
456
+ return turn.turnIndex > compactedCutoff.turnIndex;
457
+ }
458
+
459
+ const STOP_WORDS = new Set([
460
+ "the",
461
+ "and",
462
+ "for",
463
+ "are",
464
+ "but",
465
+ "not",
466
+ "you",
467
+ "all",
468
+ "any",
469
+ "can",
470
+ "has",
471
+ "her",
472
+ "his",
473
+ "how",
474
+ "its",
475
+ "may",
476
+ "our",
477
+ "out",
478
+ "was",
479
+ "who",
480
+ "did",
481
+ "get",
482
+ "got",
483
+ "had",
484
+ "him",
485
+ "let",
486
+ "say",
487
+ "she",
488
+ "too",
489
+ "use",
490
+ "yes",
491
+ "yet",
492
+ "been",
493
+ "each",
494
+ "have",
495
+ "from",
496
+ "into",
497
+ "just",
498
+ "like",
499
+ "make",
500
+ "many",
501
+ "more",
502
+ "most",
503
+ "much",
504
+ "must",
505
+ "name",
506
+ "only",
507
+ "over",
508
+ "such",
509
+ "take",
510
+ "than",
511
+ "that",
512
+ "them",
513
+ "then",
514
+ "they",
515
+ "this",
516
+ "very",
517
+ "what",
518
+ "when",
519
+ "will",
520
+ "with",
521
+ "your",
522
+ "also",
523
+ "back",
524
+ "been",
525
+ "come",
526
+ "could",
527
+ "does",
528
+ "don",
529
+ "dont",
530
+ "even",
531
+ "give",
532
+ "goes",
533
+ "gone",
534
+ "good",
535
+ "here",
536
+ "high",
537
+ "keep",
538
+ "know",
539
+ "last",
540
+ "long",
541
+ "look",
542
+ "made",
543
+ "some",
544
+ "sure",
545
+ "tell",
546
+ "told",
547
+ "want",
548
+ "well",
549
+ "were",
550
+ "which",
551
+ "would",
552
+ "about",
553
+ "after",
554
+ "again",
555
+ "being",
556
+ "below",
557
+ "could",
558
+ "every",
559
+ "first",
560
+ "found",
561
+ "going",
562
+ "great",
563
+ "might",
564
+ "never",
565
+ "other",
566
+ "right",
567
+ "shall",
568
+ "since",
569
+ "still",
570
+ "their",
571
+ "there",
572
+ "these",
573
+ "thing",
574
+ "think",
575
+ "those",
576
+ "until",
577
+ "where",
578
+ "while",
579
+ "would",
580
+ "should",
581
+ "really",
582
+ "before",
583
+ "because",
584
+ "between",
585
+ "time",
586
+ "one",
587
+ "more",
588
+ "see",
589
+ "now",
590
+ "way",
591
+ "new",
592
+ "said",
593
+ "asked",
594
+ "user",
595
+ "assistant",
596
+ "hoverlover617",
597
+ ]);
598
+
599
+ function tokenize(text: string): Set<string> {
600
+ return new Set(
601
+ String(text || "")
602
+ .toLowerCase()
603
+ .replace(/[^a-z0-9\s]/g, " ")
604
+ .split(/\s+/)
605
+ .filter((t) => t.length > 2 && !STOP_WORDS.has(t)),
606
+ );
607
+ }
608
+
609
+ /**
610
+ * Generate character trigrams for a token set.
611
+ * Used for fuzzy matching (typos, near-misses).
612
+ */
613
+ function trigramSet(tokens: Set<string>): Set<string> {
614
+ const trigrams = new Set<string>();
615
+ for (const tok of tokens) {
616
+ const padded = ` ${tok} `;
617
+ for (let i = 0; i < padded.length - 2; i++) {
618
+ trigrams.add(padded.slice(i, i + 3));
619
+ }
620
+ }
621
+ return trigrams;
622
+ }
623
+
624
+ function trigramSimilarity(aTokens: Set<string>, bTokens: Set<string>): number {
625
+ if (!aTokens || !bTokens || aTokens.size === 0 || bTokens.size === 0) return 0;
626
+ const aTri = trigramSet(aTokens);
627
+ const bTri = trigramSet(bTokens);
628
+ if (aTri.size === 0 || bTri.size === 0) return 0;
629
+
630
+ let intersection = 0;
631
+ for (const tri of aTri) {
632
+ if (bTri.has(tri)) intersection++;
633
+ }
634
+ const union = aTri.size + bTri.size - intersection;
635
+ return union > 0 ? intersection / union : 0;
636
+ }
637
+
638
+ function tokenOverlapScore(aTokens: Set<string>, bTokens: Set<string>): number {
639
+ if (!aTokens || !bTokens || aTokens.size === 0 || bTokens.size === 0) return 0;
640
+
641
+ // Exact match component
642
+ let exactOverlap = 0;
643
+ for (const tok of aTokens) {
644
+ if (bTokens.has(tok)) exactOverlap++;
645
+ }
646
+ const exactScore = exactOverlap / Math.max(1, Math.min(aTokens.size, 10));
647
+
648
+ // Fuzzy trigram component (catches typos like matterpost/mattermost)
649
+ const fuzzyScore = trigramSimilarity(aTokens, bTokens);
650
+
651
+ // Blend: exact match weighted higher, fuzzy as fallback
652
+ return Math.max(exactScore, exactScore * 0.7 + fuzzyScore * 0.5);
653
+ }
654
+
655
+ function jaccard(aTokens: Set<string>, bTokens: Set<string>): number {
656
+ if ((!aTokens || aTokens.size === 0) && (!bTokens || bTokens.size === 0)) return 1;
657
+ if (!aTokens || !bTokens || aTokens.size === 0 || bTokens.size === 0) return 0;
658
+
659
+ let intersection = 0;
660
+ for (const tok of aTokens) {
661
+ if (bTokens.has(tok)) intersection++;
662
+ }
663
+
664
+ const union = aTokens.size + bTokens.size - intersection;
665
+ return union > 0 ? intersection / union : 0;
666
+ }
667
+
668
+ /**
669
+ * Detect "I don't know/remember" meta-turns that contain query terms
670
+ * but carry no actual information. These get penalized in scoring.
671
+ */
672
+ const NEGATION_PATTERNS = [
673
+ /\bdon'?t have (the |any )?(specific )?(details|context|record|information|memory)\b/i,
674
+ /\bdon'?t (remember|recall|know)\b/i,
675
+ /\bunfortunately.{0,40}(don'?t|no |not ).{0,30}(context|detail|record|memory|information)\b/i,
676
+ /\bmy memory.{0,30}(limited|doesn'?t|does not)\b/i,
677
+ /\bcould you remind me\b/i,
678
+ /\bcan'?t recall\b/i,
679
+ /\bno record of\b/i,
680
+ /\bbefore my.{0,20}(memory|persistent|context)\b/i,
681
+ ];
682
+
683
+ function isNegationTurn(content: string): boolean {
684
+ const text = String(content || "");
685
+ return NEGATION_PATTERNS.some((p) => p.test(text));
686
+ }
687
+
688
+ /**
689
+ * Simple content hash for deduplication.
690
+ * Normalizes whitespace/punctuation and takes first ~100 significant chars.
691
+ */
692
+ function contentHash(content: string): string {
693
+ return String(content || "")
694
+ .toLowerCase()
695
+ .replace(/[^a-z0-9]/g, "")
696
+ .slice(0, 100);
697
+ }
698
+
699
+ function truncate(text: string, maxLen: number): string {
700
+ const str = String(text || "");
701
+ if (str.length <= maxLen) return str;
702
+ return `${str.slice(0, maxLen - 1)}…`;
703
+ }