openclaw-topic-shift-reset 0.4.3 → 0.4.5
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 +8 -1
- package/package.json +1 -1
- package/src/index.ts +193 -7
package/README.md
CHANGED
|
@@ -147,10 +147,13 @@ All plugin logs are prefixed with `topic-shift-reset:`.
|
|
|
147
147
|
No embedding backend is available; lexical signals only.
|
|
148
148
|
- `restored state sessions=<n> rotations=<n>`
|
|
149
149
|
Persisted runtime state restored at startup.
|
|
150
|
+
- `orphan-recovery recovered=<n> store=<...>`
|
|
151
|
+
Re-linked legacy orphan transcript files (`*.jsonl`) back into session-store entries.
|
|
150
152
|
- `would-rotate source=<user|agent> reason=<...> session=<...> ...`
|
|
151
153
|
Dry-run rotation decision; no session mutation is written.
|
|
152
|
-
- `rotated source=<user|agent> reason=<...> session=<...> ... handoff=<0|1>`
|
|
154
|
+
- `rotated source=<user|agent> reason=<...> session=<...> ... handoff=<0|1> archived=<0|1>`
|
|
153
155
|
Rotation executed (new `sessionId` written). `handoff=1` means handoff context was enqueued.
|
|
156
|
+
`archived=1` means the previous transcript file was archived as `.reset.<timestamp>`.
|
|
154
157
|
|
|
155
158
|
### Debug (`debug: true`)
|
|
156
159
|
|
|
@@ -183,6 +186,10 @@ All plugin logs are prefixed with `topic-shift-reset:`.
|
|
|
183
186
|
Tail read optimization fell back to a full transcript read.
|
|
184
187
|
- `handoff read failed file=<...> err=<...>`
|
|
185
188
|
Could not read prior session transcript for handoff injection.
|
|
189
|
+
- `reset archive failed file=<...> err=<...>`
|
|
190
|
+
Could not archive the prior session transcript after rotation.
|
|
191
|
+
- `orphan-recovery failed store=<...> err=<...>`
|
|
192
|
+
Legacy orphan recovery scan failed for this session store.
|
|
186
193
|
- `persistence disabled (state path): <err>`
|
|
187
194
|
Plugin could not resolve state path; persistence is disabled.
|
|
188
195
|
- `state flush failed err=<...>`
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1286,20 +1286,29 @@ function classifyMessage(params: {
|
|
|
1286
1286
|
params.lexical.score >= cfg.hardScoreThreshold ||
|
|
1287
1287
|
(params.lexical.novelty >= cfg.hardNoveltyThreshold &&
|
|
1288
1288
|
params.lexical.lexicalDistance >= 0.65);
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
(params.similarity <= cfg.hardSimilarityThreshold &&
|
|
1293
|
-
params.lexical.novelty >= cfg.hardNoveltyThreshold));
|
|
1289
|
+
// Require a strong combined score for immediate hard-rotate.
|
|
1290
|
+
// Similarity+novelty spikes go through soft confirmation instead.
|
|
1291
|
+
const hardSignal = hasSimilarity && score >= cfg.hardScoreThreshold;
|
|
1294
1292
|
|
|
1295
1293
|
if (hardSignal) {
|
|
1296
1294
|
return { kind: "rotate-hard", metrics, reason: "hard-threshold" };
|
|
1297
1295
|
}
|
|
1298
1296
|
|
|
1299
1297
|
const forceSoftPathFromLexicalHard = !hasSimilarity && lexicalHardSignal;
|
|
1298
|
+
const forceSoftPathFromHardSimilarity =
|
|
1299
|
+
hasSimilarity &&
|
|
1300
|
+
params.similarity <= cfg.hardSimilarityThreshold &&
|
|
1301
|
+
params.lexical.novelty >= cfg.hardNoveltyThreshold;
|
|
1302
|
+
const embeddingLexicalOverrideSoftSignal =
|
|
1303
|
+
hasSimilarity &&
|
|
1304
|
+
params.lexical.lexicalDistance >= 0.9 &&
|
|
1305
|
+
params.lexical.novelty >= cfg.softNoveltyThreshold &&
|
|
1306
|
+
score >= cfg.softScoreThreshold - cfg.embeddingTriggerMargin;
|
|
1300
1307
|
const softSignal =
|
|
1301
1308
|
forceSoftPathFromLexicalHard ||
|
|
1309
|
+
forceSoftPathFromHardSimilarity ||
|
|
1302
1310
|
score >= cfg.softScoreThreshold ||
|
|
1311
|
+
embeddingLexicalOverrideSoftSignal ||
|
|
1303
1312
|
(hasSimilarity
|
|
1304
1313
|
? params.similarity <= cfg.softSimilarityThreshold &&
|
|
1305
1314
|
params.lexical.novelty >= cfg.softNoveltyThreshold
|
|
@@ -1364,6 +1373,132 @@ function resolveSessionFilePathFromEntry(params: {
|
|
|
1364
1373
|
return path.resolve(sessionsDir, `${sessionId}.jsonl`);
|
|
1365
1374
|
}
|
|
1366
1375
|
|
|
1376
|
+
function formatSessionArchiveTimestamp(nowMs = Date.now()): string {
|
|
1377
|
+
return new Date(nowMs).toISOString().replaceAll(":", "-");
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
async function archivePreviousSessionTranscript(params: {
|
|
1381
|
+
storePath: string;
|
|
1382
|
+
previousEntry?: SessionEntryLike;
|
|
1383
|
+
logger: OpenClawPluginApi["logger"];
|
|
1384
|
+
}): Promise<string | null> {
|
|
1385
|
+
const sessionFile = resolveSessionFilePathFromEntry({
|
|
1386
|
+
storePath: params.storePath,
|
|
1387
|
+
entry: params.previousEntry,
|
|
1388
|
+
});
|
|
1389
|
+
if (!sessionFile) {
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const archivedPath = `${sessionFile}.reset.${formatSessionArchiveTimestamp()}`;
|
|
1394
|
+
try {
|
|
1395
|
+
await fs.rename(sessionFile, archivedPath);
|
|
1396
|
+
return archivedPath;
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
const code =
|
|
1399
|
+
error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
1400
|
+
if (code === "ENOENT") {
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
params.logger.warn(`topic-shift-reset: reset archive failed file=${sessionFile} err=${String(error)}`);
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function normalizeRecoveryAgentId(agentId?: string): string {
|
|
1409
|
+
const trimmed = typeof agentId === "string" ? agentId.trim() : "";
|
|
1410
|
+
if (!trimmed) {
|
|
1411
|
+
return "main";
|
|
1412
|
+
}
|
|
1413
|
+
return trimmed.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
async function recoverLegacyOrphanedSessionEntries(params: {
|
|
1417
|
+
storePath: string;
|
|
1418
|
+
agentId?: string;
|
|
1419
|
+
logger: OpenClawPluginApi["logger"];
|
|
1420
|
+
}): Promise<number> {
|
|
1421
|
+
let recovered = 0;
|
|
1422
|
+
await withFileLock(params.storePath, LOCK_OPTIONS, async () => {
|
|
1423
|
+
const loaded = await readJsonFileWithFallback<Record<string, SessionEntryLike>>(params.storePath, {});
|
|
1424
|
+
const store = loaded.value;
|
|
1425
|
+
const sessionsDir = path.dirname(params.storePath);
|
|
1426
|
+
const entries = await fs.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
|
1427
|
+
if (entries.length === 0) {
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const referencedSessionIds = new Set<string>();
|
|
1432
|
+
const referencedFiles = new Set<string>();
|
|
1433
|
+
for (const entry of Object.values(store)) {
|
|
1434
|
+
if (!entry || typeof entry !== "object") {
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId.trim() : "";
|
|
1438
|
+
if (sessionId) {
|
|
1439
|
+
referencedSessionIds.add(sessionId);
|
|
1440
|
+
}
|
|
1441
|
+
const sessionFile = resolveSessionFilePathFromEntry({
|
|
1442
|
+
storePath: params.storePath,
|
|
1443
|
+
entry,
|
|
1444
|
+
});
|
|
1445
|
+
if (sessionFile) {
|
|
1446
|
+
referencedFiles.add(path.resolve(sessionFile));
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
let changed = false;
|
|
1451
|
+
const recoveryAgentId = normalizeRecoveryAgentId(params.agentId);
|
|
1452
|
+
for (const candidate of entries) {
|
|
1453
|
+
if (!candidate.isFile()) {
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
const fileName = candidate.name;
|
|
1457
|
+
if (!fileName.endsWith(".jsonl")) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
const fullPath = path.resolve(sessionsDir, fileName);
|
|
1461
|
+
if (referencedFiles.has(fullPath)) {
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const sessionId = fileName.slice(0, -".jsonl".length).trim();
|
|
1466
|
+
if (!sessionId || referencedSessionIds.has(sessionId)) {
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
1471
|
+
if (!stat || !stat.isFile()) {
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const baseKey = `agent:${recoveryAgentId}:recovered:${sessionId}`;
|
|
1476
|
+
let recoveryKey = baseKey;
|
|
1477
|
+
let suffix = 2;
|
|
1478
|
+
while (Object.prototype.hasOwnProperty.call(store, recoveryKey)) {
|
|
1479
|
+
recoveryKey = `${baseKey}:${suffix}`;
|
|
1480
|
+
suffix += 1;
|
|
1481
|
+
}
|
|
1482
|
+
store[recoveryKey] = {
|
|
1483
|
+
sessionId,
|
|
1484
|
+
updatedAt: Math.max(0, Math.floor(stat.mtimeMs)),
|
|
1485
|
+
sessionFile: fileName,
|
|
1486
|
+
systemSent: false,
|
|
1487
|
+
abortedLastRun: false,
|
|
1488
|
+
};
|
|
1489
|
+
referencedSessionIds.add(sessionId);
|
|
1490
|
+
referencedFiles.add(fullPath);
|
|
1491
|
+
recovered += 1;
|
|
1492
|
+
changed = true;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if (changed) {
|
|
1496
|
+
await writeJsonFileAtomically(params.storePath, store);
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
return recovered;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1367
1502
|
function parseTranscriptTailLines(lines: string[], takeLast: number): TranscriptMessage[] {
|
|
1368
1503
|
const messages: TranscriptMessage[] = [];
|
|
1369
1504
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
@@ -1663,12 +1798,25 @@ async function rotateSessionEntry(params: {
|
|
|
1663
1798
|
contextKey: `topic-shift-reset:${params.contentHash}`,
|
|
1664
1799
|
});
|
|
1665
1800
|
}
|
|
1801
|
+
const archivedTranscript = await archivePreviousSessionTranscript({
|
|
1802
|
+
storePath,
|
|
1803
|
+
previousEntry,
|
|
1804
|
+
logger: params.api.logger,
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
const seededHistory =
|
|
1808
|
+
params.reason === "soft-confirmed"
|
|
1809
|
+
? trimHistory([...params.state.pendingEntries, params.entry], params.cfg.historyWindow)
|
|
1810
|
+
: trimHistory([params.entry], params.cfg.historyWindow);
|
|
1666
1811
|
|
|
1667
1812
|
params.state.lastResetAt = Date.now();
|
|
1668
1813
|
params.state.pendingSoftSignals = 0;
|
|
1669
1814
|
params.state.pendingEntries = [];
|
|
1670
|
-
params.state.history =
|
|
1671
|
-
seedTopicCentroid(params.state,
|
|
1815
|
+
params.state.history = seededHistory;
|
|
1816
|
+
seedTopicCentroid(params.state, undefined);
|
|
1817
|
+
for (const historyEntry of seededHistory) {
|
|
1818
|
+
updateTopicCentroid(params.state, historyEntry.embedding);
|
|
1819
|
+
}
|
|
1672
1820
|
|
|
1673
1821
|
params.api.logger.info(
|
|
1674
1822
|
[
|
|
@@ -1681,6 +1829,7 @@ async function rotateSessionEntry(params: {
|
|
|
1681
1829
|
`lex=${params.metrics.lexicalDistance.toFixed(3)}`,
|
|
1682
1830
|
`sim=${typeof params.metrics.similarity === "number" ? params.metrics.similarity.toFixed(3) : "n/a"}`,
|
|
1683
1831
|
`handoff=${handoff ? "1" : "0"}`,
|
|
1832
|
+
`archived=${archivedTranscript ? "1" : "0"}`,
|
|
1684
1833
|
].join(" "),
|
|
1685
1834
|
);
|
|
1686
1835
|
|
|
@@ -1696,6 +1845,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1696
1845
|
const pendingSoftSuspectSteeringBySession = new Map<string, number>();
|
|
1697
1846
|
const awaitingSoftSuspectReplyBySession = new Map<string, number>();
|
|
1698
1847
|
const sessionWorkQueue = new Map<string, Promise<unknown>>();
|
|
1848
|
+
const orphanRecoveryByStorePath = new Map<string, Promise<void>>();
|
|
1699
1849
|
|
|
1700
1850
|
const clearSoftSuspectSteerState = (sessionKey: string) => {
|
|
1701
1851
|
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
@@ -1920,6 +2070,42 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1920
2070
|
return;
|
|
1921
2071
|
}
|
|
1922
2072
|
|
|
2073
|
+
const ensureLegacyOrphanRecovery = async () => {
|
|
2074
|
+
if (cfg.dryRun) {
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
const storePath = api.runtime.channel.session.resolveStorePath(api.config.session?.store, {
|
|
2078
|
+
agentId: params.agentId,
|
|
2079
|
+
});
|
|
2080
|
+
const normalizedStorePath = path.resolve(storePath);
|
|
2081
|
+
const existing = orphanRecoveryByStorePath.get(normalizedStorePath);
|
|
2082
|
+
if (existing) {
|
|
2083
|
+
await existing;
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const pending = (async () => {
|
|
2087
|
+
try {
|
|
2088
|
+
const recovered = await recoverLegacyOrphanedSessionEntries({
|
|
2089
|
+
storePath: normalizedStorePath,
|
|
2090
|
+
agentId: params.agentId,
|
|
2091
|
+
logger: api.logger,
|
|
2092
|
+
});
|
|
2093
|
+
if (recovered > 0) {
|
|
2094
|
+
api.logger.info(
|
|
2095
|
+
`topic-shift-reset: orphan-recovery recovered=${recovered} store=${normalizedStorePath}`,
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
} catch (error) {
|
|
2099
|
+
api.logger.warn(
|
|
2100
|
+
`topic-shift-reset: orphan-recovery failed store=${normalizedStorePath} err=${String(error)}`,
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
})();
|
|
2104
|
+
orphanRecoveryByStorePath.set(normalizedStorePath, pending);
|
|
2105
|
+
await pending;
|
|
2106
|
+
};
|
|
2107
|
+
await ensureLegacyOrphanRecovery();
|
|
2108
|
+
|
|
1923
2109
|
const rawText = params.text.trim();
|
|
1924
2110
|
const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
|
|
1925
2111
|
if (!text || text.startsWith("/")) {
|