@clawmem-ai/clawmem 0.1.15 → 0.1.16

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/src/service.ts CHANGED
@@ -5,31 +5,34 @@ import { filterDirectCollaborators, listRepoAccessTeams, resolveOrgInvitationRol
5
5
  import { ConversationMirror } from "./conversation.js";
6
6
  import { GitHubIssueClient } from "./github-client.js";
7
7
  import { KeyedAsyncQueue } from "./keyed-async-queue.js";
8
- import { MemoryStore } from "./memory.js";
8
+ import { MemoryStore, mergeMemoryCandidates } from "./memory.js";
9
9
  import { sanitizeRecallQueryInput } from "./recall-sanitize.js";
10
10
  import { loadState, resolveStatePath, saveState } from "./state.js";
11
11
  import { readTranscriptSnapshot } from "./transcript.js";
12
- import type { BootstrapIdentityResponse, ClawMemPluginConfig, ClawMemResolvedRoute, PluginState, SessionMirrorState, TranscriptSnapshot } from "./types.js";
12
+ import type { BootstrapIdentityResponse, ClawMemPluginConfig, ClawMemResolvedRoute, PluginState, SessionDerivedState, SessionMirrorState, TranscriptSnapshot } from "./types.js";
13
13
  import { buildAgentBootstrapRegistration, inferAgentIdFromTranscriptPath, normalizeAgentId, sessionScopeKey } from "./utils.js";
14
+ import { getOpenClawAgentIdFromEnv, getOpenClawHostVersionFromEnv } from "./runtime-env.js";
14
15
 
15
16
  type TurnPayload = { sessionId?: string; sessionKey?: string; agentId?: string; messages: unknown[] };
16
17
  type FinalizePayload = { sessionId?: string; sessionKey?: string; sessionFile?: string; agentId?: string; reason?: string; messages?: unknown[] };
17
18
  type CollaborationPermission = "read" | "write" | "admin";
18
19
  type CollaborationTeamRole = "member" | "maintainer";
19
20
 
20
- const SESSION_MAINTENANCE_RETRY_DELAYS_MS = [5000, 30000, 120000] as const;
21
+ const DERIVED_WORK_RECOVERY_DELAYS_MS = [5000, 30000, 120000] as const;
21
22
  const MODERN_PROMPT_HOOK_MIN_HOST_VERSION = "2026.3.7";
22
23
  type PromptHookMode = "modern" | "legacy";
23
24
 
24
25
  class ClawMemService {
25
26
  private readonly config: ClawMemPluginConfig;
26
- private readonly queue = new KeyedAsyncQueue();
27
+ private readonly ioQueue = new KeyedAsyncQueue();
28
+ private readonly deriveQueue = new KeyedAsyncQueue();
29
+ private readonly repoWriteQueue = new KeyedAsyncQueue();
27
30
  private readonly stateQueue = new KeyedAsyncQueue();
28
31
  private readonly pending = new Set<Promise<unknown>>();
29
32
  private readonly syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
30
- private readonly maintenanceTimers = new Map<string, ReturnType<typeof setTimeout>>();
33
+ private readonly recoveryTimers = new Map<string, ReturnType<typeof setTimeout>>();
31
34
  private statePath = "";
32
- private state: PluginState = { version: 2, sessions: {} };
35
+ private state: PluginState = { version: 3, sessions: {} };
33
36
  private unsubTranscript?: () => void;
34
37
  private loadPromise: Promise<void> | null = null;
35
38
  private readonly configPromises = new Map<string, Promise<boolean>>();
@@ -59,9 +62,7 @@ class ClawMemService {
59
62
  this.unsubTranscript = this.api.runtime.events.onSessionTranscriptUpdate((u) => {
60
63
  void this.track(this.handleTranscript(u.sessionFile)).catch((e) => this.warn("transcript update", e));
61
64
  });
62
- for (const agentId of new Set(Object.values(this.state.sessions).map((session) => normalizeAgentId(session.agentId)))) {
63
- this.scheduleRecentSessionMaintenance(agentId);
64
- }
65
+ this.recoverDerivedWorkOnStart();
65
66
  const configuredCount = Object.keys(this.config.agents).filter((agentId) => {
66
67
  const route = resolveAgentRoute(this.config, agentId);
67
68
  return isAgentConfigured(route) && hasDefaultRepo(route);
@@ -77,8 +78,8 @@ class ClawMemService {
77
78
  this.unsubTranscript?.();
78
79
  for (const t of this.syncTimers.values()) clearTimeout(t);
79
80
  this.syncTimers.clear();
80
- for (const t of this.maintenanceTimers.values()) clearTimeout(t);
81
- this.maintenanceTimers.clear();
81
+ for (const t of this.recoveryTimers.values()) clearTimeout(t);
82
+ this.recoveryTimers.clear();
82
83
  await Promise.allSettled([...this.pending]);
83
84
  },
84
85
  });
@@ -342,12 +343,12 @@ class ClawMemService {
342
343
  if ("error" in resolved) return toolText(resolved.error);
343
344
  const kind = typeof p.kind === "string" && p.kind.trim() ? p.kind.trim() : undefined;
344
345
  const topics = Array.isArray(p.topics) ? p.topics.filter((topic): topic is string => typeof topic === "string" && topic.trim().length > 0) : undefined;
345
- const result = await resolved.mem.store({
346
+ const result = await this.enqueueRepoWrite(this.repoWriteKey(resolved.route), () => resolved.mem.store({
346
347
  ...(title ? { title } : {}),
347
348
  detail,
348
349
  ...(kind ? { kind } : {}),
349
350
  ...(topics && topics.length > 0 ? { topics } : {}),
350
- });
351
+ }));
351
352
  if (!result.created) return toolText(`Memory already exists in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
352
353
  return toolText(`Stored memory in ${resolved.route.repo}.\n${renderMemoryBlock(result.memory)}`);
353
354
  },
@@ -391,7 +392,10 @@ class ClawMemService {
391
392
  if ("error" in resolved) return toolText(resolved.error);
392
393
  let updated;
393
394
  try {
394
- updated = await resolved.mem.update(memoryId, { ...(title ? { title } : {}), ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) });
395
+ updated = await this.enqueueRepoWrite(
396
+ this.repoWriteKey(resolved.route),
397
+ () => resolved.mem.update(memoryId, { ...(title ? { title } : {}), ...(detail ? { detail } : {}), ...(kind !== undefined ? { kind } : {}), ...(topics !== undefined ? { topics } : {}) }),
398
+ );
395
399
  } catch (error) {
396
400
  return toolText(`Unable to update memory "${memoryId}": ${String(error)}`);
397
401
  }
@@ -421,7 +425,7 @@ class ClawMemService {
421
425
  const agentId = this.resolveToolAgentId(p.agentId);
422
426
  const resolved = await this.requireToolRoute(agentId, p.repo);
423
427
  if ("error" in resolved) return toolText(resolved.error);
424
- const forgotten = await resolved.mem.forget(memoryId);
428
+ const forgotten = await this.enqueueRepoWrite(this.repoWriteKey(resolved.route), () => resolved.mem.forget(memoryId));
425
429
  if (!forgotten) return toolText(`No active memory matched id "${memoryId}" in ${resolved.route.repo}.`);
426
430
  return toolText(`Marked memory [${forgotten.memoryId}] stale in ${resolved.route.repo}: ${forgotten.detail}`);
427
431
  },
@@ -1330,7 +1334,6 @@ class ClawMemService {
1330
1334
  private async collectAutoRecallContext(event: unknown, agentId?: string): Promise<string | undefined> {
1331
1335
  const routeAgentId = normalizeAgentId(agentId);
1332
1336
  if (!(await this.ensureDefaultRepoConfigured(routeAgentId))) return undefined;
1333
- this.scheduleRecentSessionMaintenance(routeAgentId);
1334
1337
  const prompt = extractPromptTextForRecall(event);
1335
1338
  if (typeof prompt !== "string" || prompt.trim().length < 5) return undefined;
1336
1339
  try {
@@ -1357,7 +1360,7 @@ class ClawMemService {
1357
1360
  const { conv } = this.getServices(agentId);
1358
1361
  if (!conv.shouldMirror(snap.sessionId, snap.messages)) return;
1359
1362
  if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
1360
- await this.enqueueSession(sessionScopeKey(snap.sessionId, agentId), async () => {
1363
+ await this.enqueueSessionIo(sessionScopeKey(snap.sessionId, agentId), async () => {
1361
1364
  const s = this.getOrCreate(snap.sessionId!, agentId);
1362
1365
  s.sessionFile = sessionFile;
1363
1366
  s.updatedAt = new Date().toISOString();
@@ -1373,7 +1376,7 @@ class ClawMemService {
1373
1376
  if (prev) clearTimeout(prev);
1374
1377
  const timer = setTimeout(() => {
1375
1378
  this.syncTimers.delete(scopeKey);
1376
- void this.track(this.enqueueSession(scopeKey, () => this.syncTurn(p))).catch((e) => this.warn("turn sync", e));
1379
+ void this.track(this.enqueueSessionIo(scopeKey, () => this.syncTurn(p))).catch((e) => this.warn("turn sync", e));
1377
1380
  }, this.config.turnCommentDelayMs);
1378
1381
  timer.unref?.();
1379
1382
  this.syncTimers.set(scopeKey, timer);
@@ -1382,6 +1385,7 @@ class ClawMemService {
1382
1385
  private async syncTurn(p: TurnPayload): Promise<void> {
1383
1386
  if (!p.sessionId) return;
1384
1387
  const agentId = normalizeAgentId(p.agentId);
1388
+ const scopeKey = sessionScopeKey(p.sessionId, agentId);
1385
1389
  if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
1386
1390
  const { conv } = this.getServices(agentId);
1387
1391
  const s = this.getOrCreate(p.sessionId, agentId);
@@ -1392,8 +1396,9 @@ class ClawMemService {
1392
1396
  await conv.syncLabels(s, snap, false);
1393
1397
  const next = snap.messages.slice(s.lastMirroredCount);
1394
1398
  if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; }
1399
+ this.markPostMirrorTasks(s);
1395
1400
  await this.persistState();
1396
- this.scheduleRecentSessionMaintenance(agentId);
1401
+ this.kickDerivedWork(scopeKey, agentId, "turn");
1397
1402
  }
1398
1403
 
1399
1404
  private enqueueFinalize(p: FinalizePayload): void {
@@ -1401,7 +1406,7 @@ class ClawMemService {
1401
1406
  const scopeKey = sessionScopeKey(p.sessionId, p.agentId);
1402
1407
  const prev = this.syncTimers.get(scopeKey);
1403
1408
  if (prev) { clearTimeout(prev); this.syncTimers.delete(scopeKey); }
1404
- void this.track(this.enqueueSession(scopeKey, () => this.finalize(p))).catch((e) => this.warn("finalize", e));
1409
+ void this.track(this.enqueueSessionIo(scopeKey, () => this.finalize(p))).catch((e) => this.warn("finalize", e));
1405
1410
  }
1406
1411
 
1407
1412
  private async finalize(p: FinalizePayload): Promise<void> {
@@ -1423,16 +1428,26 @@ class ClawMemService {
1423
1428
  if (next.length > 0) { const n = await conv.appendComments(s.issueNumber!, next); s.lastMirroredCount += n; s.turnCount += n; allOk = n === next.length; }
1424
1429
  await conv.syncLabels(s, snap, true);
1425
1430
  await conv.syncBody(s, snap, "pending", true);
1426
- s.summaryStatus = "pending";
1427
1431
  if (allOk) s.finalizedAt = new Date().toISOString();
1432
+ this.markPostMirrorTasks(s);
1433
+ this.markSummaryPending(s);
1428
1434
  await this.persistState();
1429
- this.scheduleSessionMaintenance(scopeKey, agentId, { reason: p.reason ?? "finalize" });
1435
+ this.kickDerivedWork(scopeKey, agentId, p.reason ?? "finalize");
1430
1436
  }
1431
1437
 
1432
1438
  // --- Infrastructure ---
1433
1439
 
1434
- private enqueueSession<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
1435
- return this.queue.enqueue(sessionId, async () => { await this.ensureLoaded(); return task(); });
1440
+ private enqueueSessionIo<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
1441
+ return this.ioQueue.enqueue(sessionId, async () => { await this.ensureLoaded(); return task(); });
1442
+ }
1443
+ private enqueueSessionDerived<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
1444
+ return this.deriveQueue.enqueue(sessionId, async () => { await this.ensureLoaded(); return task(); });
1445
+ }
1446
+ private enqueueRepoWrite<T>(repoKey: string, task: () => Promise<T>): Promise<T> {
1447
+ return this.repoWriteQueue.enqueue(repoKey, task);
1448
+ }
1449
+ private repoWriteKey(route: ClawMemResolvedRoute): string {
1450
+ return route.repo || route.defaultRepo || route.agentId;
1436
1451
  }
1437
1452
  private track<T>(promise: Promise<T>): Promise<T> {
1438
1453
  this.pending.add(promise);
@@ -1453,12 +1468,90 @@ class ClawMemService {
1453
1468
  agentId: normalizeAgentId(agentId),
1454
1469
  lastMirroredCount: 0,
1455
1470
  turnCount: 0,
1471
+ derived: {
1472
+ digest: { cursor: 0, status: "idle", attempt: 0 },
1473
+ summary: { basedOnCursor: 0, status: "idle" },
1474
+ memory: {
1475
+ extractCursor: 0,
1476
+ appliedCursor: 0,
1477
+ extractStatus: "idle",
1478
+ reconcileStatus: "idle",
1479
+ attempt: 0,
1480
+ pendingCandidates: [],
1481
+ },
1482
+ },
1456
1483
  createdAt: now,
1457
1484
  updatedAt: now,
1458
1485
  };
1459
1486
  this.state.sessions[scopeKey] = s;
1460
1487
  return s;
1461
1488
  }
1489
+
1490
+ private ensureDerived(session: SessionMirrorState): SessionDerivedState {
1491
+ if (!session.derived) {
1492
+ session.derived = {
1493
+ digest: { cursor: 0, status: "idle", attempt: 0 },
1494
+ summary: { basedOnCursor: 0, status: "idle" },
1495
+ memory: {
1496
+ extractCursor: 0,
1497
+ appliedCursor: session.lastMemorySyncCount ?? 0,
1498
+ extractStatus: "idle",
1499
+ reconcileStatus: "idle",
1500
+ attempt: 0,
1501
+ pendingCandidates: [],
1502
+ },
1503
+ };
1504
+ }
1505
+ return session.derived;
1506
+ }
1507
+
1508
+ private syncLegacyTaskFields(session: SessionMirrorState): void {
1509
+ const derived = this.ensureDerived(session);
1510
+ session.summaryStatus = derived.summary.status === "complete" ? "complete" : session.finalizedAt ? "pending" : undefined;
1511
+ session.lastMemorySyncCount = derived.memory.appliedCursor;
1512
+ }
1513
+
1514
+ private hasMeaningfulTranscript(session: SessionMirrorState): boolean {
1515
+ return Math.max(session.lastMirroredCount, session.turnCount) >= 2;
1516
+ }
1517
+
1518
+ private needsDigest(session: SessionMirrorState): boolean {
1519
+ if (!this.hasMeaningfulTranscript(session)) return false;
1520
+ const derived = this.ensureDerived(session);
1521
+ return derived.digest.cursor < session.lastMirroredCount;
1522
+ }
1523
+
1524
+ private needsFinalSummary(session: SessionMirrorState): boolean {
1525
+ if (!session.finalizedAt || !this.hasMeaningfulTranscript(session)) return false;
1526
+ const derived = this.ensureDerived(session);
1527
+ return derived.summary.status !== "complete" || derived.summary.basedOnCursor < session.lastMirroredCount;
1528
+ }
1529
+
1530
+ private needsMemoryExtract(session: SessionMirrorState): boolean {
1531
+ if (!this.hasMeaningfulTranscript(session)) return false;
1532
+ const derived = this.ensureDerived(session);
1533
+ return derived.memory.extractCursor < session.lastMirroredCount;
1534
+ }
1535
+
1536
+ private needsMemoryReconcile(session: SessionMirrorState): boolean {
1537
+ if (!this.hasMeaningfulTranscript(session)) return false;
1538
+ const derived = this.ensureDerived(session);
1539
+ return derived.memory.pendingCandidates.length > 0 || derived.memory.appliedCursor < derived.memory.extractCursor;
1540
+ }
1541
+
1542
+ private markPostMirrorTasks(session: SessionMirrorState): void {
1543
+ const derived = this.ensureDerived(session);
1544
+ if (this.needsDigest(session)) derived.digest.status = "pending";
1545
+ if (this.needsMemoryExtract(session)) derived.memory.extractStatus = "pending";
1546
+ if (this.needsMemoryReconcile(session)) derived.memory.reconcileStatus = "pending";
1547
+ this.syncLegacyTaskFields(session);
1548
+ }
1549
+
1550
+ private markSummaryPending(session: SessionMirrorState): void {
1551
+ const derived = this.ensureDerived(session);
1552
+ derived.summary.status = "pending";
1553
+ this.syncLegacyTaskFields(session);
1554
+ }
1462
1555
  private resolveTranscriptAgentId(sessionId: string, sessionFile: string): string | null {
1463
1556
  const fromPath = inferAgentIdFromTranscriptPath(sessionFile);
1464
1557
  if (fromPath) return fromPath;
@@ -1583,109 +1676,277 @@ class ClawMemService {
1583
1676
  },
1584
1677
  });
1585
1678
  }
1586
- private scheduleRecentSessionMaintenance(agentId: string): void {
1587
- const sessions = Object.values(this.state.sessions)
1588
- .filter((session) => normalizeAgentId(session.agentId) === agentId)
1589
- .sort((a, b) => Date.parse(b.updatedAt ?? b.createdAt ?? "") - Date.parse(a.updatedAt ?? a.createdAt ?? ""))
1590
- .slice(0, 8);
1591
- for (const session of sessions) {
1592
- if (!this.sessionNeedsMaintenance(session)) continue;
1593
- this.scheduleSessionMaintenance(sessionScopeKey(session.sessionId, session.agentId), agentId, {
1594
- reason: "request-start-fallback",
1679
+ private clearRecoveryTimer(scopeKey: string): void {
1680
+ const prev = this.recoveryTimers.get(scopeKey);
1681
+ if (!prev) return;
1682
+ clearTimeout(prev);
1683
+ this.recoveryTimers.delete(scopeKey);
1684
+ }
1685
+
1686
+ private recoverDerivedWorkOnStart(): void {
1687
+ const recoverableSessions = Object.values(this.state.sessions)
1688
+ .filter((session) => this.sessionNeedsDerivedWork(session))
1689
+ .sort((a, b) => Date.parse(b.updatedAt ?? b.createdAt ?? "") - Date.parse(a.updatedAt ?? a.createdAt ?? ""));
1690
+ for (const session of recoverableSessions) {
1691
+ this.scheduleDerivedRecovery(sessionScopeKey(session.sessionId, session.agentId), normalizeAgentId(session.agentId), {
1595
1692
  delayMs: 0,
1693
+ attempt: 0,
1694
+ reason: "startup-recovery",
1596
1695
  });
1597
- break;
1598
1696
  }
1599
1697
  }
1600
1698
 
1601
- private scheduleSessionMaintenance(
1699
+ private kickDerivedWork(scopeKey: string, agentId: string, reason: string): void {
1700
+ this.clearRecoveryTimer(scopeKey);
1701
+ void this.track(this.enqueueSessionDerived(scopeKey, () => this.runSessionDerivedWork(scopeKey, agentId, 0, reason)))
1702
+ .catch((error) => this.warn(`derived work for ${scopeKey}`, error));
1703
+ }
1704
+
1705
+ private scheduleDerivedRecovery(
1602
1706
  scopeKey: string,
1603
1707
  agentId: string,
1604
1708
  options: { delayMs?: number; attempt?: number; reason?: string } = {},
1605
1709
  ): void {
1606
- const prev = this.maintenanceTimers.get(scopeKey);
1607
- if (prev) clearTimeout(prev);
1710
+ this.clearRecoveryTimer(scopeKey);
1608
1711
  const delayMs = Math.max(0, options.delayMs ?? 0);
1609
1712
  const attempt = Math.max(0, options.attempt ?? 0);
1610
- const reason = options.reason ?? "scheduled";
1713
+ const reason = options.reason ?? "scheduled-recovery";
1611
1714
  const timer = setTimeout(() => {
1612
- this.maintenanceTimers.delete(scopeKey);
1613
- void this.track(this.enqueueSession(scopeKey, () => this.runSessionMaintenance(scopeKey, agentId, attempt, reason)))
1614
- .catch((error) => this.warn(`background maintenance for ${scopeKey}`, error));
1715
+ this.recoveryTimers.delete(scopeKey);
1716
+ void this.track(this.enqueueSessionDerived(scopeKey, () => this.runSessionDerivedWork(scopeKey, agentId, attempt, reason)))
1717
+ .catch((error) => this.warn(`derived recovery for ${scopeKey}`, error));
1615
1718
  }, delayMs);
1616
1719
  timer.unref?.();
1617
- this.maintenanceTimers.set(scopeKey, timer);
1720
+ this.recoveryTimers.set(scopeKey, timer);
1618
1721
  }
1619
1722
 
1620
- private async runSessionMaintenance(scopeKey: string, agentId: string, attempt: number, reason: string): Promise<void> {
1723
+ private async runSessionDerivedWork(scopeKey: string, agentId: string, attempt: number, reason: string): Promise<void> {
1621
1724
  const session = this.state.sessions[scopeKey];
1622
- if (!session || !this.sessionNeedsMaintenance(session)) return;
1725
+ if (!session || !this.sessionNeedsDerivedWork(session)) return;
1623
1726
  if (!(await this.ensureDefaultRepoConfigured(agentId))) return;
1624
- const { conv, mem } = this.getServices(agentId);
1727
+ const { route, conv, mem, client } = this.getServices(agentId);
1625
1728
  const snap = await conv.loadSnapshot(session, []);
1626
1729
  if (!conv.shouldMirror(session.sessionId, snap.messages) || snap.messages.length === 0) return;
1627
- let changed = false;
1730
+
1628
1731
  let retryNeeded = false;
1629
- if (!session.issueNumber) {
1630
- await conv.ensureIssue(session, snap);
1631
- changed = true;
1732
+ const derived = this.ensureDerived(session);
1733
+ const updateLegacyAndPersist = async (): Promise<void> => {
1734
+ this.syncLegacyTaskFields(session);
1735
+ await this.persistState();
1736
+ };
1737
+ const mirroredCount = Math.min(session.lastMirroredCount, snap.messages.length);
1738
+ if (mirroredCount <= 0) return;
1739
+ const canCombineDeltaDerivation = this.needsDigest(session)
1740
+ && this.needsMemoryExtract(session)
1741
+ && derived.digest.cursor === derived.memory.extractCursor;
1742
+
1743
+ if (canCombineDeltaDerivation) {
1744
+ const deriveTarget = mirroredCount;
1745
+ const deriveFromCursor = Math.min(derived.digest.cursor, deriveTarget);
1746
+ const deriveSnapshot: TranscriptSnapshot = { ...snap, messages: snap.messages.slice(0, deriveTarget) };
1747
+ const startedAt = new Date().toISOString();
1748
+ derived.digest.status = "running";
1749
+ derived.digest.updatedAt = startedAt;
1750
+ derived.memory.extractStatus = "running";
1751
+ derived.memory.updatedAt = startedAt;
1752
+ await updateLegacyAndPersist();
1753
+ try {
1754
+ const result = await conv.deriveDelta(session, deriveSnapshot, deriveFromCursor, derived.digest.text);
1755
+ derived.digest.text = result.digest.trim();
1756
+ derived.digest.title = result.title?.trim() || derived.digest.title;
1757
+ derived.digest.cursor = deriveTarget;
1758
+ derived.digest.status = deriveTarget < session.lastMirroredCount ? "pending" : "complete";
1759
+ derived.digest.attempt = 0;
1760
+ derived.digest.lastError = undefined;
1761
+ derived.digest.updatedAt = new Date().toISOString();
1762
+ if (result.title?.trim() && session.issueNumber) {
1763
+ await client.updateIssue(session.issueNumber, { title: result.title.trim() });
1764
+ session.issueTitle = result.title.trim();
1765
+ session.titleSource = "digest";
1766
+ }
1767
+
1768
+ derived.memory.pendingCandidates = mergeMemoryCandidates(derived.memory.pendingCandidates, result.candidates);
1769
+ derived.memory.extractCursor = deriveTarget;
1770
+ derived.memory.extractStatus = deriveTarget < session.lastMirroredCount ? "pending" : "complete";
1771
+ derived.memory.attempt = 0;
1772
+ derived.memory.lastError = undefined;
1773
+ derived.memory.updatedAt = new Date().toISOString();
1774
+ if (derived.memory.pendingCandidates.length === 0) {
1775
+ derived.memory.appliedCursor = Math.max(derived.memory.appliedCursor, deriveTarget);
1776
+ derived.memory.reconcileStatus = "complete";
1777
+ } else {
1778
+ derived.memory.reconcileStatus = "pending";
1779
+ }
1780
+ } catch (error) {
1781
+ const failedAt = new Date().toISOString();
1782
+ derived.digest.status = "error";
1783
+ derived.digest.attempt += 1;
1784
+ derived.digest.lastError = String(error);
1785
+ derived.digest.updatedAt = failedAt;
1786
+ derived.memory.extractStatus = "error";
1787
+ derived.memory.attempt += 1;
1788
+ derived.memory.lastError = String(error);
1789
+ derived.memory.updatedAt = failedAt;
1790
+ retryNeeded = true;
1791
+ this.warn(`background derive delta for ${session.sessionId}`, error);
1792
+ }
1793
+ await updateLegacyAndPersist();
1794
+ }
1795
+
1796
+ if (!canCombineDeltaDerivation && this.needsDigest(session)) {
1797
+ const digestTarget = mirroredCount;
1798
+ const digestFromCursor = Math.min(derived.digest.cursor, digestTarget);
1799
+ const digestSnapshot: TranscriptSnapshot = { ...snap, messages: snap.messages.slice(0, digestTarget) };
1800
+ derived.digest.status = "running";
1801
+ derived.digest.updatedAt = new Date().toISOString();
1802
+ await updateLegacyAndPersist();
1803
+ try {
1804
+ const result = await conv.generateRollingDigest(session, digestSnapshot, digestFromCursor, derived.digest.text);
1805
+ derived.digest.text = result.digest.trim();
1806
+ derived.digest.title = result.title?.trim() || derived.digest.title;
1807
+ derived.digest.cursor = digestTarget;
1808
+ derived.digest.status = digestTarget < session.lastMirroredCount ? "pending" : "complete";
1809
+ derived.digest.attempt = 0;
1810
+ derived.digest.lastError = undefined;
1811
+ derived.digest.updatedAt = new Date().toISOString();
1812
+ if (result.title?.trim() && session.issueNumber) {
1813
+ await client.updateIssue(session.issueNumber, { title: result.title.trim() });
1814
+ session.issueTitle = result.title.trim();
1815
+ session.titleSource = "digest";
1816
+ }
1817
+ } catch (error) {
1818
+ derived.digest.status = "error";
1819
+ derived.digest.attempt += 1;
1820
+ derived.digest.lastError = String(error);
1821
+ derived.digest.updatedAt = new Date().toISOString();
1822
+ retryNeeded = true;
1823
+ this.warn(`background digest sync for ${session.sessionId}`, error);
1824
+ }
1825
+ await updateLegacyAndPersist();
1632
1826
  }
1633
- if (session.summaryStatus === "pending") {
1827
+
1828
+ if (this.needsFinalSummary(session) && derived.digest.cursor >= session.lastMirroredCount) {
1829
+ const summaryTarget = Math.min(session.lastMirroredCount, snap.messages.length);
1830
+ const summarySnapshot: TranscriptSnapshot = { ...snap, messages: snap.messages.slice(0, summaryTarget) };
1831
+ derived.summary.status = "running";
1832
+ derived.summary.updatedAt = new Date().toISOString();
1833
+ await updateLegacyAndPersist();
1634
1834
  try {
1635
- const result = await conv.generateSummaryAndTitle(session, snap);
1636
- await conv.syncLabels(session, snap, true);
1637
- await conv.syncBody(session, snap, result.summary, true, result.title);
1638
- session.summaryStatus = "complete";
1835
+ const result = await conv.generateFinalSummaryFromDigest(session, summarySnapshot, derived.digest.text ?? "");
1836
+ await conv.syncLabels(session, summarySnapshot, true);
1837
+ await conv.syncBody(session, summarySnapshot, result.summary, true, result.title);
1838
+ derived.summary.text = result.summary;
1839
+ derived.summary.status = summaryTarget < session.lastMirroredCount ? "pending" : "complete";
1840
+ derived.summary.basedOnCursor = summaryTarget;
1841
+ derived.summary.lastError = undefined;
1842
+ derived.summary.updatedAt = new Date().toISOString();
1639
1843
  if (result.title?.trim()) {
1640
1844
  session.issueTitle = result.title.trim();
1641
1845
  session.titleSource = "llm";
1642
1846
  }
1643
1847
  this.maybeAutoNameRepo(agentId, result.summary, result.title);
1644
- changed = true;
1645
1848
  } catch (error) {
1849
+ derived.summary.status = "error";
1850
+ derived.summary.lastError = String(error);
1851
+ derived.summary.updatedAt = new Date().toISOString();
1646
1852
  retryNeeded = true;
1647
1853
  this.warn(`background summary sync for ${session.sessionId}`, error);
1648
1854
  }
1855
+ await updateLegacyAndPersist();
1649
1856
  }
1650
- if (session.titleSource !== "llm" && snap.messages.length >= 2) {
1857
+
1858
+ if (!canCombineDeltaDerivation && this.needsMemoryExtract(session)) {
1859
+ const extractTarget = Math.min(session.lastMirroredCount, snap.messages.length);
1860
+ const extractFromCursor = Math.min(derived.memory.extractCursor, extractTarget);
1861
+ const extractSnapshot: TranscriptSnapshot = { ...snap, messages: snap.messages.slice(0, extractTarget) };
1862
+ derived.memory.extractStatus = "running";
1863
+ derived.memory.updatedAt = new Date().toISOString();
1864
+ await updateLegacyAndPersist();
1651
1865
  try {
1652
- await conv.syncTitle(session, snap);
1653
- changed = true;
1866
+ const candidates = await mem.extractCandidates(session, extractSnapshot, extractFromCursor, derived.digest.text);
1867
+ derived.memory.pendingCandidates = mergeMemoryCandidates(derived.memory.pendingCandidates, candidates);
1868
+ derived.memory.extractCursor = extractTarget;
1869
+ derived.memory.extractStatus = extractTarget < session.lastMirroredCount ? "pending" : "complete";
1870
+ derived.memory.attempt = 0;
1871
+ derived.memory.lastError = undefined;
1872
+ derived.memory.updatedAt = new Date().toISOString();
1873
+ if (derived.memory.pendingCandidates.length === 0) {
1874
+ derived.memory.appliedCursor = Math.max(derived.memory.appliedCursor, extractTarget);
1875
+ derived.memory.reconcileStatus = "complete";
1876
+ } else {
1877
+ derived.memory.reconcileStatus = "pending";
1878
+ }
1654
1879
  } catch (error) {
1880
+ derived.memory.extractStatus = "error";
1881
+ derived.memory.attempt += 1;
1882
+ derived.memory.lastError = String(error);
1883
+ derived.memory.updatedAt = new Date().toISOString();
1655
1884
  retryNeeded = true;
1656
- this.warn(`background title sync for ${session.sessionId}`, error);
1885
+ this.warn(`background memory extract for ${session.sessionId}`, error);
1657
1886
  }
1887
+ await updateLegacyAndPersist();
1658
1888
  }
1659
- if (snap.messages.length >= 2 && snap.messages.length > (session.lastMemorySyncCount ?? 0)) {
1660
- const ok = await mem.syncFromConversation(session, snap);
1661
- if (ok) {
1662
- session.lastMemorySyncCount = snap.messages.length;
1663
- changed = true;
1889
+
1890
+ if (this.needsMemoryReconcile(session)) {
1891
+ if (derived.memory.pendingCandidates.length === 0) {
1892
+ derived.memory.appliedCursor = derived.memory.extractCursor;
1893
+ derived.memory.reconcileStatus = "complete";
1894
+ derived.memory.updatedAt = new Date().toISOString();
1895
+ await updateLegacyAndPersist();
1664
1896
  } else {
1665
- retryNeeded = true;
1897
+ const candidates = mergeMemoryCandidates([], derived.memory.pendingCandidates);
1898
+ derived.memory.reconcileStatus = "running";
1899
+ derived.memory.updatedAt = new Date().toISOString();
1900
+ await updateLegacyAndPersist();
1901
+ try {
1902
+ const decision = await mem.reconcileCandidates(session, candidates);
1903
+ const { savedCount, staledCount } = await this.enqueueRepoWrite(
1904
+ this.repoWriteKey(route),
1905
+ () => mem.applyReconciledDecision(decision),
1906
+ );
1907
+ if (savedCount > 0 || staledCount > 0) {
1908
+ this.api.logger.info?.(
1909
+ `clawmem: synced memories for ${session.sessionId} (saved=${savedCount}, stale=${staledCount})`,
1910
+ );
1911
+ }
1912
+ const consumed = new Set(candidates.map((candidate) => candidate.candidateId));
1913
+ derived.memory.pendingCandidates = derived.memory.pendingCandidates.filter((candidate) => !consumed.has(candidate.candidateId));
1914
+ derived.memory.appliedCursor = derived.memory.extractCursor;
1915
+ derived.memory.reconcileStatus = derived.memory.pendingCandidates.length > 0 ? "pending" : "complete";
1916
+ derived.memory.attempt = 0;
1917
+ derived.memory.lastError = undefined;
1918
+ derived.memory.updatedAt = new Date().toISOString();
1919
+ } catch (error) {
1920
+ derived.memory.reconcileStatus = "error";
1921
+ derived.memory.attempt += 1;
1922
+ derived.memory.lastError = String(error);
1923
+ derived.memory.updatedAt = new Date().toISOString();
1924
+ retryNeeded = true;
1925
+ this.warn(`background memory reconcile for ${session.sessionId}`, error);
1926
+ }
1927
+ await updateLegacyAndPersist();
1666
1928
  }
1667
1929
  }
1668
- if (changed) await this.persistState();
1669
- if (!retryNeeded || !this.sessionNeedsMaintenance(session)) return;
1670
- if (attempt < SESSION_MAINTENANCE_RETRY_DELAYS_MS.length) {
1671
- const delayMs = SESSION_MAINTENANCE_RETRY_DELAYS_MS[attempt];
1930
+
1931
+ if (retryNeeded && this.sessionNeedsDerivedWork(session)) {
1932
+ const delayMs = DERIVED_WORK_RECOVERY_DELAYS_MS[Math.min(attempt, DERIVED_WORK_RECOVERY_DELAYS_MS.length - 1)] ?? 120000;
1672
1933
  this.api.logger.warn?.(
1673
- `clawmem: background maintenance incomplete for ${session.sessionId}; retrying in ${Math.round(delayMs / 1000)}s (${reason})`,
1934
+ `clawmem: derived work incomplete for ${session.sessionId}; retrying in ${Math.round(delayMs / 1000)}s (${reason})`,
1674
1935
  );
1675
- this.scheduleSessionMaintenance(scopeKey, agentId, { delayMs, attempt: attempt + 1, reason: "retry" });
1936
+ this.scheduleDerivedRecovery(scopeKey, agentId, { delayMs, attempt: attempt + 1, reason: "retry" });
1676
1937
  return;
1677
1938
  }
1678
- this.api.logger.warn?.(
1679
- `clawmem: background maintenance remains pending for ${session.sessionId}; it will be retried opportunistically on future requests`,
1680
- );
1939
+
1940
+ if (this.sessionNeedsDerivedWork(session)) {
1941
+ this.kickDerivedWork(scopeKey, agentId, "follow-up");
1942
+ }
1681
1943
  }
1682
1944
 
1683
- private sessionNeedsMaintenance(session: SessionMirrorState): boolean {
1684
- if (session.summaryStatus === "pending") return true;
1685
- const hasMeaningfulTranscript = Math.max(session.lastMirroredCount, session.turnCount) >= 2;
1686
- if (!hasMeaningfulTranscript) return false;
1687
- if (session.titleSource !== "llm") return true;
1688
- return (session.lastMemorySyncCount ?? 0) < session.lastMirroredCount;
1945
+ private sessionNeedsDerivedWork(session: SessionMirrorState): boolean {
1946
+ return this.needsDigest(session)
1947
+ || this.needsFinalSummary(session)
1948
+ || this.needsMemoryExtract(session)
1949
+ || this.needsMemoryReconcile(session);
1689
1950
  }
1690
1951
 
1691
1952
  private getServices(agentId?: string, repo?: string): { route: ClawMemResolvedRoute; conv: ConversationMirror; mem: MemoryStore; client: GitHubIssueClient } {
@@ -1699,7 +1960,7 @@ class ClawMemService {
1699
1960
  };
1700
1961
  }
1701
1962
  private resolveToolAgentId(agentId: unknown): string {
1702
- return normalizeAgentId(typeof agentId === "string" && agentId.trim() ? agentId : process.env.OPENCLAW_AGENT_ID);
1963
+ return normalizeAgentId(typeof agentId === "string" && agentId.trim() ? agentId : getOpenClawAgentIdFromEnv());
1703
1964
  }
1704
1965
  private resolveToolRepo(repo: unknown): { repo?: string; error?: string } {
1705
1966
  if (repo === undefined || repo === null || repo === "") return {};
@@ -1957,13 +2218,8 @@ export function resolvePromptHookMode(api: Pick<OpenClawPluginApi, "runtime">):
1957
2218
  export function resolveOpenClawHostVersion(api: Pick<OpenClawPluginApi, "runtime">): string | undefined {
1958
2219
  const runtimeVersion = typeof api.runtime?.version === "string" ? api.runtime.version.trim() : "";
1959
2220
  if (isUsableOpenClawVersion(runtimeVersion)) return runtimeVersion;
1960
- for (const candidate of [
1961
- process.env.OPENCLAW_VERSION,
1962
- process.env.OPENCLAW_SERVICE_VERSION,
1963
- ]) {
1964
- const trimmed = candidate?.trim();
1965
- if (isUsableOpenClawVersion(trimmed)) return trimmed;
1966
- }
2221
+ const envVersion = getOpenClawHostVersionFromEnv();
2222
+ if (isUsableOpenClawVersion(envVersion)) return envVersion;
1967
2223
  return undefined;
1968
2224
  }
1969
2225