@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.
- package/.claude/settings.template.json +94 -0
- package/.env.example +41 -0
- package/.env.relay.example +46 -0
- package/.env.worker.example +40 -0
- package/README.md +313 -0
- package/hooks/check-discord-messages.ts +204 -0
- package/hooks/cleanup-attachment.ts +47 -0
- package/hooks/safe-bash.ts +157 -0
- package/hooks/steer-send.ts +108 -0
- package/hooks/track-activity.ts +220 -0
- package/memory/README.md +60 -0
- package/memory/core/MemoryCoordinator.ts +703 -0
- package/memory/core/MemoryStore.ts +72 -0
- package/memory/core/session-key.ts +14 -0
- package/memory/core/types.ts +59 -0
- package/memory/index.ts +19 -0
- package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
- package/memory/providers/sqlite/index.ts +1 -0
- package/package.json +45 -0
- package/prompts/autoreply-system.md +32 -0
- package/prompts/channel-system.md +22 -0
- package/prompts/orchestrator-system.md +56 -0
- package/scripts/channel-agent.sh +159 -0
- package/scripts/generate-settings.sh +17 -0
- package/scripts/load-env.sh +79 -0
- package/scripts/migrate-memory-to-channel-keys.ts +148 -0
- package/scripts/orchestrator.sh +325 -0
- package/scripts/parse-claude-stream.ts +349 -0
- package/scripts/start-orchestrator.sh +82 -0
- package/scripts/start-relay.sh +17 -0
- package/scripts/start.sh +175 -0
- package/server/attachment.ts +182 -0
- package/server/busy-notify.ts +69 -0
- package/server/config.ts +121 -0
- package/server/db.ts +249 -0
- package/server/index.ts +311 -0
- package/server/memory.ts +88 -0
- package/server/messages.ts +111 -0
- package/server/trace-thread.ts +340 -0
- package/server/typing.ts +101 -0
- package/tools/memory-inspect.ts +94 -0
- package/tools/memory-smoke.ts +173 -0
- package/tools/send-discord +2 -0
- package/tools/send-discord.ts +82 -0
- package/tools/wait-for-discord-messages +2 -0
- 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
|
+
}
|