agenr 0.8.27 → 0.8.28

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.28] - 2026-02-24
4
+
5
+ ### Fixed
6
+ - command hook fires before_reset handoff logic for RPC-triggered /new (closes #210)
7
+ - before_reset hook only fires in the in-process auto-reply path; sessions.reset RPC
8
+ path only fires the command hook
9
+ - new command hook handler reads and parses the session JSONL directly, then runs
10
+ the same Phase 1 fallback store + Phase 2 LLM upgrade logic
11
+ - dedup guard (Set<sessionId>) prevents double-writes when both hooks fire in
12
+ auto-reply path
13
+
14
+ ### Added
15
+ - [AGENR-PROBE] debug logging throughout command hook path for observability
16
+ (to be removed in a future cleanup pass)
17
+ - readAndParseSessionJsonl() helper to parse JSONL session files line by line
18
+ - runHandoffForSession() shared helper extracted from before_reset for reuse
19
+
20
+ ### Tests
21
+ - 5 new tests for command hook handoff behavior in index.test.ts
22
+
3
23
  ## [0.8.27] - 2026-02-24
4
24
 
5
25
  ### Changed
@@ -78,6 +78,7 @@ type HandoffMessage = {
78
78
  };
79
79
  declare function getBaseSessionPath(filePath: string): string;
80
80
  declare function readSessionsJson(sessionsDir: string): Promise<Record<string, unknown>>;
81
+ declare function readAndParseSessionJsonl(sessionFile: string): Promise<unknown[]>;
81
82
  declare function getSurfaceForSessionFile(sessionFilePath: string, sessionsJson: Record<string, unknown>): string;
82
83
  declare function readMessagesFromJsonl(filePath: string): Promise<HandoffMessage[]>;
83
84
  declare function findPriorResetFile(sessionsDir: string, currentSessionFile: string): Promise<string | null>;
@@ -90,6 +91,20 @@ declare function capTranscriptLength(params: {
90
91
  maxChars: number;
91
92
  }): string;
92
93
  declare function summarizeSessionForHandoff(currentRawMessages: BeforeResetEvent["messages"], sessionsDir: string, currentSessionFile: string, logger: PluginApi["logger"], streamSimpleImpl?: StreamSimpleFn): Promise<string | null>;
94
+ declare function runHandoffForSession(opts: {
95
+ messages: unknown[];
96
+ sessionFile: string | null;
97
+ sessionId: string;
98
+ sessionKey: string;
99
+ agentId: string;
100
+ agenrPath: string;
101
+ budget: number;
102
+ defaultProject: string | undefined;
103
+ storeConfig: Record<string, unknown>;
104
+ sessionsDir: string;
105
+ logger: PluginLogger | undefined;
106
+ source: "before_reset" | "command";
107
+ }): Promise<void>;
93
108
  declare const plugin: {
94
109
  id: string;
95
110
  name: string;
@@ -99,6 +114,7 @@ declare const plugin: {
99
114
  declare const __testing: {
100
115
  clearState(): void;
101
116
  readSessionsJson: typeof readSessionsJson;
117
+ readAndParseSessionJsonl: typeof readAndParseSessionJsonl;
102
118
  getBaseSessionPath: typeof getBaseSessionPath;
103
119
  getSurfaceForSessionFile: typeof getSurfaceForSessionFile;
104
120
  readMessagesFromJsonl: typeof readMessagesFromJsonl;
@@ -106,6 +122,7 @@ declare const __testing: {
106
122
  buildMergedTranscript: typeof buildMergedTranscript;
107
123
  capTranscriptLength: typeof capTranscriptLength;
108
124
  summarizeSessionForHandoff: typeof summarizeSessionForHandoff;
125
+ runHandoffForSession: typeof runHandoffForSession;
109
126
  };
110
127
 
111
128
  export { __testing, plugin as default };
@@ -917,6 +917,7 @@ var SKIP_SESSION_PATTERNS = [":subagent:", ":cron:"];
917
917
  var DEFAULT_MAX_SEEN_SESSIONS = 1e3;
918
918
  var seenSessions = /* @__PURE__ */ new Map();
919
919
  var sessionSignalState = /* @__PURE__ */ new Map();
920
+ var handoffSeenSessionIds = /* @__PURE__ */ new Set();
920
921
  var pluginDb = null;
921
922
  var pluginDbInit = null;
922
923
  var didRegisterDbShutdown = false;
@@ -1072,6 +1073,26 @@ async function readSessionsJson(sessionsDir) {
1072
1073
  return {};
1073
1074
  }
1074
1075
  }
1076
+ async function readAndParseSessionJsonl(sessionFile) {
1077
+ try {
1078
+ const raw = await fs.promises.readFile(sessionFile, "utf8");
1079
+ const messages = [];
1080
+ for (const line of raw.split("\n")) {
1081
+ const trimmed = line.trim();
1082
+ if (!trimmed) {
1083
+ continue;
1084
+ }
1085
+ try {
1086
+ const parsed = JSON.parse(trimmed);
1087
+ messages.push(parsed);
1088
+ } catch {
1089
+ }
1090
+ }
1091
+ return messages;
1092
+ } catch {
1093
+ return [];
1094
+ }
1095
+ }
1075
1096
  function getSurfaceForSessionFile(sessionFilePath, sessionsJson) {
1076
1097
  try {
1077
1098
  const normalizedTarget = path3.resolve(sessionFilePath);
@@ -1407,19 +1428,144 @@ async function retireFallbackHandoffEntries(params) {
1407
1428
  );
1408
1429
  }
1409
1430
  }
1431
+ function normalizeHandoffMessages(messages) {
1432
+ return messages.map((message) => {
1433
+ if (!isRecord2(message)) {
1434
+ return message;
1435
+ }
1436
+ if (message["role"] === "user" || message["role"] === "assistant") {
1437
+ return message;
1438
+ }
1439
+ if (message["type"] !== "message" || !isRecord2(message["message"])) {
1440
+ return message;
1441
+ }
1442
+ const parsedMessage = { ...message["message"] };
1443
+ if (typeof parsedMessage["timestamp"] !== "string" && typeof message["timestamp"] === "string") {
1444
+ parsedMessage["timestamp"] = message["timestamp"];
1445
+ }
1446
+ return parsedMessage;
1447
+ });
1448
+ }
1449
+ async function runHandoffForSession(opts) {
1450
+ const sessionId = opts.sessionId.trim() || opts.sessionKey;
1451
+ if (handoffSeenSessionIds.has(sessionId)) {
1452
+ process.stderr.write(
1453
+ `[AGENR-PROBE] ${opts.source} hook: dedup skip sessionId=${sessionId} source=${opts.source}
1454
+ `
1455
+ );
1456
+ return;
1457
+ }
1458
+ handoffSeenSessionIds.add(sessionId);
1459
+ const evictionTimer = setTimeout(() => {
1460
+ handoffSeenSessionIds.delete(sessionId);
1461
+ }, 6e4);
1462
+ if (typeof evictionTimer.unref === "function") {
1463
+ evictionTimer.unref();
1464
+ }
1465
+ const normalizedMessages = normalizeHandoffMessages(opts.messages);
1466
+ if (normalizedMessages.length === 0) {
1467
+ process.stderr.write(`[AGENR-PROBE] ${opts.source} hook: no messages after normalization source=${opts.source}
1468
+ `);
1469
+ return;
1470
+ }
1471
+ if (!opts.sessionFile) {
1472
+ opts.logger?.debug?.(`[agenr] ${opts.source}: no sessionFile in event, using fallback`);
1473
+ }
1474
+ const fallbackText = extractLastExchangeText(normalizedMessages);
1475
+ let fallbackEntrySubject = null;
1476
+ if (fallbackText) {
1477
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
1478
+ fallbackEntrySubject = `session handoff ${timestamp}`;
1479
+ const fallbackEntry = {
1480
+ entries: [
1481
+ {
1482
+ type: "event",
1483
+ importance: 9,
1484
+ subject: fallbackEntrySubject,
1485
+ content: fallbackText,
1486
+ tags: ["handoff", "session"]
1487
+ }
1488
+ ]
1489
+ };
1490
+ try {
1491
+ await runStoreTool(opts.agenrPath, fallbackEntry, opts.storeConfig, opts.defaultProject);
1492
+ opts.logger?.debug?.(`[agenr] ${opts.source}: fallback handoff stored`);
1493
+ } catch (err) {
1494
+ opts.logger?.debug?.(
1495
+ `[agenr] ${opts.source}: fallback store failed: ${err instanceof Error ? err.message : String(err)}`
1496
+ );
1497
+ fallbackEntrySubject = null;
1498
+ }
1499
+ }
1500
+ if (opts.sessionFile) {
1501
+ void testingApi.summarizeSessionForHandoff(
1502
+ normalizedMessages,
1503
+ opts.sessionsDir,
1504
+ opts.sessionFile,
1505
+ opts.logger ?? {
1506
+ warn: () => void 0,
1507
+ error: () => void 0
1508
+ }
1509
+ ).then(async (summary) => {
1510
+ if (!summary) {
1511
+ return;
1512
+ }
1513
+ if (fallbackEntrySubject && fallbackText) {
1514
+ await retireFallbackHandoffEntries({
1515
+ agenrPath: opts.agenrPath,
1516
+ budget: opts.budget,
1517
+ defaultProject: opts.defaultProject,
1518
+ fallbackSubject: fallbackEntrySubject,
1519
+ fallbackText,
1520
+ logger: opts.logger ?? {
1521
+ warn: () => void 0,
1522
+ error: () => void 0
1523
+ }
1524
+ });
1525
+ }
1526
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
1527
+ const llmEntry = {
1528
+ entries: [
1529
+ {
1530
+ type: "event",
1531
+ importance: 10,
1532
+ subject: `session handoff ${timestamp}`,
1533
+ content: summary,
1534
+ tags: ["handoff", "session"]
1535
+ }
1536
+ ]
1537
+ };
1538
+ try {
1539
+ await runStoreTool(opts.agenrPath, llmEntry, opts.storeConfig, opts.defaultProject);
1540
+ opts.logger?.debug?.(`[agenr] ${opts.source}: LLM handoff stored`);
1541
+ } catch (err) {
1542
+ opts.logger?.debug?.(
1543
+ `[agenr] ${opts.source}: LLM handoff store failed: ${err instanceof Error ? err.message : String(err)}`
1544
+ );
1545
+ }
1546
+ }).catch((err) => {
1547
+ opts.logger?.debug?.(
1548
+ `[agenr] ${opts.source}: LLM handoff rejected: ${err instanceof Error ? err.message : String(err)}`
1549
+ );
1550
+ });
1551
+ }
1552
+ }
1410
1553
  var testingApi = {
1411
1554
  clearState() {
1412
1555
  seenSessions.clear();
1413
1556
  sessionSignalState.clear();
1557
+ handoffSeenSessionIds.clear();
1414
1558
  },
1415
1559
  readSessionsJson,
1560
+ readAndParseSessionJsonl,
1416
1561
  getBaseSessionPath,
1417
1562
  getSurfaceForSessionFile,
1418
1563
  readMessagesFromJsonl,
1419
1564
  findPriorResetFile,
1420
1565
  buildMergedTranscript,
1421
1566
  capTranscriptLength,
1422
- summarizeSessionForHandoff
1567
+ summarizeSessionForHandoff,
1568
+ runHandoffForSession
1423
1569
  };
1424
1570
  var plugin = {
1425
1571
  id: "agenr",
@@ -1578,26 +1724,25 @@ ${formatted.trim()}`);
1578
1724
  api.on("before_reset", async (event, ctx) => {
1579
1725
  try {
1580
1726
  process.stderr.write(
1581
- `[AGENR-PROBE] before_reset FIRED sessionKey=${ctx.sessionKey ?? "none"} msgs=${Array.isArray(event.messages) ? event.messages.length : "non-array"}
1727
+ `[AGENR-PROBE] before_reset FIRED sessionKey=${ctx.sessionKey ?? "none"} msgs=${Array.isArray(event.messages) ? event.messages.length : "non-array"} source=before_reset
1582
1728
  `
1583
1729
  );
1584
- api.logger.info?.(`[agenr] before_reset: fired sessionKey=${ctx.sessionKey ?? "none"} agentId=${ctx.agentId ?? "none"} msgs=${Array.isArray(event.messages) ? event.messages.length : "non-array"} sessionFile=${event.sessionFile ?? "none"}`);
1730
+ api.logger.info?.(`[agenr] before_reset: fired sessionKey=${ctx.sessionKey ?? "none"} agentId=${ctx.agentId ?? "none"} msgs=${Array.isArray(event.messages) ? event.messages.length : "non-array"} sessionFile=${event.sessionFile ?? "none"} source=before_reset`);
1585
1731
  const sessionKey = ctx.sessionKey;
1586
1732
  if (!sessionKey) {
1587
1733
  return;
1588
1734
  }
1589
- process.stderr.write(`[AGENR-PROBE] before_reset: sessionKey ok
1735
+ process.stderr.write(`[AGENR-PROBE] before_reset: sessionKey ok source=before_reset
1590
1736
  `);
1591
1737
  const messages = event.messages;
1592
1738
  if (!Array.isArray(messages) || messages.length === 0) {
1593
1739
  return;
1594
1740
  }
1595
- process.stderr.write(`[AGENR-PROBE] before_reset: messages ok count=${messages.length}
1596
- `);
1741
+ process.stderr.write(
1742
+ `[AGENR-PROBE] before_reset: messages ok count=${messages.length} source=before_reset
1743
+ `
1744
+ );
1597
1745
  const currentSessionFile = typeof event.sessionFile === "string" && event.sessionFile.trim() ? event.sessionFile.trim() : null;
1598
- if (!currentSessionFile) {
1599
- api.logger.debug?.("[agenr] before_reset: no sessionFile in event, using fallback");
1600
- }
1601
1746
  const agentId = ctx.agentId?.trim() || "main";
1602
1747
  const sessionsDir = config?.sessionsDir ?? path3.join(os.homedir(), `.openclaw/agents/${agentId}/sessions`);
1603
1748
  const agenrPath = resolveAgenrPath(config);
@@ -1607,73 +1752,21 @@ ${formatted.trim()}`);
1607
1752
  ...config,
1608
1753
  logger: api.logger
1609
1754
  };
1610
- const fallbackText = extractLastExchangeText(messages);
1611
- let fallbackEntrySubject = null;
1612
- if (fallbackText) {
1613
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
1614
- fallbackEntrySubject = `session handoff ${timestamp}`;
1615
- const fallbackEntry = {
1616
- entries: [
1617
- {
1618
- type: "event",
1619
- importance: 9,
1620
- subject: fallbackEntrySubject,
1621
- content: fallbackText,
1622
- tags: ["handoff", "session"]
1623
- }
1624
- ]
1625
- };
1626
- try {
1627
- await runStoreTool(agenrPath, fallbackEntry, storeConfig, defaultProject);
1628
- api.logger.debug?.("[agenr] before_reset: fallback handoff stored");
1629
- } catch (err) {
1630
- api.logger.debug?.(
1631
- `[agenr] before_reset: fallback store failed: ${err instanceof Error ? err.message : String(err)}`
1632
- );
1633
- fallbackEntrySubject = null;
1634
- }
1635
- }
1636
- if (currentSessionFile) {
1637
- void testingApi.summarizeSessionForHandoff(messages, sessionsDir, currentSessionFile, api.logger).then(async (summary) => {
1638
- if (!summary) {
1639
- return;
1640
- }
1641
- if (fallbackEntrySubject && fallbackText) {
1642
- await retireFallbackHandoffEntries({
1643
- agenrPath,
1644
- budget,
1645
- defaultProject,
1646
- fallbackSubject: fallbackEntrySubject,
1647
- fallbackText,
1648
- logger: api.logger
1649
- });
1650
- }
1651
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
1652
- const llmEntry = {
1653
- entries: [
1654
- {
1655
- type: "event",
1656
- importance: 10,
1657
- subject: `session handoff ${timestamp}`,
1658
- content: summary,
1659
- tags: ["handoff", "session"]
1660
- }
1661
- ]
1662
- };
1663
- try {
1664
- await runStoreTool(agenrPath, llmEntry, storeConfig, defaultProject);
1665
- api.logger.debug?.("[agenr] before_reset: LLM handoff stored");
1666
- } catch (err) {
1667
- api.logger.debug?.(
1668
- `[agenr] before_reset: LLM handoff store failed: ${err instanceof Error ? err.message : String(err)}`
1669
- );
1670
- }
1671
- }).catch((err) => {
1672
- api.logger.debug?.(
1673
- `[agenr] before_reset: LLM handoff rejected: ${err instanceof Error ? err.message : String(err)}`
1674
- );
1675
- });
1676
- }
1755
+ const sessionId = ctx.sessionId ?? ctx.sessionKey ?? sessionKey;
1756
+ await runHandoffForSession({
1757
+ messages,
1758
+ sessionFile: currentSessionFile,
1759
+ sessionId,
1760
+ sessionKey,
1761
+ agentId,
1762
+ agenrPath,
1763
+ budget,
1764
+ defaultProject,
1765
+ storeConfig,
1766
+ sessionsDir,
1767
+ logger: api.logger,
1768
+ source: "before_reset"
1769
+ });
1677
1770
  } catch (err) {
1678
1771
  api.logger.warn(
1679
1772
  `agenr plugin before_reset failed: ${err instanceof Error ? err.message : String(err)}`
@@ -1682,6 +1775,81 @@ ${formatted.trim()}`);
1682
1775
  });
1683
1776
  process.stderr.write(`[AGENR-PROBE] before_reset hook registered
1684
1777
  `);
1778
+ api.on(
1779
+ "command",
1780
+ async (event, ctx) => {
1781
+ try {
1782
+ process.stderr.write(
1783
+ `[AGENR-PROBE] command hook FIRED action=${event.action ?? "none"} sessionKey=${event.sessionKey ?? "none"} source=${String(event.context?.commandSource ?? "unknown")}
1784
+ `
1785
+ );
1786
+ if (event.action !== "new" && event.action !== "reset") {
1787
+ process.stderr.write(
1788
+ `[AGENR-PROBE] command hook: skipping action=${event.action ?? "none"} source=command
1789
+ `
1790
+ );
1791
+ return;
1792
+ }
1793
+ const sessionKey = event.sessionKey;
1794
+ if (!sessionKey) {
1795
+ process.stderr.write("[AGENR-PROBE] command hook: no sessionKey, skipping source=command\n");
1796
+ return;
1797
+ }
1798
+ const sessionFile = event.context?.sessionEntry?.sessionFile ?? null;
1799
+ const sessionId = event.context?.sessionEntry?.sessionId ?? sessionKey;
1800
+ process.stderr.write(
1801
+ `[AGENR-PROBE] command hook: new/reset detected sessionKey=${sessionKey} sessionFile=${sessionFile ?? "none"} source=command
1802
+ `
1803
+ );
1804
+ api.logger.info?.(
1805
+ `[agenr] command hook: fired action=${event.action} sessionKey=${sessionKey} sessionFile=${sessionFile ?? "none"} source=command`
1806
+ );
1807
+ let messages = [];
1808
+ if (sessionFile) {
1809
+ messages = await testingApi.readAndParseSessionJsonl(sessionFile);
1810
+ process.stderr.write(
1811
+ `[AGENR-PROBE] command hook: parsed ${messages.length} messages from JSONL source=command
1812
+ `
1813
+ );
1814
+ } else {
1815
+ process.stderr.write("[AGENR-PROBE] command hook: no sessionFile available source=command\n");
1816
+ }
1817
+ if (messages.length === 0) {
1818
+ process.stderr.write("[AGENR-PROBE] command hook: no messages, skipping handoff source=command\n");
1819
+ return;
1820
+ }
1821
+ const agentId = ctx.agentId?.trim() || "main";
1822
+ const sessionsDir = config?.sessionsDir ?? path3.join(os.homedir(), `.openclaw/agents/${agentId}/sessions`);
1823
+ const agenrPath = resolveAgenrPath(config);
1824
+ const defaultProject = config?.project?.trim() || void 0;
1825
+ const budget = resolveBudget(config);
1826
+ const storeConfig = {
1827
+ ...config,
1828
+ logger: api.logger
1829
+ };
1830
+ await runHandoffForSession({
1831
+ messages,
1832
+ sessionFile,
1833
+ sessionId,
1834
+ sessionKey,
1835
+ agentId,
1836
+ agenrPath,
1837
+ budget,
1838
+ defaultProject,
1839
+ storeConfig,
1840
+ sessionsDir,
1841
+ logger: api.logger,
1842
+ source: "command"
1843
+ });
1844
+ process.stderr.write("[AGENR-PROBE] command hook: handoff complete source=command\n");
1845
+ } catch (err) {
1846
+ api.logger.warn(
1847
+ `agenr plugin command hook failed: ${err instanceof Error ? err.message : String(err)}`
1848
+ );
1849
+ }
1850
+ }
1851
+ );
1852
+ process.stderr.write("[AGENR-PROBE] command hook registered\n");
1685
1853
  if (api.registerTool) {
1686
1854
  if (config?.enabled === false) {
1687
1855
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenr",
3
- "version": "0.8.27",
3
+ "version": "0.8.28",
4
4
  "openclaw": {
5
5
  "extensions": [
6
6
  "dist/openclaw-plugin/index.js"