openclaw-topic-shift-reset 0.4.3 → 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.
Files changed (3) hide show
  1. package/README.md +8 -1
  2. package/package.json +1 -1
  3. package/src/index.ts +169 -0
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-topic-shift-reset",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "OpenClaw plugin that detects topic shifts and starts a fresh session automatically.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/index.ts CHANGED
@@ -1364,6 +1364,132 @@ function resolveSessionFilePathFromEntry(params: {
1364
1364
  return path.resolve(sessionsDir, `${sessionId}.jsonl`);
1365
1365
  }
1366
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
+
1367
1493
  function parseTranscriptTailLines(lines: string[], takeLast: number): TranscriptMessage[] {
1368
1494
  const messages: TranscriptMessage[] = [];
1369
1495
  for (let i = lines.length - 1; i >= 0; i -= 1) {
@@ -1663,6 +1789,11 @@ async function rotateSessionEntry(params: {
1663
1789
  contextKey: `topic-shift-reset:${params.contentHash}`,
1664
1790
  });
1665
1791
  }
1792
+ const archivedTranscript = await archivePreviousSessionTranscript({
1793
+ storePath,
1794
+ previousEntry,
1795
+ logger: params.api.logger,
1796
+ });
1666
1797
 
1667
1798
  params.state.lastResetAt = Date.now();
1668
1799
  params.state.pendingSoftSignals = 0;
@@ -1681,6 +1812,7 @@ async function rotateSessionEntry(params: {
1681
1812
  `lex=${params.metrics.lexicalDistance.toFixed(3)}`,
1682
1813
  `sim=${typeof params.metrics.similarity === "number" ? params.metrics.similarity.toFixed(3) : "n/a"}`,
1683
1814
  `handoff=${handoff ? "1" : "0"}`,
1815
+ `archived=${archivedTranscript ? "1" : "0"}`,
1684
1816
  ].join(" "),
1685
1817
  );
1686
1818
 
@@ -1696,6 +1828,7 @@ export default function register(api: OpenClawPluginApi): void {
1696
1828
  const pendingSoftSuspectSteeringBySession = new Map<string, number>();
1697
1829
  const awaitingSoftSuspectReplyBySession = new Map<string, number>();
1698
1830
  const sessionWorkQueue = new Map<string, Promise<unknown>>();
1831
+ const orphanRecoveryByStorePath = new Map<string, Promise<void>>();
1699
1832
 
1700
1833
  const clearSoftSuspectSteerState = (sessionKey: string) => {
1701
1834
  pendingSoftSuspectSteeringBySession.delete(sessionKey);
@@ -1920,6 +2053,42 @@ export default function register(api: OpenClawPluginApi): void {
1920
2053
  return;
1921
2054
  }
1922
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
+
1923
2092
  const rawText = params.text.trim();
1924
2093
  const text = cfg.stripEnvelope ? stripClassifierEnvelope(rawText, cfg.stripRules) : rawText;
1925
2094
  if (!text || text.startsWith("/")) {