openclaw-topic-shift-reset 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -48
- package/docs/configuration.md +82 -61
- package/openclaw.plugin.json +126 -57
- package/package.json +7 -1
- package/src/index.ts +1038 -185
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import type { OpenClawPluginApi
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
5
|
import {
|
|
6
6
|
readJsonFileWithFallback,
|
|
7
7
|
withFileLock,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type PresetName = "conservative" | "balanced" | "aggressive";
|
|
12
12
|
type EmbeddingProvider = "auto" | "none" | "openai" | "ollama";
|
|
13
13
|
type HandoffMode = "none" | "summary" | "verbatim_last_n";
|
|
14
|
-
type
|
|
14
|
+
type SoftSuspectAction = "none" | "ask";
|
|
15
15
|
|
|
16
16
|
type EmbeddingConfig = {
|
|
17
17
|
provider?: EmbeddingProvider;
|
|
@@ -21,6 +21,24 @@ type EmbeddingConfig = {
|
|
|
21
21
|
timeoutMs?: number;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
type HandoffConfig = {
|
|
25
|
+
mode?: HandoffMode;
|
|
26
|
+
lastN?: number;
|
|
27
|
+
maxChars?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type SoftSuspectConfig = {
|
|
31
|
+
action?: SoftSuspectAction;
|
|
32
|
+
prompt?: string;
|
|
33
|
+
ttlSeconds?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type StripRulesConfig = {
|
|
37
|
+
dropLinePrefixPatterns?: string[];
|
|
38
|
+
dropExactLines?: string[];
|
|
39
|
+
dropFencedBlockAfterHeaderPatterns?: string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
24
42
|
type TopicShiftResetAdvancedConfig = {
|
|
25
43
|
historyWindow?: number;
|
|
26
44
|
minHistoryMessages?: number;
|
|
@@ -29,7 +47,12 @@ type TopicShiftResetAdvancedConfig = {
|
|
|
29
47
|
minSignalChars?: number;
|
|
30
48
|
minSignalTokenCount?: number;
|
|
31
49
|
minSignalEntropy?: number;
|
|
50
|
+
minUniqueTokenRatio?: number;
|
|
51
|
+
shortMessageTokenLimit?: number;
|
|
52
|
+
embeddingTriggerMargin?: number;
|
|
32
53
|
stripEnvelope?: boolean;
|
|
54
|
+
stripRules?: StripRulesConfig;
|
|
55
|
+
handoffTailReadMaxBytes?: number;
|
|
33
56
|
softConsecutiveSignals?: number;
|
|
34
57
|
cooldownMinutes?: number;
|
|
35
58
|
ignoredProviders?: string[];
|
|
@@ -39,18 +62,14 @@ type TopicShiftResetAdvancedConfig = {
|
|
|
39
62
|
hardSimilarityThreshold?: number;
|
|
40
63
|
softNoveltyThreshold?: number;
|
|
41
64
|
hardNoveltyThreshold?: number;
|
|
42
|
-
handoff?: HandoffPreference | HandoffMode;
|
|
43
|
-
handoffLastN?: number;
|
|
44
|
-
handoffMaxChars?: number;
|
|
45
|
-
embeddings?: EmbeddingProvider;
|
|
46
|
-
embedding?: EmbeddingConfig;
|
|
47
65
|
};
|
|
48
66
|
|
|
49
67
|
type TopicShiftResetConfig = {
|
|
50
68
|
enabled?: boolean;
|
|
51
69
|
preset?: PresetName;
|
|
52
|
-
|
|
53
|
-
handoff?:
|
|
70
|
+
embedding?: EmbeddingConfig;
|
|
71
|
+
handoff?: HandoffConfig;
|
|
72
|
+
softSuspect?: SoftSuspectConfig;
|
|
54
73
|
dryRun?: boolean;
|
|
55
74
|
debug?: boolean;
|
|
56
75
|
advanced?: TopicShiftResetAdvancedConfig;
|
|
@@ -65,7 +84,16 @@ type ResolvedConfig = {
|
|
|
65
84
|
minSignalChars: number;
|
|
66
85
|
minSignalTokenCount: number;
|
|
67
86
|
minSignalEntropy: number;
|
|
87
|
+
minUniqueTokenRatio: number;
|
|
88
|
+
shortMessageTokenLimit: number;
|
|
89
|
+
embeddingTriggerMargin: number;
|
|
68
90
|
stripEnvelope: boolean;
|
|
91
|
+
stripRules: {
|
|
92
|
+
dropLinePrefixPatterns: RegExp[];
|
|
93
|
+
dropExactLines: Set<string>;
|
|
94
|
+
dropFencedBlockAfterHeaderPatterns: RegExp[];
|
|
95
|
+
};
|
|
96
|
+
handoffTailReadMaxBytes: number;
|
|
69
97
|
softConsecutiveSignals: number;
|
|
70
98
|
cooldownMinutes: number;
|
|
71
99
|
ignoredProviders: Set<string>;
|
|
@@ -75,9 +103,16 @@ type ResolvedConfig = {
|
|
|
75
103
|
hardSimilarityThreshold: number;
|
|
76
104
|
softNoveltyThreshold: number;
|
|
77
105
|
hardNoveltyThreshold: number;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
106
|
+
handoff: {
|
|
107
|
+
mode: HandoffMode;
|
|
108
|
+
lastN: number;
|
|
109
|
+
maxChars: number;
|
|
110
|
+
};
|
|
111
|
+
softSuspect: {
|
|
112
|
+
action: SoftSuspectAction;
|
|
113
|
+
prompt: string;
|
|
114
|
+
ttlMs: number;
|
|
115
|
+
};
|
|
81
116
|
embedding: {
|
|
82
117
|
provider: EmbeddingProvider;
|
|
83
118
|
model?: string;
|
|
@@ -113,18 +148,55 @@ type SessionState = {
|
|
|
113
148
|
pendingSoftSignals: number;
|
|
114
149
|
pendingEntries: HistoryEntry[];
|
|
115
150
|
lastResetAt?: number;
|
|
151
|
+
topicCentroid?: number[];
|
|
152
|
+
topicCount: number;
|
|
153
|
+
topicDim?: number;
|
|
154
|
+
lastSeenAt: number;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
type PersistedHistoryEntry = {
|
|
158
|
+
tokens: string[];
|
|
159
|
+
at: number;
|
|
160
|
+
embedding?: number[];
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
type PersistedSessionState = {
|
|
164
|
+
history: PersistedHistoryEntry[];
|
|
165
|
+
pendingSoftSignals: number;
|
|
166
|
+
pendingEntries: PersistedHistoryEntry[];
|
|
167
|
+
lastResetAt?: number;
|
|
168
|
+
topicCentroid?: number[];
|
|
169
|
+
topicCount: number;
|
|
170
|
+
topicDim?: number;
|
|
116
171
|
lastSeenAt: number;
|
|
117
172
|
};
|
|
118
173
|
|
|
174
|
+
type PersistedRuntimeState = {
|
|
175
|
+
version: number;
|
|
176
|
+
savedAt: number;
|
|
177
|
+
sessionStateBySessionKey: Record<string, PersistedSessionState>;
|
|
178
|
+
recentRotationBySession: Record<string, number>;
|
|
179
|
+
};
|
|
180
|
+
|
|
119
181
|
type ClassifierMetrics = {
|
|
120
182
|
score: number;
|
|
121
183
|
novelty: number;
|
|
122
184
|
lexicalDistance: number;
|
|
185
|
+
uniqueTokenRatio: number;
|
|
186
|
+
entropy: number;
|
|
123
187
|
similarity?: number;
|
|
124
188
|
usedEmbedding: boolean;
|
|
125
189
|
pendingSoftSignals: number;
|
|
126
190
|
};
|
|
127
191
|
|
|
192
|
+
type LexicalFeatures = {
|
|
193
|
+
score: number;
|
|
194
|
+
novelty: number;
|
|
195
|
+
lexicalDistance: number;
|
|
196
|
+
uniqueTokenRatio: number;
|
|
197
|
+
entropy: number;
|
|
198
|
+
};
|
|
199
|
+
|
|
128
200
|
type ClassificationDecision =
|
|
129
201
|
| { kind: "warmup" | "stable" | "suspect"; metrics: ClassifierMetrics; reason: string }
|
|
130
202
|
| { kind: "rotate-hard" | "rotate-soft"; metrics: ClassifierMetrics; reason: string };
|
|
@@ -134,9 +206,9 @@ type EmbeddingBackend = {
|
|
|
134
206
|
embed: (text: string) => Promise<number[] | null>;
|
|
135
207
|
};
|
|
136
208
|
|
|
137
|
-
type
|
|
138
|
-
|
|
139
|
-
|
|
209
|
+
type FastMessageEventLike = {
|
|
210
|
+
from?: string;
|
|
211
|
+
metadata?: Record<string, unknown>;
|
|
140
212
|
};
|
|
141
213
|
|
|
142
214
|
type TranscriptMessage = {
|
|
@@ -207,15 +279,30 @@ const PRESETS = {
|
|
|
207
279
|
const DEFAULTS = {
|
|
208
280
|
enabled: true,
|
|
209
281
|
preset: "balanced" as PresetName,
|
|
210
|
-
|
|
282
|
+
handoffMode: "summary" as HandoffMode,
|
|
211
283
|
handoffLastN: 6,
|
|
212
284
|
handoffMaxChars: 220,
|
|
285
|
+
softSuspectAction: "ask" as SoftSuspectAction,
|
|
286
|
+
softSuspectPrompt:
|
|
287
|
+
"Potential topic shift detected. Ask one short clarification question to confirm the user's new goal before proceeding.",
|
|
288
|
+
softSuspectTtlSeconds: 120,
|
|
213
289
|
embeddingProvider: "auto" as EmbeddingProvider,
|
|
214
290
|
embeddingTimeoutMs: 7000,
|
|
215
291
|
minSignalChars: 20,
|
|
216
292
|
minSignalTokenCount: 3,
|
|
217
293
|
minSignalEntropy: 1.2,
|
|
294
|
+
minUniqueTokenRatio: 0.34,
|
|
295
|
+
shortMessageTokenLimit: 6,
|
|
296
|
+
embeddingTriggerMargin: 0.08,
|
|
218
297
|
stripEnvelope: true,
|
|
298
|
+
stripDropLinePrefixPatterns: [
|
|
299
|
+
"^[A-Za-z][A-Za-z _-]{0,30}:\\s*\\[",
|
|
300
|
+
],
|
|
301
|
+
stripDropExactLines: [] as string[],
|
|
302
|
+
stripDropFencedBlockAfterHeaderPatterns: [
|
|
303
|
+
"^[A-Za-z][A-Za-z _-]{0,40}:\\s*\\([^)]*(metadata|context)[^)]*\\):?$",
|
|
304
|
+
],
|
|
305
|
+
handoffTailReadMaxBytes: 512 * 1024,
|
|
219
306
|
dryRun: false,
|
|
220
307
|
debug: false,
|
|
221
308
|
} as const;
|
|
@@ -228,7 +315,7 @@ const LOCK_OPTIONS = {
|
|
|
228
315
|
maxTimeout: 250,
|
|
229
316
|
randomize: true,
|
|
230
317
|
},
|
|
231
|
-
stale:
|
|
318
|
+
stale: 30 * 60 * 1000,
|
|
232
319
|
} as const;
|
|
233
320
|
|
|
234
321
|
const MAX_TRACKED_SESSIONS = 10_000;
|
|
@@ -236,6 +323,13 @@ const STALE_SESSION_STATE_MS = 4 * 60 * 60 * 1000;
|
|
|
236
323
|
const MAX_RECENT_FAST_EVENTS = 20_000;
|
|
237
324
|
const FAST_EVENT_TTL_MS = 5 * 60 * 1000;
|
|
238
325
|
const ROTATION_DEDUPE_MS = 25_000;
|
|
326
|
+
const CROSS_PATH_DEDUPE_MS = 15_000;
|
|
327
|
+
const PERSISTENCE_SCHEMA_VERSION = 1;
|
|
328
|
+
const PERSISTENCE_FILE_NAME = "runtime-state.v1.json";
|
|
329
|
+
const PERSISTENCE_FLUSH_DEBOUNCE_MS = 1_200;
|
|
330
|
+
const MAX_TOKENS_PER_PERSISTED_ENTRY = 256;
|
|
331
|
+
const MAX_PERSISTED_EMBEDDING_DIM = 8_192;
|
|
332
|
+
const MAX_PERSISTED_PENDING_ENTRIES = 8;
|
|
239
333
|
|
|
240
334
|
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
241
335
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
@@ -264,15 +358,6 @@ function clampFloat(value: unknown, fallback: number, min: number, max: number):
|
|
|
264
358
|
return value;
|
|
265
359
|
}
|
|
266
360
|
|
|
267
|
-
function pickDefined<T>(...values: Array<T | undefined>): T | undefined {
|
|
268
|
-
for (const value of values) {
|
|
269
|
-
if (value !== undefined) {
|
|
270
|
-
return value;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return undefined;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
361
|
function normalizePreset(value: unknown): PresetName {
|
|
277
362
|
if (typeof value !== "string") {
|
|
278
363
|
return DEFAULTS.preset;
|
|
@@ -300,18 +385,268 @@ function normalizeEmbeddingProvider(value: unknown): EmbeddingProvider {
|
|
|
300
385
|
return DEFAULTS.embeddingProvider;
|
|
301
386
|
}
|
|
302
387
|
|
|
303
|
-
function
|
|
388
|
+
function normalizeHandoffMode(value: unknown): HandoffMode {
|
|
389
|
+
if (typeof value !== "string") {
|
|
390
|
+
return DEFAULTS.handoffMode;
|
|
391
|
+
}
|
|
392
|
+
const normalized = value.trim().toLowerCase();
|
|
393
|
+
if (normalized === "none" || normalized === "summary" || normalized === "verbatim_last_n") {
|
|
394
|
+
return normalized as HandoffMode;
|
|
395
|
+
}
|
|
396
|
+
return DEFAULTS.handoffMode;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function normalizeSoftSuspectAction(value: unknown): SoftSuspectAction {
|
|
304
400
|
if (typeof value !== "string") {
|
|
305
|
-
return DEFAULTS.
|
|
401
|
+
return DEFAULTS.softSuspectAction;
|
|
306
402
|
}
|
|
307
403
|
const normalized = value.trim().toLowerCase();
|
|
308
|
-
if (normalized === "none" || normalized === "
|
|
404
|
+
if (normalized === "none" || normalized === "ask") {
|
|
309
405
|
return normalized;
|
|
310
406
|
}
|
|
311
|
-
|
|
312
|
-
|
|
407
|
+
return DEFAULTS.softSuspectAction;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function compileRegexList(values: unknown, fallback: readonly string[]): RegExp[] {
|
|
411
|
+
const source = Array.isArray(values) ? values : fallback;
|
|
412
|
+
const out: RegExp[] = [];
|
|
413
|
+
for (const item of source) {
|
|
414
|
+
if (typeof item !== "string") {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const pattern = item.trim();
|
|
418
|
+
if (!pattern) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
out.push(new RegExp(pattern));
|
|
423
|
+
} catch {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function normalizeStringList(values: unknown, fallback: readonly string[]): string[] {
|
|
431
|
+
const source = Array.isArray(values) ? values : fallback;
|
|
432
|
+
return source
|
|
433
|
+
.filter((item): item is string => typeof item === "string")
|
|
434
|
+
.map((item) => item.trim())
|
|
435
|
+
.filter(Boolean);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function sanitizeTimestamp(value: unknown, fallback: number): number {
|
|
439
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
440
|
+
return fallback;
|
|
441
|
+
}
|
|
442
|
+
return Math.floor(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function sanitizeEmbeddingVector(value: unknown): number[] | undefined {
|
|
446
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
const out: number[] = [];
|
|
450
|
+
for (const item of value) {
|
|
451
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
out.push(item);
|
|
455
|
+
if (out.length >= MAX_PERSISTED_EMBEDDING_DIM) {
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return out.length > 0 ? out : undefined;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function normalizeTokenArray(values: unknown): string[] {
|
|
463
|
+
if (!Array.isArray(values)) {
|
|
464
|
+
return [];
|
|
313
465
|
}
|
|
314
|
-
|
|
466
|
+
const out: string[] = [];
|
|
467
|
+
for (const value of values) {
|
|
468
|
+
if (typeof value !== "string") {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const token = value.trim();
|
|
472
|
+
if (!token) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
out.push(token);
|
|
476
|
+
if (out.length >= MAX_TOKENS_PER_PERSISTED_ENTRY) {
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function serializeHistoryEntry(entry: HistoryEntry, includeEmbedding: boolean): PersistedHistoryEntry {
|
|
484
|
+
const tokens = [...entry.tokens]
|
|
485
|
+
.map((token) => token.trim())
|
|
486
|
+
.filter(Boolean)
|
|
487
|
+
.slice(0, MAX_TOKENS_PER_PERSISTED_ENTRY);
|
|
488
|
+
const persisted: PersistedHistoryEntry = {
|
|
489
|
+
tokens,
|
|
490
|
+
at: sanitizeTimestamp(entry.at, Date.now()),
|
|
491
|
+
};
|
|
492
|
+
if (includeEmbedding && Array.isArray(entry.embedding) && entry.embedding.length > 0) {
|
|
493
|
+
persisted.embedding = entry.embedding.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM);
|
|
494
|
+
}
|
|
495
|
+
return persisted;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function deserializeHistoryEntry(
|
|
499
|
+
value: unknown,
|
|
500
|
+
includeEmbedding: boolean,
|
|
501
|
+
): HistoryEntry | null {
|
|
502
|
+
if (!value || typeof value !== "object") {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const record = value as Partial<PersistedHistoryEntry>;
|
|
506
|
+
const tokens = normalizeTokenArray(record.tokens);
|
|
507
|
+
if (tokens.length === 0) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const entry: HistoryEntry = {
|
|
511
|
+
tokens: new Set(tokens),
|
|
512
|
+
at: sanitizeTimestamp(record.at, Date.now()),
|
|
513
|
+
};
|
|
514
|
+
if (includeEmbedding) {
|
|
515
|
+
entry.embedding = sanitizeEmbeddingVector(record.embedding);
|
|
516
|
+
}
|
|
517
|
+
return entry;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function serializeSessionState(state: SessionState): PersistedSessionState {
|
|
521
|
+
const history = trimHistory(state.history, 40).map((entry) => serializeHistoryEntry(entry, false));
|
|
522
|
+
const pendingEntries = trimHistory(state.pendingEntries, MAX_PERSISTED_PENDING_ENTRIES).map((entry) =>
|
|
523
|
+
serializeHistoryEntry(entry, true),
|
|
524
|
+
);
|
|
525
|
+
const topicCentroid =
|
|
526
|
+
Array.isArray(state.topicCentroid) && state.topicCentroid.length > 0
|
|
527
|
+
? state.topicCentroid.filter(Number.isFinite).slice(0, MAX_PERSISTED_EMBEDDING_DIM)
|
|
528
|
+
: undefined;
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
history,
|
|
532
|
+
pendingSoftSignals: clampInt(state.pendingSoftSignals, 0, 0, 16),
|
|
533
|
+
pendingEntries,
|
|
534
|
+
lastResetAt:
|
|
535
|
+
typeof state.lastResetAt === "number" && Number.isFinite(state.lastResetAt)
|
|
536
|
+
? Math.floor(state.lastResetAt)
|
|
537
|
+
: undefined,
|
|
538
|
+
topicCentroid,
|
|
539
|
+
topicCount: clampInt(state.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER),
|
|
540
|
+
topicDim:
|
|
541
|
+
typeof state.topicDim === "number" && Number.isFinite(state.topicDim) && state.topicDim > 0
|
|
542
|
+
? Math.floor(state.topicDim)
|
|
543
|
+
: topicCentroid?.length,
|
|
544
|
+
lastSeenAt: sanitizeTimestamp(state.lastSeenAt, Date.now()),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function deserializeSessionState(value: unknown, cfg: ResolvedConfig): SessionState | null {
|
|
549
|
+
if (!value || typeof value !== "object") {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const record = value as Partial<PersistedSessionState>;
|
|
553
|
+
const historySource = Array.isArray(record.history) ? record.history : [];
|
|
554
|
+
const pendingSource = Array.isArray(record.pendingEntries) ? record.pendingEntries : [];
|
|
555
|
+
|
|
556
|
+
const history = trimHistory(
|
|
557
|
+
historySource
|
|
558
|
+
.map((entry) => deserializeHistoryEntry(entry, false))
|
|
559
|
+
.filter((entry): entry is HistoryEntry => !!entry),
|
|
560
|
+
cfg.historyWindow,
|
|
561
|
+
);
|
|
562
|
+
const pendingEntries = trimHistory(
|
|
563
|
+
pendingSource
|
|
564
|
+
.map((entry) => deserializeHistoryEntry(entry, true))
|
|
565
|
+
.filter((entry): entry is HistoryEntry => !!entry),
|
|
566
|
+
Math.max(MAX_PERSISTED_PENDING_ENTRIES, cfg.softConsecutiveSignals),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const topicCentroid = sanitizeEmbeddingVector(record.topicCentroid);
|
|
570
|
+
const topicCount = clampInt(record.topicCount, topicCentroid ? 1 : 0, 0, Number.MAX_SAFE_INTEGER);
|
|
571
|
+
const topicDim = clampInt(record.topicDim, topicCentroid?.length ?? 0, 0, MAX_PERSISTED_EMBEDDING_DIM);
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
history,
|
|
575
|
+
pendingSoftSignals: clampInt(record.pendingSoftSignals, 0, 0, 16),
|
|
576
|
+
pendingEntries,
|
|
577
|
+
lastResetAt:
|
|
578
|
+
typeof record.lastResetAt === "number" && Number.isFinite(record.lastResetAt)
|
|
579
|
+
? Math.floor(record.lastResetAt)
|
|
580
|
+
: undefined,
|
|
581
|
+
topicCentroid,
|
|
582
|
+
topicCount,
|
|
583
|
+
topicDim: topicDim > 0 ? topicDim : topicCentroid?.length,
|
|
584
|
+
lastSeenAt: sanitizeTimestamp(record.lastSeenAt, Date.now()),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function normalizeProviderId(raw: string): string {
|
|
589
|
+
const provider = raw.trim().toLowerCase();
|
|
590
|
+
if (!provider) {
|
|
591
|
+
return "";
|
|
592
|
+
}
|
|
593
|
+
if (provider === "telegram" || provider.includes("telegram")) {
|
|
594
|
+
return "telegram";
|
|
595
|
+
}
|
|
596
|
+
if (
|
|
597
|
+
provider === "whatsapp" ||
|
|
598
|
+
provider.includes("whatsapp") ||
|
|
599
|
+
provider.includes("baileys")
|
|
600
|
+
) {
|
|
601
|
+
return "whatsapp";
|
|
602
|
+
}
|
|
603
|
+
if (provider === "signal" || provider.includes("signal")) {
|
|
604
|
+
return "signal";
|
|
605
|
+
}
|
|
606
|
+
if (provider === "discord" || provider.includes("discord")) {
|
|
607
|
+
return "discord";
|
|
608
|
+
}
|
|
609
|
+
if (provider === "slack" || provider.includes("slack")) {
|
|
610
|
+
return "slack";
|
|
611
|
+
}
|
|
612
|
+
if (provider === "matrix" || provider.includes("matrix")) {
|
|
613
|
+
return "matrix";
|
|
614
|
+
}
|
|
615
|
+
if (provider === "msteams" || provider.includes("teams")) {
|
|
616
|
+
return "msteams";
|
|
617
|
+
}
|
|
618
|
+
if (provider === "imessage" || provider.includes("imessage") || provider.includes("bluebubbles")) {
|
|
619
|
+
return "imessage";
|
|
620
|
+
}
|
|
621
|
+
if (provider === "web" || provider.includes("webchat")) {
|
|
622
|
+
return "web";
|
|
623
|
+
}
|
|
624
|
+
if (provider === "voice" || provider.includes("voice")) {
|
|
625
|
+
return "voice";
|
|
626
|
+
}
|
|
627
|
+
if (provider.includes("openai")) {
|
|
628
|
+
return "openai";
|
|
629
|
+
}
|
|
630
|
+
if (provider.includes("anthropic")) {
|
|
631
|
+
return "anthropic";
|
|
632
|
+
}
|
|
633
|
+
if (provider.includes("ollama")) {
|
|
634
|
+
return "ollama";
|
|
635
|
+
}
|
|
636
|
+
return provider;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function isInternalNonUserProvider(provider: string): boolean {
|
|
640
|
+
if (!provider) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
return (
|
|
644
|
+
provider === "heartbeat" ||
|
|
645
|
+
provider === "exec-event" ||
|
|
646
|
+
provider.startsWith("cron") ||
|
|
647
|
+
provider.includes("heartbeat") ||
|
|
648
|
+
provider.includes("cron")
|
|
649
|
+
);
|
|
315
650
|
}
|
|
316
651
|
|
|
317
652
|
function resolveConfig(raw: unknown): ResolvedConfig {
|
|
@@ -320,9 +655,17 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
320
655
|
obj.advanced && typeof obj.advanced === "object"
|
|
321
656
|
? (obj.advanced as TopicShiftResetAdvancedConfig)
|
|
322
657
|
: {};
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
658
|
+
const embedding =
|
|
659
|
+
obj.embedding && typeof obj.embedding === "object" ? (obj.embedding as EmbeddingConfig) : {};
|
|
660
|
+
const handoff =
|
|
661
|
+
obj.handoff && typeof obj.handoff === "object" ? (obj.handoff as HandoffConfig) : {};
|
|
662
|
+
const softSuspect =
|
|
663
|
+
obj.softSuspect && typeof obj.softSuspect === "object"
|
|
664
|
+
? (obj.softSuspect as SoftSuspectConfig)
|
|
665
|
+
: {};
|
|
666
|
+
const stripRules =
|
|
667
|
+
advanced.stripRules && typeof advanced.stripRules === "object"
|
|
668
|
+
? (advanced.stripRules as StripRulesConfig)
|
|
326
669
|
: {};
|
|
327
670
|
|
|
328
671
|
const preset = normalizePreset(obj.preset);
|
|
@@ -331,15 +674,13 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
331
674
|
const ignoredProviders = new Set(
|
|
332
675
|
Array.isArray(advanced.ignoredProviders)
|
|
333
676
|
? advanced.ignoredProviders
|
|
334
|
-
.map((value) =>
|
|
677
|
+
.map((value) =>
|
|
678
|
+
typeof value === "string" ? normalizeProviderId(value.trim().toLowerCase()) : "",
|
|
679
|
+
)
|
|
335
680
|
.filter(Boolean)
|
|
336
681
|
: [],
|
|
337
682
|
);
|
|
338
683
|
|
|
339
|
-
const handoffPreference = normalizeHandoffPreference(pickDefined(advanced.handoff, obj.handoff));
|
|
340
|
-
const handoffMode: HandoffMode =
|
|
341
|
-
handoffPreference === "verbatim" ? "verbatim_last_n" : handoffPreference;
|
|
342
|
-
|
|
343
684
|
return {
|
|
344
685
|
enabled: obj.enabled ?? DEFAULTS.enabled,
|
|
345
686
|
historyWindow: clampInt(advanced.historyWindow, presetConfig.historyWindow, 2, 40),
|
|
@@ -369,7 +710,44 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
369
710
|
0,
|
|
370
711
|
8,
|
|
371
712
|
),
|
|
713
|
+
minUniqueTokenRatio: clampFloat(
|
|
714
|
+
advanced.minUniqueTokenRatio,
|
|
715
|
+
DEFAULTS.minUniqueTokenRatio,
|
|
716
|
+
0,
|
|
717
|
+
1,
|
|
718
|
+
),
|
|
719
|
+
shortMessageTokenLimit: clampInt(
|
|
720
|
+
advanced.shortMessageTokenLimit,
|
|
721
|
+
DEFAULTS.shortMessageTokenLimit,
|
|
722
|
+
1,
|
|
723
|
+
40,
|
|
724
|
+
),
|
|
725
|
+
embeddingTriggerMargin: clampFloat(
|
|
726
|
+
advanced.embeddingTriggerMargin,
|
|
727
|
+
DEFAULTS.embeddingTriggerMargin,
|
|
728
|
+
0,
|
|
729
|
+
0.5,
|
|
730
|
+
),
|
|
372
731
|
stripEnvelope: advanced.stripEnvelope ?? DEFAULTS.stripEnvelope,
|
|
732
|
+
stripRules: {
|
|
733
|
+
dropLinePrefixPatterns: compileRegexList(
|
|
734
|
+
stripRules.dropLinePrefixPatterns,
|
|
735
|
+
DEFAULTS.stripDropLinePrefixPatterns,
|
|
736
|
+
),
|
|
737
|
+
dropExactLines: new Set(
|
|
738
|
+
normalizeStringList(stripRules.dropExactLines, DEFAULTS.stripDropExactLines),
|
|
739
|
+
),
|
|
740
|
+
dropFencedBlockAfterHeaderPatterns: compileRegexList(
|
|
741
|
+
stripRules.dropFencedBlockAfterHeaderPatterns,
|
|
742
|
+
DEFAULTS.stripDropFencedBlockAfterHeaderPatterns,
|
|
743
|
+
),
|
|
744
|
+
},
|
|
745
|
+
handoffTailReadMaxBytes: clampInt(
|
|
746
|
+
advanced.handoffTailReadMaxBytes,
|
|
747
|
+
DEFAULTS.handoffTailReadMaxBytes,
|
|
748
|
+
64 * 1024,
|
|
749
|
+
8 * 1024 * 1024,
|
|
750
|
+
),
|
|
373
751
|
softConsecutiveSignals: clampInt(
|
|
374
752
|
advanced.softConsecutiveSignals,
|
|
375
753
|
presetConfig.softConsecutiveSignals,
|
|
@@ -404,26 +782,34 @@ function resolveConfig(raw: unknown): ResolvedConfig {
|
|
|
404
782
|
0,
|
|
405
783
|
1,
|
|
406
784
|
),
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
785
|
+
handoff: {
|
|
786
|
+
mode: normalizeHandoffMode(handoff.mode),
|
|
787
|
+
lastN: clampInt(handoff.lastN, DEFAULTS.handoffLastN, 1, 20),
|
|
788
|
+
maxChars: clampInt(handoff.maxChars, DEFAULTS.handoffMaxChars, 60, 800),
|
|
789
|
+
},
|
|
790
|
+
softSuspect: {
|
|
791
|
+
action: normalizeSoftSuspectAction(softSuspect.action),
|
|
792
|
+
prompt:
|
|
793
|
+
typeof softSuspect.prompt === "string" && softSuspect.prompt.trim()
|
|
794
|
+
? softSuspect.prompt.trim()
|
|
795
|
+
: DEFAULTS.softSuspectPrompt,
|
|
796
|
+
ttlMs: clampInt(softSuspect.ttlSeconds, DEFAULTS.softSuspectTtlSeconds, 10, 1_800) * 1000,
|
|
797
|
+
},
|
|
410
798
|
embedding: {
|
|
411
|
-
provider: normalizeEmbeddingProvider(
|
|
412
|
-
pickDefined(advanced.embeddings, advancedEmbedding.provider, obj.embeddings),
|
|
413
|
-
),
|
|
799
|
+
provider: normalizeEmbeddingProvider(embedding.provider),
|
|
414
800
|
model: (() => {
|
|
415
|
-
const rawModel =
|
|
801
|
+
const rawModel = embedding.model;
|
|
416
802
|
return typeof rawModel === "string" ? rawModel.trim() : undefined;
|
|
417
803
|
})(),
|
|
418
804
|
baseUrl: (() => {
|
|
419
|
-
const rawBaseUrl =
|
|
805
|
+
const rawBaseUrl = embedding.baseUrl;
|
|
420
806
|
return typeof rawBaseUrl === "string" ? rawBaseUrl.trim() : undefined;
|
|
421
807
|
})(),
|
|
422
808
|
apiKey: (() => {
|
|
423
|
-
const rawApiKey =
|
|
809
|
+
const rawApiKey = embedding.apiKey;
|
|
424
810
|
return typeof rawApiKey === "string" ? rawApiKey.trim() : undefined;
|
|
425
811
|
})(),
|
|
426
|
-
timeoutMs: clampInt(
|
|
812
|
+
timeoutMs: clampInt(embedding.timeoutMs, DEFAULTS.embeddingTimeoutMs, 1000, 30_000),
|
|
427
813
|
},
|
|
428
814
|
dryRun: obj.dryRun ?? DEFAULTS.dryRun,
|
|
429
815
|
debug: obj.debug ?? DEFAULTS.debug,
|
|
@@ -484,13 +870,27 @@ function tokenEntropy(tokens: string[]): number {
|
|
|
484
870
|
return entropy;
|
|
485
871
|
}
|
|
486
872
|
|
|
487
|
-
function
|
|
873
|
+
function matchesAny(patterns: RegExp[], value: string): boolean {
|
|
874
|
+
for (const pattern of patterns) {
|
|
875
|
+
if (pattern.test(value)) {
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function stripClassifierEnvelope(
|
|
883
|
+
text: string,
|
|
884
|
+
rules: ResolvedConfig["stripRules"],
|
|
885
|
+
): string {
|
|
488
886
|
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
489
887
|
const kept: string[] = [];
|
|
490
888
|
let skipFence = false;
|
|
491
889
|
let expectingMetadataFence = false;
|
|
890
|
+
let sawSemanticContent = false;
|
|
492
891
|
|
|
493
|
-
for (
|
|
892
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
893
|
+
const line = lines[index] ?? "";
|
|
494
894
|
const trimmed = line.trim();
|
|
495
895
|
if (skipFence) {
|
|
496
896
|
if (trimmed.startsWith("```")) {
|
|
@@ -499,14 +899,17 @@ function stripClassifierEnvelope(text: string): string {
|
|
|
499
899
|
continue;
|
|
500
900
|
}
|
|
501
901
|
|
|
502
|
-
if (
|
|
503
|
-
trimmed === "Conversation info (untrusted metadata):" ||
|
|
504
|
-
trimmed === "Replied message (untrusted, for context):"
|
|
505
|
-
) {
|
|
902
|
+
if (matchesAny(rules.dropFencedBlockAfterHeaderPatterns, trimmed)) {
|
|
506
903
|
expectingMetadataFence = true;
|
|
507
904
|
continue;
|
|
508
905
|
}
|
|
509
906
|
|
|
907
|
+
// Drop top fenced metadata envelopes even if header wording changes.
|
|
908
|
+
if (!sawSemanticContent && index < 12 && (trimmed === "```" || trimmed === "```json")) {
|
|
909
|
+
skipFence = true;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
|
|
510
913
|
if (expectingMetadataFence && trimmed.startsWith("```")) {
|
|
511
914
|
skipFence = true;
|
|
512
915
|
expectingMetadataFence = false;
|
|
@@ -515,17 +918,14 @@ function stripClassifierEnvelope(text: string): string {
|
|
|
515
918
|
|
|
516
919
|
expectingMetadataFence = false;
|
|
517
920
|
|
|
518
|
-
if (
|
|
519
|
-
trimmed.startsWith("System: [") ||
|
|
520
|
-
trimmed.startsWith("Current time:") ||
|
|
521
|
-
trimmed.startsWith("Read HEARTBEAT.md if it exists") ||
|
|
522
|
-
trimmed.startsWith("To send an image back, prefer the message tool") ||
|
|
523
|
-
trimmed.startsWith("[media attached:")
|
|
524
|
-
) {
|
|
921
|
+
if (rules.dropExactLines.has(trimmed) || matchesAny(rules.dropLinePrefixPatterns, trimmed)) {
|
|
525
922
|
continue;
|
|
526
923
|
}
|
|
527
924
|
|
|
528
925
|
kept.push(line);
|
|
926
|
+
if (trimmed) {
|
|
927
|
+
sawSemanticContent = true;
|
|
928
|
+
}
|
|
529
929
|
}
|
|
530
930
|
|
|
531
931
|
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
@@ -592,29 +992,36 @@ function cosineSimilarity(a: number[], b: number[]): number | undefined {
|
|
|
592
992
|
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
593
993
|
}
|
|
594
994
|
|
|
595
|
-
function
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return undefined;
|
|
995
|
+
function seedTopicCentroid(state: SessionState, vector?: number[]): void {
|
|
996
|
+
if (!Array.isArray(vector) || vector.length === 0) {
|
|
997
|
+
state.topicCentroid = undefined;
|
|
998
|
+
state.topicCount = 0;
|
|
999
|
+
state.topicDim = undefined;
|
|
1000
|
+
return;
|
|
602
1001
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1002
|
+
state.topicCentroid = [...vector];
|
|
1003
|
+
state.topicCount = 1;
|
|
1004
|
+
state.topicDim = vector.length;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function updateTopicCentroid(state: SessionState, vector?: number[]): void {
|
|
1008
|
+
if (!Array.isArray(vector) || vector.length === 0) {
|
|
1009
|
+
return;
|
|
607
1010
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1011
|
+
if (
|
|
1012
|
+
!Array.isArray(state.topicCentroid) ||
|
|
1013
|
+
state.topicCentroid.length !== vector.length ||
|
|
1014
|
+
!state.topicCount
|
|
1015
|
+
) {
|
|
1016
|
+
seedTopicCentroid(state, vector);
|
|
1017
|
+
return;
|
|
613
1018
|
}
|
|
614
|
-
|
|
615
|
-
|
|
1019
|
+
const nextCount = state.topicCount + 1;
|
|
1020
|
+
for (let i = 0; i < vector.length; i += 1) {
|
|
1021
|
+
state.topicCentroid[i] += (vector[i] - state.topicCentroid[i]) / nextCount;
|
|
616
1022
|
}
|
|
617
|
-
|
|
1023
|
+
state.topicCount = nextCount;
|
|
1024
|
+
state.topicDim = vector.length;
|
|
618
1025
|
}
|
|
619
1026
|
|
|
620
1027
|
function trimHistory(entries: HistoryEntry[], limit: number): HistoryEntry[] {
|
|
@@ -645,7 +1052,10 @@ function looksLikeGroup(value?: string): boolean {
|
|
|
645
1052
|
);
|
|
646
1053
|
}
|
|
647
1054
|
|
|
648
|
-
function inferFastPeer(
|
|
1055
|
+
function inferFastPeer(
|
|
1056
|
+
event: FastMessageEventLike,
|
|
1057
|
+
ctx: { conversationId?: string },
|
|
1058
|
+
): { kind: "direct" | "group" | "channel"; id: string } {
|
|
649
1059
|
const from = event.from?.trim() ?? "";
|
|
650
1060
|
const conversationId = ctx.conversationId?.trim() || from;
|
|
651
1061
|
const metadata = (event.metadata ?? {}) as Record<string, unknown>;
|
|
@@ -659,12 +1069,13 @@ function inferFastPeer(event: PluginHookMessageReceivedEvent, ctx: { conversatio
|
|
|
659
1069
|
|
|
660
1070
|
if (threadId) {
|
|
661
1071
|
return {
|
|
662
|
-
kind: "
|
|
1072
|
+
kind: "group",
|
|
663
1073
|
id: `${conversationId || from}:thread:${threadId}`,
|
|
664
1074
|
};
|
|
665
1075
|
}
|
|
666
1076
|
|
|
667
|
-
const kind
|
|
1077
|
+
const kind: "group" | "direct" =
|
|
1078
|
+
looksLikeGroup(conversationId) || looksLikeGroup(from) ? "group" : "direct";
|
|
668
1079
|
return {
|
|
669
1080
|
kind,
|
|
670
1081
|
id: conversationId || from || "unknown",
|
|
@@ -763,45 +1174,84 @@ function resolveEmbeddingBackend(cfg: ResolvedConfig): EmbeddingBackend | null {
|
|
|
763
1174
|
return createOllamaBackend(cfg);
|
|
764
1175
|
}
|
|
765
1176
|
|
|
766
|
-
function
|
|
1177
|
+
function computeLexicalFeatures(params: {
|
|
767
1178
|
cfg: ResolvedConfig;
|
|
768
|
-
state: SessionState;
|
|
769
1179
|
entry: HistoryEntry;
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
const baselineTokens = unionTokens(baselineEntries);
|
|
775
|
-
|
|
776
|
-
const lexicalSimilarity = jaccardSimilarity(entry.tokens, baselineTokens);
|
|
1180
|
+
tokenList: string[];
|
|
1181
|
+
baselineTokens: Set<string>;
|
|
1182
|
+
}): LexicalFeatures {
|
|
1183
|
+
const lexicalSimilarity = jaccardSimilarity(params.entry.tokens, params.baselineTokens);
|
|
777
1184
|
const lexicalDistance = 1 - lexicalSimilarity;
|
|
778
|
-
const novelty = noveltyRatio(entry.tokens, baselineTokens);
|
|
1185
|
+
const novelty = noveltyRatio(params.entry.tokens, params.baselineTokens);
|
|
1186
|
+
const uniqueTokenRatio =
|
|
1187
|
+
params.tokenList.length > 0 ? params.entry.tokens.size / params.tokenList.length : 0;
|
|
1188
|
+
const entropy = tokenEntropy(params.tokenList);
|
|
779
1189
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1190
|
+
let score = 0.55 * lexicalDistance + 0.45 * novelty;
|
|
1191
|
+
if (uniqueTokenRatio < params.cfg.minUniqueTokenRatio) {
|
|
1192
|
+
score -= (params.cfg.minUniqueTokenRatio - uniqueTokenRatio) * 0.4;
|
|
1193
|
+
}
|
|
1194
|
+
if (params.tokenList.length <= params.cfg.shortMessageTokenLimit && entropy < params.cfg.minSignalEntropy) {
|
|
1195
|
+
score -= (params.cfg.minSignalEntropy - entropy) * 0.06;
|
|
1196
|
+
}
|
|
787
1197
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1198
|
+
return {
|
|
1199
|
+
score: Math.max(0, Math.min(1, score)),
|
|
1200
|
+
novelty,
|
|
1201
|
+
lexicalDistance,
|
|
1202
|
+
uniqueTokenRatio,
|
|
1203
|
+
entropy,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function shouldRequestEmbedding(params: {
|
|
1208
|
+
cfg: ResolvedConfig;
|
|
1209
|
+
backendAvailable: boolean;
|
|
1210
|
+
lexical: LexicalFeatures;
|
|
1211
|
+
warmup: boolean;
|
|
1212
|
+
cooldownActive: boolean;
|
|
1213
|
+
}): boolean {
|
|
1214
|
+
if (!params.backendAvailable || params.warmup || params.cooldownActive) {
|
|
1215
|
+
return false;
|
|
1216
|
+
}
|
|
1217
|
+
if (params.lexical.score >= params.cfg.softScoreThreshold - params.cfg.embeddingTriggerMargin) {
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
return (
|
|
1221
|
+
params.lexical.novelty >= params.cfg.softNoveltyThreshold * 0.9 &&
|
|
1222
|
+
params.lexical.lexicalDistance >= 0.35
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function classifyMessage(params: {
|
|
1227
|
+
cfg: ResolvedConfig;
|
|
1228
|
+
state: SessionState;
|
|
1229
|
+
baselineTokenCount: number;
|
|
1230
|
+
lexical: LexicalFeatures;
|
|
1231
|
+
similarity?: number;
|
|
1232
|
+
usedEmbedding: boolean;
|
|
1233
|
+
now: number;
|
|
1234
|
+
}): ClassificationDecision {
|
|
1235
|
+
const { cfg, state, now } = params;
|
|
1236
|
+
const score =
|
|
1237
|
+
params.usedEmbedding && typeof params.similarity === "number"
|
|
1238
|
+
? 0.7 * (1 - params.similarity) +
|
|
1239
|
+
0.15 * params.lexical.lexicalDistance +
|
|
1240
|
+
0.15 * params.lexical.novelty
|
|
1241
|
+
: params.lexical.score;
|
|
791
1242
|
|
|
792
1243
|
const metrics = {
|
|
793
1244
|
score,
|
|
794
|
-
novelty,
|
|
795
|
-
lexicalDistance,
|
|
796
|
-
|
|
797
|
-
|
|
1245
|
+
novelty: params.lexical.novelty,
|
|
1246
|
+
lexicalDistance: params.lexical.lexicalDistance,
|
|
1247
|
+
uniqueTokenRatio: params.lexical.uniqueTokenRatio,
|
|
1248
|
+
entropy: params.lexical.entropy,
|
|
1249
|
+
similarity: params.similarity,
|
|
1250
|
+
usedEmbedding: params.usedEmbedding,
|
|
798
1251
|
pendingSoftSignals: state.pendingSoftSignals,
|
|
799
1252
|
} satisfies ClassifierMetrics;
|
|
800
1253
|
|
|
801
|
-
if (
|
|
802
|
-
baselineEntries.length < cfg.minHistoryMessages ||
|
|
803
|
-
baselineTokens.size < cfg.minMeaningfulTokens
|
|
804
|
-
) {
|
|
1254
|
+
if (state.history.length < cfg.minHistoryMessages || params.baselineTokenCount < cfg.minMeaningfulTokens) {
|
|
805
1255
|
return { kind: "warmup", metrics, reason: "warmup" };
|
|
806
1256
|
}
|
|
807
1257
|
|
|
@@ -814,8 +1264,11 @@ function classifyMessage(params: {
|
|
|
814
1264
|
|
|
815
1265
|
const hardSignal =
|
|
816
1266
|
score >= cfg.hardScoreThreshold ||
|
|
817
|
-
(
|
|
818
|
-
|
|
1267
|
+
(params.usedEmbedding && typeof params.similarity === "number"
|
|
1268
|
+
? params.similarity <= cfg.hardSimilarityThreshold &&
|
|
1269
|
+
params.lexical.novelty >= cfg.hardNoveltyThreshold
|
|
1270
|
+
: params.lexical.novelty >= cfg.hardNoveltyThreshold &&
|
|
1271
|
+
params.lexical.lexicalDistance >= 0.65);
|
|
819
1272
|
|
|
820
1273
|
if (hardSignal) {
|
|
821
1274
|
return { kind: "rotate-hard", metrics, reason: "hard-threshold" };
|
|
@@ -823,9 +1276,11 @@ function classifyMessage(params: {
|
|
|
823
1276
|
|
|
824
1277
|
const softSignal =
|
|
825
1278
|
score >= cfg.softScoreThreshold ||
|
|
826
|
-
(
|
|
827
|
-
|
|
828
|
-
|
|
1279
|
+
(params.usedEmbedding && typeof params.similarity === "number"
|
|
1280
|
+
? params.similarity <= cfg.softSimilarityThreshold &&
|
|
1281
|
+
params.lexical.novelty >= cfg.softNoveltyThreshold
|
|
1282
|
+
: params.lexical.novelty >= cfg.softNoveltyThreshold &&
|
|
1283
|
+
params.lexical.lexicalDistance >= 0.45);
|
|
829
1284
|
|
|
830
1285
|
if (!softSignal) {
|
|
831
1286
|
return { kind: "stable", metrics, reason: "stable" };
|
|
@@ -885,15 +1340,10 @@ function resolveSessionFilePathFromEntry(params: {
|
|
|
885
1340
|
return path.resolve(sessionsDir, `${sessionId}.jsonl`);
|
|
886
1341
|
}
|
|
887
1342
|
|
|
888
|
-
|
|
889
|
-
sessionFile: string;
|
|
890
|
-
takeLast: number;
|
|
891
|
-
}): Promise<TranscriptMessage[]> {
|
|
892
|
-
const raw = await fs.readFile(params.sessionFile, "utf-8");
|
|
893
|
-
const lines = raw.split("\n");
|
|
1343
|
+
function parseTranscriptTailLines(lines: string[], takeLast: number): TranscriptMessage[] {
|
|
894
1344
|
const messages: TranscriptMessage[] = [];
|
|
895
|
-
|
|
896
|
-
|
|
1345
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
1346
|
+
const line = lines[i] ?? "";
|
|
897
1347
|
const trimmed = line.trim();
|
|
898
1348
|
if (!trimmed) {
|
|
899
1349
|
continue;
|
|
@@ -924,12 +1374,67 @@ async function readTranscriptTail(params: {
|
|
|
924
1374
|
continue;
|
|
925
1375
|
}
|
|
926
1376
|
messages.push({ role, text });
|
|
1377
|
+
if (messages.length >= takeLast) {
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
927
1380
|
}
|
|
1381
|
+
messages.reverse();
|
|
1382
|
+
return messages;
|
|
1383
|
+
}
|
|
928
1384
|
|
|
929
|
-
|
|
930
|
-
|
|
1385
|
+
async function readTranscriptTail(params: {
|
|
1386
|
+
sessionFile: string;
|
|
1387
|
+
takeLast: number;
|
|
1388
|
+
maxBytes: number;
|
|
1389
|
+
onFallbackFullRead?: () => void;
|
|
1390
|
+
}): Promise<TranscriptMessage[]> {
|
|
1391
|
+
const fd = await fs.open(params.sessionFile, "r");
|
|
1392
|
+
let fallbackFullRead = false;
|
|
1393
|
+
try {
|
|
1394
|
+
const stat = await fd.stat();
|
|
1395
|
+
if (stat.size <= 0) {
|
|
1396
|
+
return [];
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
let position = stat.size;
|
|
1400
|
+
let bytesReadTotal = 0;
|
|
1401
|
+
const chunks: string[] = [];
|
|
1402
|
+
while (position > 0 && bytesReadTotal < params.maxBytes) {
|
|
1403
|
+
const remainingBudget = params.maxBytes - bytesReadTotal;
|
|
1404
|
+
const toRead = Math.min(64 * 1024, position, remainingBudget);
|
|
1405
|
+
if (toRead <= 0) {
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
const nextPosition = position - toRead;
|
|
1409
|
+
const buffer = Buffer.allocUnsafe(toRead);
|
|
1410
|
+
const result = await fd.read(buffer, 0, toRead, nextPosition);
|
|
1411
|
+
if (result.bytesRead <= 0) {
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
chunks.unshift(buffer.subarray(0, result.bytesRead).toString("utf-8"));
|
|
1415
|
+
position = nextPosition;
|
|
1416
|
+
bytesReadTotal += result.bytesRead;
|
|
1417
|
+
if (chunks.join("").split("\n").length > params.takeLast * 30) {
|
|
1418
|
+
break;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const tail = parseTranscriptTailLines(chunks.join("").split("\n"), params.takeLast);
|
|
1423
|
+
if (tail.length >= params.takeLast || bytesReadTotal >= stat.size) {
|
|
1424
|
+
return tail;
|
|
1425
|
+
}
|
|
1426
|
+
fallbackFullRead = true;
|
|
1427
|
+
} finally {
|
|
1428
|
+
await fd.close();
|
|
931
1429
|
}
|
|
932
|
-
|
|
1430
|
+
|
|
1431
|
+
if (!fallbackFullRead) {
|
|
1432
|
+
return [];
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
params.onFallbackFullRead?.();
|
|
1436
|
+
const raw = await fs.readFile(params.sessionFile, "utf-8");
|
|
1437
|
+
return parseTranscriptTailLines(raw.split("\n"), params.takeLast);
|
|
933
1438
|
}
|
|
934
1439
|
|
|
935
1440
|
function truncateText(text: string, maxChars: number): string {
|
|
@@ -968,7 +1473,7 @@ async function buildHandoffEventFromPreviousSession(params: {
|
|
|
968
1473
|
previousEntry?: SessionEntryLike;
|
|
969
1474
|
logger: OpenClawPluginApi["logger"];
|
|
970
1475
|
}): Promise<string | null> {
|
|
971
|
-
if (params.cfg.
|
|
1476
|
+
if (params.cfg.handoff.mode === "none") {
|
|
972
1477
|
return null;
|
|
973
1478
|
}
|
|
974
1479
|
const sessionFile = resolveSessionFilePathFromEntry({
|
|
@@ -982,12 +1487,18 @@ async function buildHandoffEventFromPreviousSession(params: {
|
|
|
982
1487
|
try {
|
|
983
1488
|
const tail = await readTranscriptTail({
|
|
984
1489
|
sessionFile,
|
|
985
|
-
takeLast: params.cfg.
|
|
1490
|
+
takeLast: params.cfg.handoff.lastN,
|
|
1491
|
+
maxBytes: params.cfg.handoffTailReadMaxBytes,
|
|
1492
|
+
onFallbackFullRead: () => {
|
|
1493
|
+
params.logger.warn(
|
|
1494
|
+
`topic-shift-reset: handoff tail fallback full-read file=${sessionFile}`,
|
|
1495
|
+
);
|
|
1496
|
+
},
|
|
986
1497
|
});
|
|
987
1498
|
return formatHandoffEventText({
|
|
988
|
-
mode: params.cfg.
|
|
1499
|
+
mode: params.cfg.handoff.mode,
|
|
989
1500
|
messages: tail,
|
|
990
|
-
maxChars: params.cfg.
|
|
1501
|
+
maxChars: params.cfg.handoff.maxChars,
|
|
991
1502
|
});
|
|
992
1503
|
} catch (error) {
|
|
993
1504
|
params.logger.warn(
|
|
@@ -997,15 +1508,17 @@ async function buildHandoffEventFromPreviousSession(params: {
|
|
|
997
1508
|
}
|
|
998
1509
|
}
|
|
999
1510
|
|
|
1000
|
-
function pruneStateMaps(stateBySession: Map<string, SessionState>):
|
|
1511
|
+
function pruneStateMaps(stateBySession: Map<string, SessionState>): boolean {
|
|
1512
|
+
let changed = false;
|
|
1001
1513
|
const now = Date.now();
|
|
1002
1514
|
for (const [sessionKey, state] of stateBySession) {
|
|
1003
1515
|
if (now - state.lastSeenAt > STALE_SESSION_STATE_MS) {
|
|
1004
1516
|
stateBySession.delete(sessionKey);
|
|
1517
|
+
changed = true;
|
|
1005
1518
|
}
|
|
1006
1519
|
}
|
|
1007
1520
|
if (stateBySession.size <= MAX_TRACKED_SESSIONS) {
|
|
1008
|
-
return;
|
|
1521
|
+
return changed;
|
|
1009
1522
|
}
|
|
1010
1523
|
const ordered = [...stateBySession.entries()].sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt);
|
|
1011
1524
|
const toDrop = stateBySession.size - MAX_TRACKED_SESSIONS;
|
|
@@ -1013,19 +1526,23 @@ function pruneStateMaps(stateBySession: Map<string, SessionState>): void {
|
|
|
1013
1526
|
const sessionKey = ordered[i]?.[0];
|
|
1014
1527
|
if (sessionKey) {
|
|
1015
1528
|
stateBySession.delete(sessionKey);
|
|
1529
|
+
changed = true;
|
|
1016
1530
|
}
|
|
1017
1531
|
}
|
|
1532
|
+
return changed;
|
|
1018
1533
|
}
|
|
1019
1534
|
|
|
1020
|
-
function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number):
|
|
1535
|
+
function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number): boolean {
|
|
1536
|
+
let changed = false;
|
|
1021
1537
|
const now = Date.now();
|
|
1022
1538
|
for (const [key, ts] of map) {
|
|
1023
1539
|
if (now - ts > ttlMs) {
|
|
1024
1540
|
map.delete(key);
|
|
1541
|
+
changed = true;
|
|
1025
1542
|
}
|
|
1026
1543
|
}
|
|
1027
1544
|
if (map.size <= maxSize) {
|
|
1028
|
-
return;
|
|
1545
|
+
return changed;
|
|
1029
1546
|
}
|
|
1030
1547
|
const ordered = [...map.entries()].sort((a, b) => a[1] - b[1]);
|
|
1031
1548
|
const toDrop = map.size - maxSize;
|
|
@@ -1033,8 +1550,38 @@ function pruneRecentMap(map: Map<string, number>, ttlMs: number, maxSize: number
|
|
|
1033
1550
|
const key = ordered[i]?.[0];
|
|
1034
1551
|
if (key) {
|
|
1035
1552
|
map.delete(key);
|
|
1553
|
+
changed = true;
|
|
1036
1554
|
}
|
|
1037
1555
|
}
|
|
1556
|
+
return changed;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function pruneRecentClassifiedMap(
|
|
1560
|
+
map: Map<string, { at: number; source: "fast" | "fallback" }>,
|
|
1561
|
+
ttlMs: number,
|
|
1562
|
+
maxSize: number,
|
|
1563
|
+
): boolean {
|
|
1564
|
+
let changed = false;
|
|
1565
|
+
const now = Date.now();
|
|
1566
|
+
for (const [key, value] of map) {
|
|
1567
|
+
if (now - value.at > ttlMs) {
|
|
1568
|
+
map.delete(key);
|
|
1569
|
+
changed = true;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
if (map.size <= maxSize) {
|
|
1573
|
+
return changed;
|
|
1574
|
+
}
|
|
1575
|
+
const ordered = [...map.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
1576
|
+
const toDrop = map.size - maxSize;
|
|
1577
|
+
for (let i = 0; i < toDrop; i += 1) {
|
|
1578
|
+
const key = ordered[i]?.[0];
|
|
1579
|
+
if (key) {
|
|
1580
|
+
map.delete(key);
|
|
1581
|
+
changed = true;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return changed;
|
|
1038
1585
|
}
|
|
1039
1586
|
|
|
1040
1587
|
async function rotateSessionEntry(params: {
|
|
@@ -1054,13 +1601,9 @@ async function rotateSessionEntry(params: {
|
|
|
1054
1601
|
});
|
|
1055
1602
|
|
|
1056
1603
|
if (params.cfg.dryRun) {
|
|
1057
|
-
params.state.lastResetAt = Date.now();
|
|
1058
|
-
params.state.pendingSoftSignals = 0;
|
|
1059
|
-
params.state.pendingEntries = [];
|
|
1060
|
-
params.state.history = trimHistory([params.entry], params.cfg.historyWindow);
|
|
1061
1604
|
params.api.logger.info(
|
|
1062
1605
|
[
|
|
1063
|
-
`topic-shift-reset:
|
|
1606
|
+
`topic-shift-reset: would-rotate`,
|
|
1064
1607
|
`source=${params.source}`,
|
|
1065
1608
|
`reason=${params.reason}`,
|
|
1066
1609
|
`session=${params.sessionKey}`,
|
|
@@ -1129,8 +1672,9 @@ async function rotateSessionEntry(params: {
|
|
|
1129
1672
|
params.state.pendingSoftSignals = 0;
|
|
1130
1673
|
params.state.pendingEntries = [];
|
|
1131
1674
|
params.state.history = trimHistory([params.entry], params.cfg.historyWindow);
|
|
1675
|
+
seedTopicCentroid(params.state, params.entry.embedding);
|
|
1132
1676
|
|
|
1133
|
-
params.api.logger.
|
|
1677
|
+
params.api.logger.info(
|
|
1134
1678
|
[
|
|
1135
1679
|
`topic-shift-reset: rotated`,
|
|
1136
1680
|
`source=${params.source}`,
|
|
@@ -1152,6 +1696,185 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1152
1696
|
const sessionState = new Map<string, SessionState>();
|
|
1153
1697
|
const recentFastEvents = new Map<string, number>();
|
|
1154
1698
|
const recentRotationBySession = new Map<string, number>();
|
|
1699
|
+
const pendingSoftSuspectSteeringBySession = new Map<string, number>();
|
|
1700
|
+
const recentClassifiedBySessionHash = new Map<
|
|
1701
|
+
string,
|
|
1702
|
+
{ at: number; source: "fast" | "fallback" }
|
|
1703
|
+
>();
|
|
1704
|
+
const sessionWorkQueue = new Map<string, Promise<unknown>>();
|
|
1705
|
+
|
|
1706
|
+
const persistencePath = (() => {
|
|
1707
|
+
try {
|
|
1708
|
+
const stateDir = api.runtime.state.resolveStateDir();
|
|
1709
|
+
return path.join(stateDir, "plugins", api.id, PERSISTENCE_FILE_NAME);
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
api.logger.warn(`topic-shift-reset: persistence disabled (state path): ${String(error)}`);
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
})();
|
|
1715
|
+
|
|
1716
|
+
let persistenceDirty = false;
|
|
1717
|
+
let persistenceTimer: NodeJS.Timeout | null = null;
|
|
1718
|
+
let persistenceFlushPromise: Promise<void> | null = null;
|
|
1719
|
+
let persistenceLoadPromise: Promise<void> = Promise.resolve();
|
|
1720
|
+
|
|
1721
|
+
const clearPersistenceTimer = () => {
|
|
1722
|
+
if (!persistenceTimer) {
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
clearTimeout(persistenceTimer);
|
|
1726
|
+
persistenceTimer = null;
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
const flushPersistentState = async (reason: string): Promise<void> => {
|
|
1730
|
+
if (!persistencePath) {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
await persistenceLoadPromise;
|
|
1734
|
+
if (!persistenceDirty) {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (persistenceFlushPromise) {
|
|
1738
|
+
await persistenceFlushPromise;
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
persistenceFlushPromise = (async () => {
|
|
1742
|
+
const now = Date.now();
|
|
1743
|
+
pruneStateMaps(sessionState);
|
|
1744
|
+
pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
|
|
1745
|
+
|
|
1746
|
+
const payload: PersistedRuntimeState = {
|
|
1747
|
+
version: PERSISTENCE_SCHEMA_VERSION,
|
|
1748
|
+
savedAt: now,
|
|
1749
|
+
sessionStateBySessionKey: {},
|
|
1750
|
+
recentRotationBySession: {},
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
for (const [sessionKey, state] of sessionState) {
|
|
1754
|
+
payload.sessionStateBySessionKey[sessionKey] = serializeSessionState(state);
|
|
1755
|
+
}
|
|
1756
|
+
for (const [rotationKey, ts] of recentRotationBySession) {
|
|
1757
|
+
if (now - ts <= ROTATION_DEDUPE_MS * 3) {
|
|
1758
|
+
payload.recentRotationBySession[rotationKey] = ts;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
|
|
1763
|
+
await writeJsonFileAtomically(persistencePath, payload);
|
|
1764
|
+
});
|
|
1765
|
+
persistenceDirty = false;
|
|
1766
|
+
if (cfg.debug) {
|
|
1767
|
+
api.logger.debug(
|
|
1768
|
+
`topic-shift-reset: state-flushed reason=${reason} sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
})()
|
|
1772
|
+
.catch((error) => {
|
|
1773
|
+
api.logger.warn(`topic-shift-reset: state flush failed err=${String(error)}`);
|
|
1774
|
+
})
|
|
1775
|
+
.finally(() => {
|
|
1776
|
+
persistenceFlushPromise = null;
|
|
1777
|
+
});
|
|
1778
|
+
await persistenceFlushPromise;
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
const schedulePersistentFlush = (urgent = false) => {
|
|
1782
|
+
if (!persistencePath) {
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
persistenceDirty = true;
|
|
1786
|
+
if (urgent) {
|
|
1787
|
+
void flushPersistentState("urgent");
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (persistenceTimer) {
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
persistenceTimer = setTimeout(() => {
|
|
1794
|
+
persistenceTimer = null;
|
|
1795
|
+
void flushPersistentState("scheduled");
|
|
1796
|
+
}, PERSISTENCE_FLUSH_DEBOUNCE_MS);
|
|
1797
|
+
persistenceTimer.unref?.();
|
|
1798
|
+
};
|
|
1799
|
+
|
|
1800
|
+
const runSerializedBySession = async <T>(
|
|
1801
|
+
sessionKey: string,
|
|
1802
|
+
fn: () => Promise<T>,
|
|
1803
|
+
): Promise<T> => {
|
|
1804
|
+
const previous = sessionWorkQueue.get(sessionKey) ?? Promise.resolve();
|
|
1805
|
+
const current = previous.catch(() => undefined).then(fn);
|
|
1806
|
+
const tail = current.then(
|
|
1807
|
+
() => undefined,
|
|
1808
|
+
() => undefined,
|
|
1809
|
+
);
|
|
1810
|
+
sessionWorkQueue.set(sessionKey, tail);
|
|
1811
|
+
try {
|
|
1812
|
+
return await current;
|
|
1813
|
+
} finally {
|
|
1814
|
+
if (sessionWorkQueue.get(sessionKey) === tail) {
|
|
1815
|
+
sessionWorkQueue.delete(sessionKey);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
persistenceLoadPromise = (async () => {
|
|
1821
|
+
if (!persistencePath) {
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
try {
|
|
1825
|
+
const loaded = await withFileLock(persistencePath, LOCK_OPTIONS, async () => {
|
|
1826
|
+
return await readJsonFileWithFallback<PersistedRuntimeState | null>(persistencePath, null);
|
|
1827
|
+
});
|
|
1828
|
+
const value = loaded.value;
|
|
1829
|
+
if (!value || typeof value !== "object") {
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (value.version !== PERSISTENCE_SCHEMA_VERSION) {
|
|
1833
|
+
api.logger.warn(
|
|
1834
|
+
`topic-shift-reset: state version mismatch expected=${PERSISTENCE_SCHEMA_VERSION} got=${String(value.version)}; ignoring persisted state`,
|
|
1835
|
+
);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const restoredSessionStateByKey =
|
|
1840
|
+
value.sessionStateBySessionKey && typeof value.sessionStateBySessionKey === "object"
|
|
1841
|
+
? value.sessionStateBySessionKey
|
|
1842
|
+
: {};
|
|
1843
|
+
for (const [sessionKey, rawState] of Object.entries(restoredSessionStateByKey)) {
|
|
1844
|
+
const trimmedSessionKey = sessionKey.trim();
|
|
1845
|
+
if (!trimmedSessionKey) {
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
const restored = deserializeSessionState(rawState, cfg);
|
|
1849
|
+
if (!restored) {
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
sessionState.set(trimmedSessionKey, restored);
|
|
1853
|
+
}
|
|
1854
|
+
pruneStateMaps(sessionState);
|
|
1855
|
+
|
|
1856
|
+
const restoredRotationByKey =
|
|
1857
|
+
value.recentRotationBySession && typeof value.recentRotationBySession === "object"
|
|
1858
|
+
? value.recentRotationBySession
|
|
1859
|
+
: {};
|
|
1860
|
+
for (const [key, ts] of Object.entries(restoredRotationByKey)) {
|
|
1861
|
+
if (!key.trim()) {
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
recentRotationBySession.set(key, Math.floor(ts));
|
|
1868
|
+
}
|
|
1869
|
+
pruneRecentMap(recentRotationBySession, ROTATION_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
|
|
1870
|
+
|
|
1871
|
+
api.logger.info(
|
|
1872
|
+
`topic-shift-reset: restored state sessions=${sessionState.size} rotations=${recentRotationBySession.size}`,
|
|
1873
|
+
);
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
api.logger.warn(`topic-shift-reset: state restore failed err=${String(error)}`);
|
|
1876
|
+
}
|
|
1877
|
+
})();
|
|
1155
1878
|
|
|
1156
1879
|
let embeddingBackend: EmbeddingBackend | null = null;
|
|
1157
1880
|
let embeddingInitError: string | null = null;
|
|
@@ -1164,54 +1887,56 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1164
1887
|
if (embeddingInitError) {
|
|
1165
1888
|
api.logger.warn(`topic-shift-reset: embedding backend init failed: ${embeddingInitError}`);
|
|
1166
1889
|
} else if (!embeddingBackend) {
|
|
1167
|
-
api.logger.
|
|
1890
|
+
api.logger.info("topic-shift-reset: embedding backend unavailable, using lexical-only mode");
|
|
1168
1891
|
} else {
|
|
1169
1892
|
api.logger.info(`topic-shift-reset: embedding backend ${embeddingBackend.name}`);
|
|
1170
1893
|
}
|
|
1171
1894
|
|
|
1172
|
-
const
|
|
1895
|
+
const classifyAndMaybeRotateInner = async (params: {
|
|
1173
1896
|
source: "fast" | "fallback";
|
|
1174
1897
|
sessionKey: string;
|
|
1175
1898
|
text: string;
|
|
1176
1899
|
messageProvider?: string;
|
|
1177
1900
|
agentId?: string;
|
|
1178
|
-
dedupeKey?: string;
|
|
1179
1901
|
}) => {
|
|
1902
|
+
await persistenceLoadPromise;
|
|
1180
1903
|
if (!cfg.enabled) {
|
|
1181
1904
|
return;
|
|
1182
1905
|
}
|
|
1183
|
-
const sessionKey = params.sessionKey
|
|
1906
|
+
const sessionKey = params.sessionKey;
|
|
1184
1907
|
if (!sessionKey) {
|
|
1185
1908
|
return;
|
|
1186
1909
|
}
|
|
1187
1910
|
|
|
1188
|
-
const provider = params.messageProvider
|
|
1911
|
+
const provider = normalizeProviderId(params.messageProvider ?? "");
|
|
1189
1912
|
if (provider && cfg.ignoredProviders.has(provider)) {
|
|
1190
1913
|
return;
|
|
1191
1914
|
}
|
|
1915
|
+
if (isInternalNonUserProvider(provider)) {
|
|
1916
|
+
if (cfg.debug) {
|
|
1917
|
+
api.logger.debug(
|
|
1918
|
+
`topic-shift-reset: skip-internal-provider source=${params.source} provider=${provider} session=${sessionKey}`,
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1192
1923
|
|
|
1193
1924
|
const rawText = params.text.trim();
|
|
1194
|
-
const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText) : rawText;
|
|
1925
|
+
const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
|
|
1195
1926
|
if (!text || text.startsWith("/")) {
|
|
1196
1927
|
return;
|
|
1197
1928
|
}
|
|
1198
1929
|
|
|
1199
1930
|
const tokenList = tokenizeList(text, cfg.minTokenLength);
|
|
1200
|
-
|
|
1201
|
-
if (
|
|
1202
|
-
text.length < cfg.minSignalChars ||
|
|
1203
|
-
tokenList.length < cfg.minSignalTokenCount ||
|
|
1204
|
-
signalEntropy < cfg.minSignalEntropy
|
|
1205
|
-
) {
|
|
1931
|
+
if (text.length < cfg.minSignalChars || tokenList.length < cfg.minSignalTokenCount) {
|
|
1206
1932
|
if (cfg.debug) {
|
|
1207
|
-
api.logger.
|
|
1933
|
+
api.logger.debug(
|
|
1208
1934
|
[
|
|
1209
1935
|
`topic-shift-reset: skip-low-signal`,
|
|
1210
1936
|
`source=${params.source}`,
|
|
1211
1937
|
`session=${sessionKey}`,
|
|
1212
1938
|
`chars=${text.length}`,
|
|
1213
1939
|
`tokens=${tokenList.length}`,
|
|
1214
|
-
`entropy=${signalEntropy.toFixed(3)}`,
|
|
1215
1940
|
].join(" "),
|
|
1216
1941
|
);
|
|
1217
1942
|
}
|
|
@@ -1219,18 +1944,73 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1219
1944
|
}
|
|
1220
1945
|
|
|
1221
1946
|
const tokens = new Set(tokenList);
|
|
1222
|
-
|
|
1947
|
+
|
|
1948
|
+
const now = Date.now();
|
|
1949
|
+
const contentHash = hashString(normalizeTextForHash(text));
|
|
1950
|
+
const classifyDedupeKey = `${sessionKey}:${contentHash}`;
|
|
1951
|
+
const recentCrossSourceClassification = recentClassifiedBySessionHash.get(classifyDedupeKey);
|
|
1952
|
+
if (
|
|
1953
|
+
recentCrossSourceClassification &&
|
|
1954
|
+
recentCrossSourceClassification.source !== params.source &&
|
|
1955
|
+
now - recentCrossSourceClassification.at < CROSS_PATH_DEDUPE_MS
|
|
1956
|
+
) {
|
|
1957
|
+
if (cfg.debug) {
|
|
1958
|
+
api.logger.debug(
|
|
1959
|
+
[
|
|
1960
|
+
`topic-shift-reset: skip-cross-path-duplicate`,
|
|
1961
|
+
`session=${sessionKey}`,
|
|
1962
|
+
`source=${params.source}`,
|
|
1963
|
+
`prevSource=${recentCrossSourceClassification.source}`,
|
|
1964
|
+
].join(" "),
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1223
1967
|
return;
|
|
1224
1968
|
}
|
|
1969
|
+
recentClassifiedBySessionHash.set(classifyDedupeKey, { at: now, source: params.source });
|
|
1970
|
+
pruneRecentClassifiedMap(recentClassifiedBySessionHash, CROSS_PATH_DEDUPE_MS * 3, MAX_RECENT_FAST_EVENTS);
|
|
1225
1971
|
|
|
1226
|
-
const contentHash = hashString(normalizeTextForHash(text));
|
|
1227
1972
|
const lastRotationAt = recentRotationBySession.get(`${sessionKey}:${contentHash}`);
|
|
1228
|
-
if (typeof lastRotationAt === "number" &&
|
|
1973
|
+
if (typeof lastRotationAt === "number" && now - lastRotationAt < ROTATION_DEDUPE_MS) {
|
|
1229
1974
|
return;
|
|
1230
1975
|
}
|
|
1976
|
+
const state =
|
|
1977
|
+
sessionState.get(sessionKey) ??
|
|
1978
|
+
({
|
|
1979
|
+
history: [],
|
|
1980
|
+
pendingSoftSignals: 0,
|
|
1981
|
+
pendingEntries: [],
|
|
1982
|
+
lastResetAt: undefined,
|
|
1983
|
+
topicCentroid: undefined,
|
|
1984
|
+
topicCount: 0,
|
|
1985
|
+
topicDim: undefined,
|
|
1986
|
+
lastSeenAt: now,
|
|
1987
|
+
} satisfies SessionState);
|
|
1988
|
+
state.lastSeenAt = now;
|
|
1989
|
+
|
|
1990
|
+
const baselineTokens = unionTokens(state.history);
|
|
1991
|
+
const lexical = computeLexicalFeatures({
|
|
1992
|
+
cfg,
|
|
1993
|
+
entry: { tokens, at: now },
|
|
1994
|
+
tokenList,
|
|
1995
|
+
baselineTokens,
|
|
1996
|
+
});
|
|
1997
|
+
const warmup =
|
|
1998
|
+
state.history.length < cfg.minHistoryMessages || baselineTokens.size < cfg.minMeaningfulTokens;
|
|
1999
|
+
const cooldownMs = cfg.cooldownMinutes * 60_000;
|
|
2000
|
+
const cooldownActive =
|
|
2001
|
+
cooldownMs > 0 && typeof state.lastResetAt === "number" && now - state.lastResetAt < cooldownMs;
|
|
1231
2002
|
|
|
1232
2003
|
let embedding: number[] | undefined;
|
|
1233
|
-
if (
|
|
2004
|
+
if (
|
|
2005
|
+
shouldRequestEmbedding({
|
|
2006
|
+
cfg,
|
|
2007
|
+
backendAvailable: !!embeddingBackend,
|
|
2008
|
+
lexical,
|
|
2009
|
+
warmup,
|
|
2010
|
+
cooldownActive,
|
|
2011
|
+
}) &&
|
|
2012
|
+
embeddingBackend
|
|
2013
|
+
) {
|
|
1234
2014
|
try {
|
|
1235
2015
|
const vector = await embeddingBackend.embed(text);
|
|
1236
2016
|
if (Array.isArray(vector) && vector.length > 0) {
|
|
@@ -1241,23 +2021,23 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1241
2021
|
}
|
|
1242
2022
|
}
|
|
1243
2023
|
|
|
1244
|
-
const now = Date.now();
|
|
1245
|
-
const state =
|
|
1246
|
-
sessionState.get(sessionKey) ??
|
|
1247
|
-
({
|
|
1248
|
-
history: [],
|
|
1249
|
-
pendingSoftSignals: 0,
|
|
1250
|
-
pendingEntries: [],
|
|
1251
|
-
lastResetAt: undefined,
|
|
1252
|
-
lastSeenAt: now,
|
|
1253
|
-
} satisfies SessionState);
|
|
1254
|
-
state.lastSeenAt = now;
|
|
1255
|
-
|
|
1256
2024
|
const entry: HistoryEntry = { tokens, embedding, at: now };
|
|
1257
|
-
const
|
|
2025
|
+
const similarity =
|
|
2026
|
+
entry.embedding && state.topicCentroid
|
|
2027
|
+
? cosineSimilarity(entry.embedding, state.topicCentroid)
|
|
2028
|
+
: undefined;
|
|
2029
|
+
const decision = classifyMessage({
|
|
2030
|
+
cfg,
|
|
2031
|
+
state,
|
|
2032
|
+
baselineTokenCount: baselineTokens.size,
|
|
2033
|
+
lexical,
|
|
2034
|
+
similarity,
|
|
2035
|
+
usedEmbedding: typeof similarity === "number",
|
|
2036
|
+
now,
|
|
2037
|
+
});
|
|
1258
2038
|
|
|
1259
2039
|
if (cfg.debug) {
|
|
1260
|
-
api.logger.
|
|
2040
|
+
api.logger.debug(
|
|
1261
2041
|
[
|
|
1262
2042
|
`topic-shift-reset: classify`,
|
|
1263
2043
|
`source=${params.source}`,
|
|
@@ -1267,6 +2047,8 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1267
2047
|
`score=${decision.metrics.score.toFixed(3)}`,
|
|
1268
2048
|
`novelty=${decision.metrics.novelty.toFixed(3)}`,
|
|
1269
2049
|
`lex=${decision.metrics.lexicalDistance.toFixed(3)}`,
|
|
2050
|
+
`uniq=${decision.metrics.uniqueTokenRatio.toFixed(3)}`,
|
|
2051
|
+
`entropy=${decision.metrics.entropy.toFixed(3)}`,
|
|
1270
2052
|
`sim=${typeof decision.metrics.similarity === "number" ? decision.metrics.similarity.toFixed(3) : "n/a"}`,
|
|
1271
2053
|
`embed=${decision.metrics.usedEmbedding ? "1" : "0"}`,
|
|
1272
2054
|
`pending=${state.pendingSoftSignals}`,
|
|
@@ -1275,29 +2057,46 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1275
2057
|
}
|
|
1276
2058
|
|
|
1277
2059
|
if (decision.kind === "warmup") {
|
|
2060
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1278
2061
|
state.pendingSoftSignals = 0;
|
|
1279
2062
|
state.pendingEntries = [];
|
|
1280
2063
|
state.history = trimHistory([...state.history, entry], cfg.historyWindow);
|
|
2064
|
+
updateTopicCentroid(state, entry.embedding);
|
|
1281
2065
|
sessionState.set(sessionKey, state);
|
|
1282
2066
|
pruneStateMaps(sessionState);
|
|
2067
|
+
schedulePersistentFlush(false);
|
|
1283
2068
|
return;
|
|
1284
2069
|
}
|
|
1285
2070
|
|
|
1286
2071
|
if (decision.kind === "stable") {
|
|
2072
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
1287
2073
|
const merged = [...state.history, ...state.pendingEntries, entry];
|
|
2074
|
+
for (const item of [...state.pendingEntries, entry]) {
|
|
2075
|
+
updateTopicCentroid(state, item.embedding);
|
|
2076
|
+
}
|
|
1288
2077
|
state.pendingSoftSignals = 0;
|
|
1289
2078
|
state.pendingEntries = [];
|
|
1290
2079
|
state.history = trimHistory(merged, cfg.historyWindow);
|
|
1291
2080
|
sessionState.set(sessionKey, state);
|
|
1292
2081
|
pruneStateMaps(sessionState);
|
|
2082
|
+
schedulePersistentFlush(false);
|
|
1293
2083
|
return;
|
|
1294
2084
|
}
|
|
1295
2085
|
|
|
1296
2086
|
if (decision.kind === "suspect") {
|
|
2087
|
+
if (cfg.softSuspect.action === "ask") {
|
|
2088
|
+
pendingSoftSuspectSteeringBySession.set(sessionKey, now);
|
|
2089
|
+
pruneRecentMap(
|
|
2090
|
+
pendingSoftSuspectSteeringBySession,
|
|
2091
|
+
Math.max(cfg.softSuspect.ttlMs * 2, 60_000),
|
|
2092
|
+
MAX_RECENT_FAST_EVENTS,
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
1297
2095
|
state.pendingSoftSignals += 1;
|
|
1298
2096
|
state.pendingEntries = trimHistory([...state.pendingEntries, entry], cfg.softConsecutiveSignals);
|
|
1299
2097
|
sessionState.set(sessionKey, state);
|
|
1300
2098
|
pruneStateMaps(sessionState);
|
|
2099
|
+
schedulePersistentFlush(false);
|
|
1301
2100
|
return;
|
|
1302
2101
|
}
|
|
1303
2102
|
|
|
@@ -1318,12 +2117,42 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1318
2117
|
});
|
|
1319
2118
|
|
|
1320
2119
|
if (rotated) {
|
|
1321
|
-
|
|
2120
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
2121
|
+
if (!cfg.dryRun) {
|
|
2122
|
+
recentRotationBySession.set(`${sessionKey}:${contentHash}`, Date.now());
|
|
2123
|
+
}
|
|
2124
|
+
schedulePersistentFlush(true);
|
|
1322
2125
|
}
|
|
1323
2126
|
|
|
1324
2127
|
sessionState.set(sessionKey, state);
|
|
1325
2128
|
pruneStateMaps(sessionState);
|
|
1326
|
-
|
|
2129
|
+
const prunedRotations = pruneRecentMap(
|
|
2130
|
+
recentRotationBySession,
|
|
2131
|
+
ROTATION_DEDUPE_MS * 3,
|
|
2132
|
+
MAX_RECENT_FAST_EVENTS,
|
|
2133
|
+
);
|
|
2134
|
+
if (prunedRotations) {
|
|
2135
|
+
schedulePersistentFlush(false);
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
const classifyAndMaybeRotate = async (params: {
|
|
2140
|
+
source: "fast" | "fallback";
|
|
2141
|
+
sessionKey: string;
|
|
2142
|
+
text: string;
|
|
2143
|
+
messageProvider?: string;
|
|
2144
|
+
agentId?: string;
|
|
2145
|
+
}) => {
|
|
2146
|
+
const sessionKey = params.sessionKey.trim();
|
|
2147
|
+
if (!sessionKey) {
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
await runSerializedBySession(sessionKey, async () => {
|
|
2151
|
+
await classifyAndMaybeRotateInner({
|
|
2152
|
+
...params,
|
|
2153
|
+
sessionKey,
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
1327
2156
|
};
|
|
1328
2157
|
|
|
1329
2158
|
api.on("message_received", async (event, ctx) => {
|
|
@@ -1355,7 +2184,8 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1355
2184
|
recentFastEvents.set(fastEventKey, Date.now());
|
|
1356
2185
|
pruneRecentMap(recentFastEvents, FAST_EVENT_TTL_MS, MAX_RECENT_FAST_EVENTS);
|
|
1357
2186
|
|
|
1358
|
-
let
|
|
2187
|
+
let resolvedSessionKey = "";
|
|
2188
|
+
let resolvedAgentId: string | undefined;
|
|
1359
2189
|
try {
|
|
1360
2190
|
const route = api.runtime.channel.routing.resolveAgentRoute({
|
|
1361
2191
|
cfg: api.config,
|
|
@@ -1363,13 +2193,11 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1363
2193
|
accountId: ctx.accountId,
|
|
1364
2194
|
peer,
|
|
1365
2195
|
});
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
routeKind: peer.kind,
|
|
1369
|
-
};
|
|
2196
|
+
resolvedSessionKey = route.sessionKey;
|
|
2197
|
+
resolvedAgentId = route.agentId;
|
|
1370
2198
|
} catch (error) {
|
|
1371
2199
|
if (cfg.debug) {
|
|
1372
|
-
api.logger.
|
|
2200
|
+
api.logger.debug(
|
|
1373
2201
|
`topic-shift-reset: fast-route-skip channel=${channelId} peer=${maybeJson(peer)} err=${String(error)}`,
|
|
1374
2202
|
);
|
|
1375
2203
|
}
|
|
@@ -1378,10 +2206,10 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1378
2206
|
|
|
1379
2207
|
await classifyAndMaybeRotate({
|
|
1380
2208
|
source: "fast",
|
|
1381
|
-
sessionKey:
|
|
2209
|
+
sessionKey: resolvedSessionKey,
|
|
1382
2210
|
text,
|
|
1383
2211
|
messageProvider: channelId,
|
|
1384
|
-
|
|
2212
|
+
agentId: resolvedAgentId,
|
|
1385
2213
|
});
|
|
1386
2214
|
});
|
|
1387
2215
|
|
|
@@ -1402,4 +2230,29 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1402
2230
|
agentId: ctx.agentId,
|
|
1403
2231
|
});
|
|
1404
2232
|
});
|
|
2233
|
+
|
|
2234
|
+
api.on("before_prompt_build", async (_event, ctx) => {
|
|
2235
|
+
if (!cfg.enabled || cfg.softSuspect.action !== "ask") {
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const sessionKey = ctx.sessionKey?.trim();
|
|
2239
|
+
if (!sessionKey) {
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
const seenAt = pendingSoftSuspectSteeringBySession.get(sessionKey);
|
|
2243
|
+
if (typeof seenAt !== "number") {
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
if (Date.now() - seenAt > cfg.softSuspect.ttlMs) {
|
|
2247
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
2251
|
+
return { prependContext: cfg.softSuspect.prompt };
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
api.on("gateway_stop", async () => {
|
|
2255
|
+
clearPersistenceTimer();
|
|
2256
|
+
await flushPersistentState("gateway-stop");
|
|
2257
|
+
});
|
|
1405
2258
|
}
|