openclaw-topic-shift-reset 0.4.2 → 0.4.4
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 +182 -8
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
|
@@ -1252,8 +1252,9 @@ function classifyMessage(params: {
|
|
|
1252
1252
|
now: number;
|
|
1253
1253
|
}): ClassificationDecision {
|
|
1254
1254
|
const { cfg, state, now } = params;
|
|
1255
|
+
const hasSimilarity = params.usedEmbedding && typeof params.similarity === "number";
|
|
1255
1256
|
const score =
|
|
1256
|
-
|
|
1257
|
+
hasSimilarity
|
|
1257
1258
|
? 0.7 * (1 - params.similarity) +
|
|
1258
1259
|
0.15 * params.lexical.lexicalDistance +
|
|
1259
1260
|
0.15 * params.lexical.novelty
|
|
@@ -1281,21 +1282,25 @@ function classifyMessage(params: {
|
|
|
1281
1282
|
return { kind: "stable", metrics, reason: "cooldown" };
|
|
1282
1283
|
}
|
|
1283
1284
|
|
|
1285
|
+
const lexicalHardSignal =
|
|
1286
|
+
params.lexical.score >= cfg.hardScoreThreshold ||
|
|
1287
|
+
(params.lexical.novelty >= cfg.hardNoveltyThreshold &&
|
|
1288
|
+
params.lexical.lexicalDistance >= 0.65);
|
|
1284
1289
|
const hardSignal =
|
|
1285
|
-
|
|
1286
|
-
(
|
|
1287
|
-
|
|
1288
|
-
params.lexical.novelty >= cfg.hardNoveltyThreshold
|
|
1289
|
-
: params.lexical.novelty >= cfg.hardNoveltyThreshold &&
|
|
1290
|
-
params.lexical.lexicalDistance >= 0.65);
|
|
1290
|
+
hasSimilarity &&
|
|
1291
|
+
(score >= cfg.hardScoreThreshold ||
|
|
1292
|
+
(params.similarity <= cfg.hardSimilarityThreshold &&
|
|
1293
|
+
params.lexical.novelty >= cfg.hardNoveltyThreshold));
|
|
1291
1294
|
|
|
1292
1295
|
if (hardSignal) {
|
|
1293
1296
|
return { kind: "rotate-hard", metrics, reason: "hard-threshold" };
|
|
1294
1297
|
}
|
|
1295
1298
|
|
|
1299
|
+
const forceSoftPathFromLexicalHard = !hasSimilarity && lexicalHardSignal;
|
|
1296
1300
|
const softSignal =
|
|
1301
|
+
forceSoftPathFromLexicalHard ||
|
|
1297
1302
|
score >= cfg.softScoreThreshold ||
|
|
1298
|
-
(
|
|
1303
|
+
(hasSimilarity
|
|
1299
1304
|
? params.similarity <= cfg.softSimilarityThreshold &&
|
|
1300
1305
|
params.lexical.novelty >= cfg.softNoveltyThreshold
|
|
1301
1306
|
: params.lexical.novelty >= cfg.softNoveltyThreshold &&
|
|
@@ -1359,6 +1364,132 @@ function resolveSessionFilePathFromEntry(params: {
|
|
|
1359
1364
|
return path.resolve(sessionsDir, `${sessionId}.jsonl`);
|
|
1360
1365
|
}
|
|
1361
1366
|
|
|
1367
|
+
function formatSessionArchiveTimestamp(nowMs = Date.now()): string {
|
|
1368
|
+
return new Date(nowMs).toISOString().replaceAll(":", "-");
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function archivePreviousSessionTranscript(params: {
|
|
1372
|
+
storePath: string;
|
|
1373
|
+
previousEntry?: SessionEntryLike;
|
|
1374
|
+
logger: OpenClawPluginApi["logger"];
|
|
1375
|
+
}): Promise<string | null> {
|
|
1376
|
+
const sessionFile = resolveSessionFilePathFromEntry({
|
|
1377
|
+
storePath: params.storePath,
|
|
1378
|
+
entry: params.previousEntry,
|
|
1379
|
+
});
|
|
1380
|
+
if (!sessionFile) {
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const archivedPath = `${sessionFile}.reset.${formatSessionArchiveTimestamp()}`;
|
|
1385
|
+
try {
|
|
1386
|
+
await fs.rename(sessionFile, archivedPath);
|
|
1387
|
+
return archivedPath;
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
const code =
|
|
1390
|
+
error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
1391
|
+
if (code === "ENOENT") {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
params.logger.warn(`topic-shift-reset: reset archive failed file=${sessionFile} err=${String(error)}`);
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function normalizeRecoveryAgentId(agentId?: string): string {
|
|
1400
|
+
const trimmed = typeof agentId === "string" ? agentId.trim() : "";
|
|
1401
|
+
if (!trimmed) {
|
|
1402
|
+
return "main";
|
|
1403
|
+
}
|
|
1404
|
+
return trimmed.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async function recoverLegacyOrphanedSessionEntries(params: {
|
|
1408
|
+
storePath: string;
|
|
1409
|
+
agentId?: string;
|
|
1410
|
+
logger: OpenClawPluginApi["logger"];
|
|
1411
|
+
}): Promise<number> {
|
|
1412
|
+
let recovered = 0;
|
|
1413
|
+
await withFileLock(params.storePath, LOCK_OPTIONS, async () => {
|
|
1414
|
+
const loaded = await readJsonFileWithFallback<Record<string, SessionEntryLike>>(params.storePath, {});
|
|
1415
|
+
const store = loaded.value;
|
|
1416
|
+
const sessionsDir = path.dirname(params.storePath);
|
|
1417
|
+
const entries = await fs.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
|
1418
|
+
if (entries.length === 0) {
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const referencedSessionIds = new Set<string>();
|
|
1423
|
+
const referencedFiles = new Set<string>();
|
|
1424
|
+
for (const entry of Object.values(store)) {
|
|
1425
|
+
if (!entry || typeof entry !== "object") {
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId.trim() : "";
|
|
1429
|
+
if (sessionId) {
|
|
1430
|
+
referencedSessionIds.add(sessionId);
|
|
1431
|
+
}
|
|
1432
|
+
const sessionFile = resolveSessionFilePathFromEntry({
|
|
1433
|
+
storePath: params.storePath,
|
|
1434
|
+
entry,
|
|
1435
|
+
});
|
|
1436
|
+
if (sessionFile) {
|
|
1437
|
+
referencedFiles.add(path.resolve(sessionFile));
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
let changed = false;
|
|
1442
|
+
const recoveryAgentId = normalizeRecoveryAgentId(params.agentId);
|
|
1443
|
+
for (const candidate of entries) {
|
|
1444
|
+
if (!candidate.isFile()) {
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
const fileName = candidate.name;
|
|
1448
|
+
if (!fileName.endsWith(".jsonl")) {
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
const fullPath = path.resolve(sessionsDir, fileName);
|
|
1452
|
+
if (referencedFiles.has(fullPath)) {
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const sessionId = fileName.slice(0, -".jsonl".length).trim();
|
|
1457
|
+
if (!sessionId || referencedSessionIds.has(sessionId)) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
1462
|
+
if (!stat || !stat.isFile()) {
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const baseKey = `agent:${recoveryAgentId}:recovered:${sessionId}`;
|
|
1467
|
+
let recoveryKey = baseKey;
|
|
1468
|
+
let suffix = 2;
|
|
1469
|
+
while (Object.prototype.hasOwnProperty.call(store, recoveryKey)) {
|
|
1470
|
+
recoveryKey = `${baseKey}:${suffix}`;
|
|
1471
|
+
suffix += 1;
|
|
1472
|
+
}
|
|
1473
|
+
store[recoveryKey] = {
|
|
1474
|
+
sessionId,
|
|
1475
|
+
updatedAt: Math.max(0, Math.floor(stat.mtimeMs)),
|
|
1476
|
+
sessionFile: fileName,
|
|
1477
|
+
systemSent: false,
|
|
1478
|
+
abortedLastRun: false,
|
|
1479
|
+
};
|
|
1480
|
+
referencedSessionIds.add(sessionId);
|
|
1481
|
+
referencedFiles.add(fullPath);
|
|
1482
|
+
recovered += 1;
|
|
1483
|
+
changed = true;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (changed) {
|
|
1487
|
+
await writeJsonFileAtomically(params.storePath, store);
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
return recovered;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1362
1493
|
function parseTranscriptTailLines(lines: string[], takeLast: number): TranscriptMessage[] {
|
|
1363
1494
|
const messages: TranscriptMessage[] = [];
|
|
1364
1495
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
@@ -1658,6 +1789,11 @@ async function rotateSessionEntry(params: {
|
|
|
1658
1789
|
contextKey: `topic-shift-reset:${params.contentHash}`,
|
|
1659
1790
|
});
|
|
1660
1791
|
}
|
|
1792
|
+
const archivedTranscript = await archivePreviousSessionTranscript({
|
|
1793
|
+
storePath,
|
|
1794
|
+
previousEntry,
|
|
1795
|
+
logger: params.api.logger,
|
|
1796
|
+
});
|
|
1661
1797
|
|
|
1662
1798
|
params.state.lastResetAt = Date.now();
|
|
1663
1799
|
params.state.pendingSoftSignals = 0;
|
|
@@ -1676,6 +1812,7 @@ async function rotateSessionEntry(params: {
|
|
|
1676
1812
|
`lex=${params.metrics.lexicalDistance.toFixed(3)}`,
|
|
1677
1813
|
`sim=${typeof params.metrics.similarity === "number" ? params.metrics.similarity.toFixed(3) : "n/a"}`,
|
|
1678
1814
|
`handoff=${handoff ? "1" : "0"}`,
|
|
1815
|
+
`archived=${archivedTranscript ? "1" : "0"}`,
|
|
1679
1816
|
].join(" "),
|
|
1680
1817
|
);
|
|
1681
1818
|
|
|
@@ -1691,6 +1828,7 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1691
1828
|
const pendingSoftSuspectSteeringBySession = new Map<string, number>();
|
|
1692
1829
|
const awaitingSoftSuspectReplyBySession = new Map<string, number>();
|
|
1693
1830
|
const sessionWorkQueue = new Map<string, Promise<unknown>>();
|
|
1831
|
+
const orphanRecoveryByStorePath = new Map<string, Promise<void>>();
|
|
1694
1832
|
|
|
1695
1833
|
const clearSoftSuspectSteerState = (sessionKey: string) => {
|
|
1696
1834
|
pendingSoftSuspectSteeringBySession.delete(sessionKey);
|
|
@@ -1915,6 +2053,42 @@ export default function register(api: OpenClawPluginApi): void {
|
|
|
1915
2053
|
return;
|
|
1916
2054
|
}
|
|
1917
2055
|
|
|
2056
|
+
const ensureLegacyOrphanRecovery = async () => {
|
|
2057
|
+
if (cfg.dryRun) {
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
const storePath = api.runtime.channel.session.resolveStorePath(api.config.session?.store, {
|
|
2061
|
+
agentId: params.agentId,
|
|
2062
|
+
});
|
|
2063
|
+
const normalizedStorePath = path.resolve(storePath);
|
|
2064
|
+
const existing = orphanRecoveryByStorePath.get(normalizedStorePath);
|
|
2065
|
+
if (existing) {
|
|
2066
|
+
await existing;
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
const pending = (async () => {
|
|
2070
|
+
try {
|
|
2071
|
+
const recovered = await recoverLegacyOrphanedSessionEntries({
|
|
2072
|
+
storePath: normalizedStorePath,
|
|
2073
|
+
agentId: params.agentId,
|
|
2074
|
+
logger: api.logger,
|
|
2075
|
+
});
|
|
2076
|
+
if (recovered > 0) {
|
|
2077
|
+
api.logger.info(
|
|
2078
|
+
`topic-shift-reset: orphan-recovery recovered=${recovered} store=${normalizedStorePath}`,
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
} catch (error) {
|
|
2082
|
+
api.logger.warn(
|
|
2083
|
+
`topic-shift-reset: orphan-recovery failed store=${normalizedStorePath} err=${String(error)}`,
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
})();
|
|
2087
|
+
orphanRecoveryByStorePath.set(normalizedStorePath, pending);
|
|
2088
|
+
await pending;
|
|
2089
|
+
};
|
|
2090
|
+
await ensureLegacyOrphanRecovery();
|
|
2091
|
+
|
|
1918
2092
|
const rawText = params.text.trim();
|
|
1919
2093
|
const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
|
|
1920
2094
|
if (!text || text.startsWith("/")) {
|