@loggie-ai/openclaw-plugin 0.1.0 → 0.1.1

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 CHANGED
@@ -15,7 +15,8 @@ OpenClaw inbound channel turn for the configured agent.
15
15
  3. The monitor opens a WebSocket to `baseUrl + socketPath`, authenticating with
16
16
  either `Authorization: Bearer <token>` or `x-api-key: <token>`.
17
17
  4. Before opening the socket, the plugin loads the durable cursor from OpenClaw
18
- runtime state and appends it to the URL as `?cursor=<lastCursor>`.
18
+ trusted runtime state when available, otherwise from the file-backed cursor
19
+ store, and appends it to the URL as `?cursor=<lastCursor>`.
19
20
  5. Loggie sends event envelopes. The plugin validates the envelope, skips
20
21
  duplicates or cursor regressions, and processes `meeting.transcript.ready`.
21
22
  6. If the event includes `payload.detailPath`, the plugin fetches that detail
@@ -144,9 +145,13 @@ Session identity is deterministic:
144
145
  - Embedded run session id:
145
146
  `loggie-<accountId>-<meetingScheduleId|meetingId|eventId>`
146
147
 
147
- The cursor store is required to be durable. Runtime startup fails if OpenClaw
148
- does not provide a keyed runtime state store. This prevents silent in-memory
149
- cursor loss and duplicate transcript dispatch after restart.
148
+ The cursor store is always durable. Trusted OpenClaw installs use
149
+ `runtime.state.openKeyedStore({ namespace: "loggie-cursors" })`. Plain external
150
+ npm installs that do not receive the trusted runtime state API fall back to a
151
+ file-backed store at `runtime.state.resolveStateDir()/loggie-cursors.json` when
152
+ OpenClaw exposes a state directory, or `~/.openclaw/state/loggie-cursors.json`
153
+ otherwise. Startup does not fail solely because trusted state is unavailable,
154
+ and the fallback still persists replay cursors across gateway restarts.
150
155
 
151
156
  The plugin keeps a bounded set of recently seen event ids and skips:
152
157
 
@@ -21,6 +21,12 @@ declare const _default: {
21
21
  connected: {};
22
22
  authenticated: {};
23
23
  caughtUp: {};
24
+ cursorStoreMode: {} | null;
25
+ cursorPersistenceFailed: {};
26
+ lastCursorPersistenceAt: {} | null;
27
+ lastCursorPersistenceError: {} | null;
28
+ lastCursorPersistenceEventId: {} | null;
29
+ lastCursorPersistenceCursor: {} | null;
24
30
  lastEventId: {} | null;
25
31
  lastCursor: {} | null;
26
32
  lastSequence: {} | null;
@@ -22,6 +22,12 @@ export declare const loggiePlugin: {
22
22
  connected: {};
23
23
  authenticated: {};
24
24
  caughtUp: {};
25
+ cursorStoreMode: {} | null;
26
+ cursorPersistenceFailed: {};
27
+ lastCursorPersistenceAt: {} | null;
28
+ lastCursorPersistenceError: {} | null;
29
+ lastCursorPersistenceEventId: {} | null;
30
+ lastCursorPersistenceCursor: {} | null;
25
31
  lastEventId: {} | null;
26
32
  lastCursor: {} | null;
27
33
  lastSequence: {} | null;
@@ -37,9 +37,14 @@ export type LoggieCursorDecision = {
37
37
  reason: "duplicate_event" | "cursor_regression";
38
38
  };
39
39
  export type LoggieCursorStore = {
40
+ readonly mode?: LoggieCursorStoreMode;
40
41
  load(accountId: string, agentProfileId: string): Promise<LoggieCursorRecord | undefined>;
41
42
  save(record: LoggieCursorRecord): Promise<void>;
42
43
  };
44
+ export type LoggieCursorStoreMode = "trusted" | "file";
45
+ export type RuntimeCursorStoreOptions = {
46
+ warn?: (message: string) => void;
47
+ };
43
48
  export declare function createEmptyCursorRecord(params: {
44
49
  accountId: string;
45
50
  agentProfileId: string;
@@ -73,8 +78,10 @@ export declare function deadLetterEvent(params: {
73
78
  now?: Date;
74
79
  }): LoggieCursorRecord;
75
80
  export declare function createMemoryCursorStore(initial?: LoggieCursorRecord[]): LoggieCursorStore;
81
+ export declare function createFileCursorStore(filePath?: string): LoggieCursorStore;
76
82
  export declare function createRuntimeCursorStore(runtime: {
77
83
  state?: {
84
+ resolveStateDir?: () => string;
78
85
  openKeyedStore?: <T>(opts: {
79
86
  namespace: string;
80
87
  maxEntries: number;
@@ -83,4 +90,4 @@ export declare function createRuntimeCursorStore(runtime: {
83
90
  register(key: string, value: T): Promise<void>;
84
91
  };
85
92
  };
86
- }): LoggieCursorStore;
93
+ }, options?: RuntimeCursorStoreOptions): LoggieCursorStore;
@@ -1,6 +1,10 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
1
4
  const MAX_SEEN_EVENT_IDS = 500;
2
5
  const MAX_FAILED_EVENTS = 50;
3
6
  const MAX_DEAD_LETTERED_EVENTS = 100;
7
+ const warnedFileCursorStorePaths = new Set();
4
8
  export function createEmptyCursorRecord(params) {
5
9
  return {
6
10
  accountId: params.accountId,
@@ -111,19 +115,86 @@ export function createMemoryCursorStore(initial) {
111
115
  },
112
116
  };
113
117
  }
114
- export function createRuntimeCursorStore(runtime) {
118
+ export function createFileCursorStore(filePath = defaultCursorStorePath()) {
119
+ let pending = Promise.resolve();
120
+ async function withFileLock(operation) {
121
+ const result = pending.then(operation, operation);
122
+ pending = result.then(() => undefined, () => undefined);
123
+ return result;
124
+ }
125
+ return {
126
+ mode: "file",
127
+ load(accountId, agentProfileId) {
128
+ return withFileLock(async () => {
129
+ const records = await readCursorFile(filePath);
130
+ return records[cursorKey(accountId, agentProfileId)];
131
+ });
132
+ },
133
+ save(record) {
134
+ return withFileLock(async () => {
135
+ const records = await readCursorFile(filePath);
136
+ records[cursorKey(record.accountId, record.agentProfileId)] = record;
137
+ await writeCursorFile(filePath, records);
138
+ });
139
+ },
140
+ };
141
+ }
142
+ export function createRuntimeCursorStore(runtime, options = {}) {
115
143
  const store = runtime.state?.openKeyedStore?.({
116
144
  namespace: "loggie-cursors",
117
145
  maxEntries: 1000,
118
146
  });
119
147
  if (!store) {
120
- throw new Error("Loggie durable cursor store is unavailable");
148
+ const stateDir = runtime.state?.resolveStateDir?.();
149
+ const filePath = stateDir ? join(stateDir, "loggie-cursors.json") : defaultCursorStorePath();
150
+ warnFileCursorStoreFallbackOnce(filePath, options.warn);
151
+ return createFileCursorStore(filePath);
121
152
  }
122
153
  return {
154
+ mode: "trusted",
123
155
  load: (accountId, agentProfileId) => store.lookup(cursorKey(accountId, agentProfileId)),
124
156
  save: (record) => store.register(cursorKey(record.accountId, record.agentProfileId), record),
125
157
  };
126
158
  }
159
+ function defaultCursorStorePath() {
160
+ return join(homedir(), ".openclaw", "state", "loggie-cursors.json");
161
+ }
162
+ function warnFileCursorStoreFallbackOnce(filePath, warn) {
163
+ if (!warn || warnedFileCursorStorePaths.has(filePath)) {
164
+ return;
165
+ }
166
+ warnedFileCursorStorePaths.add(filePath);
167
+ warn(`Loggie trusted runtime cursor store is unavailable; using file-backed cursor store at ${filePath}. Cursor durability now depends on filesystem access to that path.`);
168
+ }
169
+ async function readCursorFile(filePath) {
170
+ try {
171
+ const contents = await fs.readFile(filePath, "utf8");
172
+ const parsed = JSON.parse(contents);
173
+ if (isCursorFile(parsed)) {
174
+ return parsed;
175
+ }
176
+ throw new Error(`Loggie cursor store file has invalid JSON shape: ${filePath}`);
177
+ }
178
+ catch (error) {
179
+ if (isNodeError(error) && error.code === "ENOENT") {
180
+ return {};
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ async function writeCursorFile(filePath, records) {
186
+ const directory = dirname(filePath);
187
+ await fs.mkdir(directory, { recursive: true });
188
+ const tempPath = join(directory, `.loggie-cursors.${process.pid}.${Date.now()}.tmp`);
189
+ await fs.writeFile(tempPath, `${JSON.stringify(records, null, 2)}\n`, "utf8");
190
+ await fs.rename(tempPath, filePath);
191
+ }
192
+ function isCursorFile(value) {
193
+ return !!value && typeof value === "object" && !Array.isArray(value);
194
+ }
195
+ function isNodeError(error) {
196
+ return error instanceof Error && "code" in error;
197
+ }
127
198
  function cursorKey(accountId, agentProfileId) {
128
199
  return `${accountId}:${agentProfileId}`;
129
200
  }
@@ -1,7 +1,7 @@
1
1
  import { createRuntimeCursorStore } from "../cursor-store.js";
2
2
  import { buildLoggieAuthHeaders, buildLoggiePingMessage, buildLoggieResumeMessage, buildLoggieWebSocketUrl, decodeLoggieSocketMessage, defaultLoggieSocketFactory, } from "../loggie-client.js";
3
3
  import { createOpenClawTranscriptDispatcher } from "./transcript-dispatch.js";
4
- import { handleLoggieEvent } from "./event-handler.js";
4
+ import { handleLoggieEvent, isLoggieCursorPersistenceError } from "./event-handler.js";
5
5
  export async function monitorLoggieAccount(params) {
6
6
  if (!params.account.enabled) {
7
7
  params.log?.info?.(`[${params.account.accountId}] Loggie account disabled`);
@@ -13,7 +13,7 @@ export async function monitorLoggieAccount(params) {
13
13
  if (!params.channelRuntime && !params.dispatcher) {
14
14
  throw new Error("Loggie channelRuntime is required for transcript dispatch");
15
15
  }
16
- const cursorStore = params.cursorStore ?? createRuntimeCursorStore(params.runtime);
16
+ const cursorStore = params.cursorStore ?? createRuntimeCursorStore(params.runtime, { warn: params.log?.warn });
17
17
  const dispatcher = params.dispatcher ??
18
18
  createOpenClawTranscriptDispatcher({
19
19
  account: params.account,
@@ -38,6 +38,24 @@ export async function monitorLoggieAccount(params) {
38
38
  if (params.abortSignal?.aborted) {
39
39
  return;
40
40
  }
41
+ if (isLoggieCursorPersistenceError(error)) {
42
+ const failedAt = Date.now();
43
+ params.setStatus?.({
44
+ ...(params.getStatus?.() ?? { accountId: params.account.accountId }),
45
+ running: false,
46
+ connected: false,
47
+ authenticated: false,
48
+ ...cursorStoreStatus(cursorStore.mode),
49
+ cursorPersistenceFailed: true,
50
+ lastCursorPersistenceAt: failedAt,
51
+ lastCursorPersistenceError: error.message,
52
+ lastCursorPersistenceEventId: error.eventId,
53
+ lastCursorPersistenceCursor: error.cursor ?? null,
54
+ lastError: error.message,
55
+ });
56
+ params.log?.warn?.(`[${params.account.accountId}] Loggie cursor persistence failed; stopping monitor to avoid duplicate transcript dispatch: ${error.message}`);
57
+ throw error;
58
+ }
41
59
  attempts += 1;
42
60
  const delayMs = Math.min(params.account.reconnect.maxMs, params.account.reconnect.minMs * 2 ** Math.max(0, attempts - 1));
43
61
  params.setStatus?.({
@@ -46,6 +64,8 @@ export async function monitorLoggieAccount(params) {
46
64
  connected: false,
47
65
  authenticated: false,
48
66
  reconnectAttempts: attempts,
67
+ ...cursorStoreStatus(cursorStore.mode),
68
+ ...cursorPersistenceOkStatus(),
49
69
  lastError: error instanceof Error ? error.message : String(error),
50
70
  });
51
71
  params.log?.warn?.(`[${params.account.accountId}] Loggie socket failed; reconnecting in ${delayMs}ms`);
@@ -64,6 +84,8 @@ export async function runLoggieSocketOnce(params) {
64
84
  running: true,
65
85
  connected: false,
66
86
  authenticated: false,
87
+ ...cursorStoreStatus(params.cursorStore.mode),
88
+ ...cursorPersistenceOkStatus(),
67
89
  lastStartAt: Date.now(),
68
90
  });
69
91
  await new Promise((resolve, reject) => {
@@ -145,6 +167,8 @@ export async function runLoggieSocketOnce(params) {
145
167
  connected: true,
146
168
  authenticated: true,
147
169
  reconnectAttempts: 0,
170
+ ...cursorStoreStatus(params.cursorStore.mode),
171
+ ...cursorPersistenceOkStatus(),
148
172
  lastConnectedAt: connectedAt,
149
173
  lastError: null,
150
174
  });
@@ -211,6 +235,8 @@ export async function runLoggieSocketOnce(params) {
211
235
  connected: true,
212
236
  authenticated: true,
213
237
  caughtUp: true,
238
+ ...cursorStoreStatus(params.cursorStore.mode),
239
+ ...cursorPersistenceOkStatus(),
214
240
  lastEventAt: eventAt,
215
241
  ...(result.status === "dispatched" ? { lastDispatchAt: eventAt } : {}),
216
242
  ...(deadLettered && result.event
@@ -253,6 +279,18 @@ export async function runLoggieSocketOnce(params) {
253
279
  });
254
280
  });
255
281
  }
282
+ function cursorStoreStatus(mode) {
283
+ return mode ? { cursorStoreMode: mode } : {};
284
+ }
285
+ function cursorPersistenceOkStatus() {
286
+ return {
287
+ cursorPersistenceFailed: false,
288
+ lastCursorPersistenceAt: null,
289
+ lastCursorPersistenceError: null,
290
+ lastCursorPersistenceEventId: null,
291
+ lastCursorPersistenceCursor: null,
292
+ };
293
+ }
256
294
  function parseControlMessage(value) {
257
295
  if (value === null || typeof value !== "object" || !("type" in value)) {
258
296
  return { type: "none" };
@@ -18,6 +18,20 @@ export type LoggieEventHandleResult = {
18
18
  reason: string;
19
19
  retryable: boolean;
20
20
  };
21
+ export declare class LoggieCursorPersistenceError extends Error {
22
+ readonly eventId: string;
23
+ readonly cursor?: string;
24
+ readonly accountId: string;
25
+ readonly agentProfileId: string;
26
+ readonly phase: "post_dispatch";
27
+ constructor(params: {
28
+ event: LoggieEventEnvelope;
29
+ account: ResolvedLoggieAccount;
30
+ phase: "post_dispatch";
31
+ cause: unknown;
32
+ });
33
+ }
34
+ export declare function isLoggieCursorPersistenceError(error: unknown): error is LoggieCursorPersistenceError;
21
35
  export declare function handleLoggieEvent(params: {
22
36
  raw: unknown;
23
37
  account: ResolvedLoggieAccount;
@@ -1,6 +1,28 @@
1
1
  import { advanceCursor, deadLetterEvent, decideCursor, recordEventFailure, } from "../cursor-store.js";
2
2
  import { isTranscriptReadyEvent, parseLoggieEventEnvelope, } from "../event-types.js";
3
3
  const MAX_EVENT_DISPATCH_ATTEMPTS = 3;
4
+ export class LoggieCursorPersistenceError extends Error {
5
+ eventId;
6
+ cursor;
7
+ accountId;
8
+ agentProfileId;
9
+ phase;
10
+ constructor(params) {
11
+ const causeMessage = params.cause instanceof Error ? params.cause.message : String(params.cause);
12
+ super(`Loggie cursor persistence failed after dispatching event ${params.event.eventId}: ${causeMessage}`, {
13
+ cause: params.cause,
14
+ });
15
+ this.name = "LoggieCursorPersistenceError";
16
+ this.eventId = params.event.eventId;
17
+ this.cursor = params.event.cursor;
18
+ this.accountId = params.account.accountId;
19
+ this.agentProfileId = params.account.agentProfileId;
20
+ this.phase = params.phase;
21
+ }
22
+ }
23
+ export function isLoggieCursorPersistenceError(error) {
24
+ return error instanceof LoggieCursorPersistenceError;
25
+ }
4
26
  export async function handleLoggieEvent(params) {
5
27
  const parsed = parseLoggieEventEnvelope(params.raw);
6
28
  if (!parsed.ok) {
@@ -79,12 +101,23 @@ export async function handleLoggieEvent(params) {
79
101
  },
80
102
  };
81
103
  }
82
- await params.cursorStore.save(advanceCursor({
104
+ const advanced = advanceCursor({
83
105
  record: cursor,
84
106
  event,
85
107
  accountId: params.account.accountId,
86
108
  agentProfileId: params.account.agentProfileId,
87
109
  now: params.now,
88
- }));
110
+ });
111
+ try {
112
+ await params.cursorStore.save(advanced);
113
+ }
114
+ catch (error) {
115
+ throw new LoggieCursorPersistenceError({
116
+ event,
117
+ account: params.account,
118
+ phase: "post_dispatch",
119
+ cause: error,
120
+ });
121
+ }
89
122
  return { status: "dispatched", event };
90
123
  }
@@ -2,7 +2,14 @@ import { fetchLoggieTranscriptDetail } from "../loggie-client.js";
2
2
  import { buildLoggieRouteSessionKey, buildLoggieSessionId, meetingConversationId } from "../routing.js";
3
3
  import { formatTranscriptReadyPrompt } from "./transcript-format.js";
4
4
  export function createOpenClawTranscriptDispatcher(params) {
5
- const agentRuntime = params.runtime.agent;
5
+ let agentRuntimePromise;
6
+ const getAgentRuntime = () => {
7
+ agentRuntimePromise ??= resolveAgentRuntime(params.runtime).catch((error) => {
8
+ agentRuntimePromise = undefined;
9
+ throw error;
10
+ });
11
+ return agentRuntimePromise;
12
+ };
6
13
  return {
7
14
  async dispatchTranscriptReady(event) {
8
15
  const routeSessionKey = buildLoggieRouteSessionKey({ account: params.account, event });
@@ -86,6 +93,7 @@ export function createOpenClawTranscriptDispatcher(params) {
86
93
  },
87
94
  },
88
95
  runDispatch: async () => {
96
+ const agentRuntime = await getAgentRuntime();
89
97
  const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(params.cfg, params.account.agentId);
90
98
  await agentRuntime.ensureAgentWorkspace?.({ dir: workspaceDir });
91
99
  return agentRuntime.runEmbeddedAgent({
@@ -122,3 +130,44 @@ export function createOpenClawTranscriptDispatcher(params) {
122
130
  },
123
131
  };
124
132
  }
133
+ async function resolveAgentRuntime(runtime) {
134
+ const runtimeAgent = runtime.agent;
135
+ if (hasRequiredAgentHelpers(runtimeAgent)) {
136
+ return runtimeAgent;
137
+ }
138
+ const extensionApi = (await import("openclaw/extension-api"));
139
+ return {
140
+ runEmbeddedAgent: readRunEmbeddedAgent(runtimeAgent, extensionApi),
141
+ resolveAgentWorkspaceDir: readHelper(runtimeAgent, extensionApi, "resolveAgentWorkspaceDir"),
142
+ resolveAgentDir: readHelper(runtimeAgent, extensionApi, "resolveAgentDir"),
143
+ resolveAgentTimeoutMs: readHelper(runtimeAgent, extensionApi, "resolveAgentTimeoutMs"),
144
+ ensureAgentWorkspace: readOptionalHelper(runtimeAgent, extensionApi, "ensureAgentWorkspace"),
145
+ };
146
+ }
147
+ function hasRequiredAgentHelpers(value) {
148
+ return (typeof value?.runEmbeddedAgent === "function" &&
149
+ typeof value.resolveAgentWorkspaceDir === "function" &&
150
+ typeof value.resolveAgentDir === "function" &&
151
+ typeof value.resolveAgentTimeoutMs === "function");
152
+ }
153
+ function readRunEmbeddedAgent(runtimeAgent, extensionApi) {
154
+ const helper = runtimeAgent?.runEmbeddedAgent ?? extensionApi.runEmbeddedAgent ?? extensionApi.runEmbeddedPiAgent;
155
+ if (typeof helper !== "function") {
156
+ throw new Error("Loggie transcript dispatch requires OpenClaw agent helper: runEmbeddedAgent");
157
+ }
158
+ return helper;
159
+ }
160
+ function readHelper(runtimeAgent, extensionApi, key) {
161
+ const helper = runtimeAgent?.[key] ?? extensionApi[key];
162
+ if (typeof helper !== "function") {
163
+ throw new Error(`Loggie transcript dispatch requires OpenClaw agent helper: ${key}`);
164
+ }
165
+ return helper;
166
+ }
167
+ function readOptionalHelper(runtimeAgent, extensionApi, key) {
168
+ const helper = runtimeAgent?.[key] ?? extensionApi[key];
169
+ if (helper !== undefined && typeof helper !== "function") {
170
+ throw new Error(`Loggie transcript dispatch requires OpenClaw agent helper: ${key}`);
171
+ }
172
+ return helper;
173
+ }
@@ -3,6 +3,12 @@ export type LoggieRuntimeExtra = {
3
3
  connected?: boolean;
4
4
  authenticated?: boolean;
5
5
  caughtUp?: boolean;
6
+ cursorStoreMode?: "trusted" | "file" | null;
7
+ cursorPersistenceFailed?: boolean;
8
+ lastCursorPersistenceAt?: number | null;
9
+ lastCursorPersistenceError?: string | null;
10
+ lastCursorPersistenceEventId?: string | null;
11
+ lastCursorPersistenceCursor?: string | null;
6
12
  lastEventId?: string | null;
7
13
  lastCursor?: string | null;
8
14
  lastSequence?: number | null;
@@ -33,6 +39,12 @@ export declare function buildLoggieAccountStatusSnapshot(params: {
33
39
  connected: {};
34
40
  authenticated: {};
35
41
  caughtUp: {};
42
+ cursorStoreMode: {} | null;
43
+ cursorPersistenceFailed: {};
44
+ lastCursorPersistenceAt: {} | null;
45
+ lastCursorPersistenceError: {} | null;
46
+ lastCursorPersistenceEventId: {} | null;
47
+ lastCursorPersistenceCursor: {} | null;
36
48
  lastEventId: {} | null;
37
49
  lastCursor: {} | null;
38
50
  lastSequence: {} | null;
@@ -3,6 +3,12 @@ export const defaultLoggieRuntimeState = createDefaultChannelRuntimeState("defau
3
3
  connected: false,
4
4
  authenticated: false,
5
5
  caughtUp: false,
6
+ cursorStoreMode: null,
7
+ cursorPersistenceFailed: false,
8
+ lastCursorPersistenceAt: null,
9
+ lastCursorPersistenceError: null,
10
+ lastCursorPersistenceEventId: null,
11
+ lastCursorPersistenceCursor: null,
6
12
  lastEventId: null,
7
13
  lastCursor: null,
8
14
  lastSequence: null,
@@ -32,6 +38,12 @@ export function buildLoggieAccountStatusSnapshot(params) {
32
38
  connected: params.runtime?.connected ?? false,
33
39
  authenticated: params.runtime?.authenticated ?? false,
34
40
  caughtUp: params.runtime?.caughtUp ?? false,
41
+ cursorStoreMode: params.runtime?.cursorStoreMode ?? null,
42
+ cursorPersistenceFailed: params.runtime?.cursorPersistenceFailed ?? false,
43
+ lastCursorPersistenceAt: params.runtime?.lastCursorPersistenceAt ?? null,
44
+ lastCursorPersistenceError: params.runtime?.lastCursorPersistenceError ?? null,
45
+ lastCursorPersistenceEventId: params.runtime?.lastCursorPersistenceEventId ?? null,
46
+ lastCursorPersistenceCursor: params.runtime?.lastCursorPersistenceCursor ?? null,
35
47
  lastEventId: params.runtime?.lastEventId ?? null,
36
48
  lastCursor: params.runtime?.lastCursor ?? null,
37
49
  lastSequence: params.runtime?.lastSequence ?? null,
@@ -2,7 +2,7 @@
2
2
  "id": "loggie",
3
3
  "kind": "channel",
4
4
  "name": "Loggie",
5
- "version": "0.1.0",
5
+ "version": "0.1.1",
6
6
  "description": "OpenClaw channel plugin for durable Loggie meeting transcript events over WebSocket.",
7
7
  "activation": {
8
8
  "onStartup": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loggie-ai/openclaw-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenClaw channel plugin for Loggie meeting transcript events.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,3 +1,6 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
1
4
  import type { LoggieEventEnvelope } from "./event-types.js";
2
5
 
3
6
  export type LoggieCursorRecord = {
@@ -39,13 +42,21 @@ export type LoggieCursorDecision =
39
42
  | { action: "skip"; reason: "duplicate_event" | "cursor_regression" };
40
43
 
41
44
  export type LoggieCursorStore = {
45
+ readonly mode?: LoggieCursorStoreMode;
42
46
  load(accountId: string, agentProfileId: string): Promise<LoggieCursorRecord | undefined>;
43
47
  save(record: LoggieCursorRecord): Promise<void>;
44
48
  };
45
49
 
50
+ export type LoggieCursorStoreMode = "trusted" | "file";
51
+
52
+ export type RuntimeCursorStoreOptions = {
53
+ warn?: (message: string) => void;
54
+ };
55
+
46
56
  const MAX_SEEN_EVENT_IDS = 500;
47
57
  const MAX_FAILED_EVENTS = 50;
48
58
  const MAX_DEAD_LETTERED_EVENTS = 100;
59
+ const warnedFileCursorStorePaths = new Set<string>();
49
60
 
50
61
  export function createEmptyCursorRecord(params: {
51
62
  accountId: string;
@@ -193,27 +204,111 @@ export function createMemoryCursorStore(initial?: LoggieCursorRecord[]): LoggieC
193
204
  };
194
205
  }
195
206
 
196
- export function createRuntimeCursorStore(runtime: {
197
- state?: {
198
- openKeyedStore?: <T>(opts: { namespace: string; maxEntries: number }) => {
199
- lookup(key: string): Promise<T | undefined>;
200
- register(key: string, value: T): Promise<void>;
201
- };
207
+ export function createFileCursorStore(filePath = defaultCursorStorePath()): LoggieCursorStore {
208
+ let pending: Promise<void> = Promise.resolve();
209
+
210
+ async function withFileLock<T>(operation: () => Promise<T>): Promise<T> {
211
+ const result = pending.then(operation, operation);
212
+ pending = result.then(
213
+ () => undefined,
214
+ () => undefined,
215
+ );
216
+ return result;
217
+ }
218
+
219
+ return {
220
+ mode: "file",
221
+ load(accountId, agentProfileId) {
222
+ return withFileLock(async () => {
223
+ const records = await readCursorFile(filePath);
224
+ return records[cursorKey(accountId, agentProfileId)];
225
+ });
226
+ },
227
+ save(record) {
228
+ return withFileLock(async () => {
229
+ const records = await readCursorFile(filePath);
230
+ records[cursorKey(record.accountId, record.agentProfileId)] = record;
231
+ await writeCursorFile(filePath, records);
232
+ });
233
+ },
202
234
  };
203
- }): LoggieCursorStore {
235
+ }
236
+
237
+ export function createRuntimeCursorStore(
238
+ runtime: {
239
+ state?: {
240
+ resolveStateDir?: () => string;
241
+ openKeyedStore?: <T>(opts: { namespace: string; maxEntries: number }) => {
242
+ lookup(key: string): Promise<T | undefined>;
243
+ register(key: string, value: T): Promise<void>;
244
+ };
245
+ };
246
+ },
247
+ options: RuntimeCursorStoreOptions = {},
248
+ ): LoggieCursorStore {
204
249
  const store = runtime.state?.openKeyedStore?.<LoggieCursorRecord>({
205
250
  namespace: "loggie-cursors",
206
251
  maxEntries: 1000,
207
252
  });
208
253
  if (!store) {
209
- throw new Error("Loggie durable cursor store is unavailable");
254
+ const stateDir = runtime.state?.resolveStateDir?.();
255
+ const filePath = stateDir ? join(stateDir, "loggie-cursors.json") : defaultCursorStorePath();
256
+ warnFileCursorStoreFallbackOnce(filePath, options.warn);
257
+ return createFileCursorStore(filePath);
210
258
  }
211
259
  return {
260
+ mode: "trusted",
212
261
  load: (accountId, agentProfileId) => store.lookup(cursorKey(accountId, agentProfileId)),
213
262
  save: (record) => store.register(cursorKey(record.accountId, record.agentProfileId), record),
214
263
  };
215
264
  }
216
265
 
266
+ function defaultCursorStorePath(): string {
267
+ return join(homedir(), ".openclaw", "state", "loggie-cursors.json");
268
+ }
269
+
270
+ function warnFileCursorStoreFallbackOnce(filePath: string, warn: RuntimeCursorStoreOptions["warn"]): void {
271
+ if (!warn || warnedFileCursorStorePaths.has(filePath)) {
272
+ return;
273
+ }
274
+ warnedFileCursorStorePaths.add(filePath);
275
+ warn(
276
+ `Loggie trusted runtime cursor store is unavailable; using file-backed cursor store at ${filePath}. Cursor durability now depends on filesystem access to that path.`,
277
+ );
278
+ }
279
+
280
+ async function readCursorFile(filePath: string): Promise<Record<string, LoggieCursorRecord>> {
281
+ try {
282
+ const contents = await fs.readFile(filePath, "utf8");
283
+ const parsed = JSON.parse(contents) as unknown;
284
+ if (isCursorFile(parsed)) {
285
+ return parsed;
286
+ }
287
+ throw new Error(`Loggie cursor store file has invalid JSON shape: ${filePath}`);
288
+ } catch (error) {
289
+ if (isNodeError(error) && error.code === "ENOENT") {
290
+ return {};
291
+ }
292
+ throw error;
293
+ }
294
+ }
295
+
296
+ async function writeCursorFile(filePath: string, records: Record<string, LoggieCursorRecord>): Promise<void> {
297
+ const directory = dirname(filePath);
298
+ await fs.mkdir(directory, { recursive: true });
299
+ const tempPath = join(directory, `.loggie-cursors.${process.pid}.${Date.now()}.tmp`);
300
+ await fs.writeFile(tempPath, `${JSON.stringify(records, null, 2)}\n`, "utf8");
301
+ await fs.rename(tempPath, filePath);
302
+ }
303
+
304
+ function isCursorFile(value: unknown): value is Record<string, LoggieCursorRecord> {
305
+ return !!value && typeof value === "object" && !Array.isArray(value);
306
+ }
307
+
308
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
309
+ return error instanceof Error && "code" in error;
310
+ }
311
+
217
312
  function cursorKey(accountId: string, agentProfileId: string): string {
218
313
  return `${accountId}:${agentProfileId}`;
219
314
  }
@@ -1,6 +1,6 @@
1
1
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/channel-core";
2
2
  import type { ResolvedLoggieAccount } from "../account.js";
3
- import { createRuntimeCursorStore, type LoggieCursorStore } from "../cursor-store.js";
3
+ import { createRuntimeCursorStore, type LoggieCursorStore, type LoggieCursorStoreMode } from "../cursor-store.js";
4
4
  import {
5
5
  buildLoggieAuthHeaders,
6
6
  buildLoggiePingMessage,
@@ -11,7 +11,7 @@ import {
11
11
  type LoggieSocketFactory,
12
12
  } from "../loggie-client.js";
13
13
  import { createOpenClawTranscriptDispatcher, type TranscriptDispatcher } from "./transcript-dispatch.js";
14
- import { handleLoggieEvent } from "./event-handler.js";
14
+ import { handleLoggieEvent, isLoggieCursorPersistenceError } from "./event-handler.js";
15
15
 
16
16
  type ChannelRuntime = PluginRuntime["channel"];
17
17
 
@@ -45,7 +45,7 @@ export async function monitorLoggieAccount(params: MonitorLoggieAccountParams):
45
45
  throw new Error("Loggie channelRuntime is required for transcript dispatch");
46
46
  }
47
47
 
48
- const cursorStore = params.cursorStore ?? createRuntimeCursorStore(params.runtime);
48
+ const cursorStore = params.cursorStore ?? createRuntimeCursorStore(params.runtime, { warn: params.log?.warn });
49
49
  const dispatcher =
50
50
  params.dispatcher ??
51
51
  createOpenClawTranscriptDispatcher({
@@ -71,6 +71,26 @@ export async function monitorLoggieAccount(params: MonitorLoggieAccountParams):
71
71
  if (params.abortSignal?.aborted) {
72
72
  return;
73
73
  }
74
+ if (isLoggieCursorPersistenceError(error)) {
75
+ const failedAt = Date.now();
76
+ params.setStatus?.({
77
+ ...(params.getStatus?.() ?? { accountId: params.account.accountId }),
78
+ running: false,
79
+ connected: false,
80
+ authenticated: false,
81
+ ...cursorStoreStatus(cursorStore.mode),
82
+ cursorPersistenceFailed: true,
83
+ lastCursorPersistenceAt: failedAt,
84
+ lastCursorPersistenceError: error.message,
85
+ lastCursorPersistenceEventId: error.eventId,
86
+ lastCursorPersistenceCursor: error.cursor ?? null,
87
+ lastError: error.message,
88
+ });
89
+ params.log?.warn?.(
90
+ `[${params.account.accountId}] Loggie cursor persistence failed; stopping monitor to avoid duplicate transcript dispatch: ${error.message}`,
91
+ );
92
+ throw error;
93
+ }
74
94
  attempts += 1;
75
95
  const delayMs = Math.min(
76
96
  params.account.reconnect.maxMs,
@@ -82,6 +102,8 @@ export async function monitorLoggieAccount(params: MonitorLoggieAccountParams):
82
102
  connected: false,
83
103
  authenticated: false,
84
104
  reconnectAttempts: attempts,
105
+ ...cursorStoreStatus(cursorStore.mode),
106
+ ...cursorPersistenceOkStatus(),
85
107
  lastError: error instanceof Error ? error.message : String(error),
86
108
  });
87
109
  params.log?.warn?.(
@@ -107,6 +129,8 @@ export async function runLoggieSocketOnce(params: MonitorLoggieAccountParams & {
107
129
  running: true,
108
130
  connected: false,
109
131
  authenticated: false,
132
+ ...cursorStoreStatus(params.cursorStore.mode),
133
+ ...cursorPersistenceOkStatus(),
110
134
  lastStartAt: Date.now(),
111
135
  });
112
136
 
@@ -187,6 +211,8 @@ export async function runLoggieSocketOnce(params: MonitorLoggieAccountParams & {
187
211
  connected: true,
188
212
  authenticated: true,
189
213
  reconnectAttempts: 0,
214
+ ...cursorStoreStatus(params.cursorStore.mode),
215
+ ...cursorPersistenceOkStatus(),
190
216
  lastConnectedAt: connectedAt,
191
217
  lastError: null,
192
218
  });
@@ -253,6 +279,8 @@ export async function runLoggieSocketOnce(params: MonitorLoggieAccountParams & {
253
279
  connected: true,
254
280
  authenticated: true,
255
281
  caughtUp: true,
282
+ ...cursorStoreStatus(params.cursorStore.mode),
283
+ ...cursorPersistenceOkStatus(),
256
284
  lastEventAt: eventAt,
257
285
  ...(result.status === "dispatched" ? { lastDispatchAt: eventAt } : {}),
258
286
  ...(deadLettered && result.event
@@ -302,6 +330,20 @@ export async function runLoggieSocketOnce(params: MonitorLoggieAccountParams & {
302
330
  });
303
331
  }
304
332
 
333
+ function cursorStoreStatus(mode: LoggieCursorStoreMode | undefined): { cursorStoreMode?: LoggieCursorStoreMode } {
334
+ return mode ? { cursorStoreMode: mode } : {};
335
+ }
336
+
337
+ function cursorPersistenceOkStatus() {
338
+ return {
339
+ cursorPersistenceFailed: false,
340
+ lastCursorPersistenceAt: null,
341
+ lastCursorPersistenceError: null,
342
+ lastCursorPersistenceEventId: null,
343
+ lastCursorPersistenceCursor: null,
344
+ };
345
+ }
346
+
305
347
  type ControlMessageDecision =
306
348
  | { type: "none" }
307
349
  | { type: "ignore" }
@@ -28,6 +28,36 @@ export type LoggieEventHandleResult =
28
28
 
29
29
  const MAX_EVENT_DISPATCH_ATTEMPTS = 3;
30
30
 
31
+ export class LoggieCursorPersistenceError extends Error {
32
+ readonly eventId: string;
33
+ readonly cursor?: string;
34
+ readonly accountId: string;
35
+ readonly agentProfileId: string;
36
+ readonly phase: "post_dispatch";
37
+
38
+ constructor(params: {
39
+ event: LoggieEventEnvelope;
40
+ account: ResolvedLoggieAccount;
41
+ phase: "post_dispatch";
42
+ cause: unknown;
43
+ }) {
44
+ const causeMessage = params.cause instanceof Error ? params.cause.message : String(params.cause);
45
+ super(`Loggie cursor persistence failed after dispatching event ${params.event.eventId}: ${causeMessage}`, {
46
+ cause: params.cause,
47
+ });
48
+ this.name = "LoggieCursorPersistenceError";
49
+ this.eventId = params.event.eventId;
50
+ this.cursor = params.event.cursor;
51
+ this.accountId = params.account.accountId;
52
+ this.agentProfileId = params.account.agentProfileId;
53
+ this.phase = params.phase;
54
+ }
55
+ }
56
+
57
+ export function isLoggieCursorPersistenceError(error: unknown): error is LoggieCursorPersistenceError {
58
+ return error instanceof LoggieCursorPersistenceError;
59
+ }
60
+
31
61
  export async function handleLoggieEvent(params: {
32
62
  raw: unknown;
33
63
  account: ResolvedLoggieAccount;
@@ -120,14 +150,22 @@ export async function handleLoggieEvent(params: {
120
150
  },
121
151
  };
122
152
  }
123
- await params.cursorStore.save(
124
- advanceCursor({
125
- record: cursor,
153
+ const advanced = advanceCursor({
154
+ record: cursor,
155
+ event,
156
+ accountId: params.account.accountId,
157
+ agentProfileId: params.account.agentProfileId,
158
+ now: params.now,
159
+ });
160
+ try {
161
+ await params.cursorStore.save(advanced);
162
+ } catch (error) {
163
+ throw new LoggieCursorPersistenceError({
126
164
  event,
127
- accountId: params.account.accountId,
128
- agentProfileId: params.account.agentProfileId,
129
- now: params.now,
130
- }),
131
- );
165
+ account: params.account,
166
+ phase: "post_dispatch",
167
+ cause: error,
168
+ });
169
+ }
132
170
  return { status: "dispatched", event };
133
171
  }
@@ -10,6 +10,26 @@ export type TranscriptDispatcher = {
10
10
  };
11
11
 
12
12
  type ChannelRuntime = PluginRuntime["channel"];
13
+ type AgentRuntimeHelpers = {
14
+ runEmbeddedAgent(input: Record<string, unknown>): Promise<unknown>;
15
+ resolveAgentWorkspaceDir(
16
+ cfg: OpenClawConfig,
17
+ agentId: string,
18
+ env?: NodeJS.ProcessEnv,
19
+ ): string;
20
+ resolveAgentDir(cfg: OpenClawConfig, agentId: string, env?: NodeJS.ProcessEnv): string;
21
+ resolveAgentTimeoutMs(opts: {
22
+ cfg?: OpenClawConfig;
23
+ overrideMs?: number | null;
24
+ overrideSeconds?: number | null;
25
+ minMs?: number;
26
+ }): number;
27
+ ensureAgentWorkspace?: (params: { dir: string }) => Promise<void> | void;
28
+ };
29
+ type ExtensionApi = Partial<AgentRuntimeHelpers>;
30
+ type ExtensionApiWithAliases = ExtensionApi & {
31
+ runEmbeddedPiAgent?: AgentRuntimeHelpers["runEmbeddedAgent"];
32
+ };
13
33
 
14
34
  export function createOpenClawTranscriptDispatcher(params: {
15
35
  account: ResolvedLoggieAccount;
@@ -19,7 +39,15 @@ export function createOpenClawTranscriptDispatcher(params: {
19
39
  abortSignal?: AbortSignal;
20
40
  fetchImpl?: typeof fetch;
21
41
  }): TranscriptDispatcher {
22
- const agentRuntime = params.runtime.agent;
42
+ let agentRuntimePromise: Promise<AgentRuntimeHelpers> | undefined;
43
+ const getAgentRuntime = () => {
44
+ agentRuntimePromise ??= resolveAgentRuntime(params.runtime).catch((error: unknown) => {
45
+ agentRuntimePromise = undefined;
46
+ throw error;
47
+ });
48
+ return agentRuntimePromise;
49
+ };
50
+
23
51
  return {
24
52
  async dispatchTranscriptReady(event) {
25
53
  const routeSessionKey = buildLoggieRouteSessionKey({ account: params.account, event });
@@ -104,6 +132,7 @@ export function createOpenClawTranscriptDispatcher(params: {
104
132
  },
105
133
  },
106
134
  runDispatch: async () => {
135
+ const agentRuntime = await getAgentRuntime();
107
136
  const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(
108
137
  params.cfg,
109
138
  params.account.agentId,
@@ -143,3 +172,63 @@ export function createOpenClawTranscriptDispatcher(params: {
143
172
  },
144
173
  };
145
174
  }
175
+
176
+ async function resolveAgentRuntime(runtime: PluginRuntime): Promise<AgentRuntimeHelpers> {
177
+ const runtimeAgent = (runtime as { agent?: Partial<AgentRuntimeHelpers> }).agent;
178
+ if (hasRequiredAgentHelpers(runtimeAgent)) {
179
+ return runtimeAgent;
180
+ }
181
+
182
+ const extensionApi = (await import("openclaw/extension-api")) as ExtensionApiWithAliases;
183
+ return {
184
+ runEmbeddedAgent: readRunEmbeddedAgent(runtimeAgent, extensionApi),
185
+ resolveAgentWorkspaceDir: readHelper(runtimeAgent, extensionApi, "resolveAgentWorkspaceDir"),
186
+ resolveAgentDir: readHelper(runtimeAgent, extensionApi, "resolveAgentDir"),
187
+ resolveAgentTimeoutMs: readHelper(runtimeAgent, extensionApi, "resolveAgentTimeoutMs"),
188
+ ensureAgentWorkspace: readOptionalHelper(runtimeAgent, extensionApi, "ensureAgentWorkspace"),
189
+ };
190
+ }
191
+
192
+ function hasRequiredAgentHelpers(value: Partial<AgentRuntimeHelpers> | undefined): value is AgentRuntimeHelpers {
193
+ return (
194
+ typeof value?.runEmbeddedAgent === "function" &&
195
+ typeof value.resolveAgentWorkspaceDir === "function" &&
196
+ typeof value.resolveAgentDir === "function" &&
197
+ typeof value.resolveAgentTimeoutMs === "function"
198
+ );
199
+ }
200
+
201
+ function readRunEmbeddedAgent(
202
+ runtimeAgent: Partial<AgentRuntimeHelpers> | undefined,
203
+ extensionApi: ExtensionApiWithAliases,
204
+ ): AgentRuntimeHelpers["runEmbeddedAgent"] {
205
+ const helper = runtimeAgent?.runEmbeddedAgent ?? extensionApi.runEmbeddedAgent ?? extensionApi.runEmbeddedPiAgent;
206
+ if (typeof helper !== "function") {
207
+ throw new Error("Loggie transcript dispatch requires OpenClaw agent helper: runEmbeddedAgent");
208
+ }
209
+ return helper;
210
+ }
211
+
212
+ function readHelper<K extends keyof AgentRuntimeHelpers>(
213
+ runtimeAgent: Partial<AgentRuntimeHelpers> | undefined,
214
+ extensionApi: ExtensionApi,
215
+ key: K,
216
+ ): AgentRuntimeHelpers[K] {
217
+ const helper = runtimeAgent?.[key] ?? extensionApi[key];
218
+ if (typeof helper !== "function") {
219
+ throw new Error(`Loggie transcript dispatch requires OpenClaw agent helper: ${key}`);
220
+ }
221
+ return helper as AgentRuntimeHelpers[K];
222
+ }
223
+
224
+ function readOptionalHelper<K extends keyof AgentRuntimeHelpers>(
225
+ runtimeAgent: Partial<AgentRuntimeHelpers> | undefined,
226
+ extensionApi: ExtensionApi,
227
+ key: K,
228
+ ): AgentRuntimeHelpers[K] | undefined {
229
+ const helper = runtimeAgent?.[key] ?? extensionApi[key];
230
+ if (helper !== undefined && typeof helper !== "function") {
231
+ throw new Error(`Loggie transcript dispatch requires OpenClaw agent helper: ${key}`);
232
+ }
233
+ return helper as AgentRuntimeHelpers[K] | undefined;
234
+ }
package/src/status.ts CHANGED
@@ -5,6 +5,12 @@ export type LoggieRuntimeExtra = {
5
5
  connected?: boolean;
6
6
  authenticated?: boolean;
7
7
  caughtUp?: boolean;
8
+ cursorStoreMode?: "trusted" | "file" | null;
9
+ cursorPersistenceFailed?: boolean;
10
+ lastCursorPersistenceAt?: number | null;
11
+ lastCursorPersistenceError?: string | null;
12
+ lastCursorPersistenceEventId?: string | null;
13
+ lastCursorPersistenceCursor?: string | null;
8
14
  lastEventId?: string | null;
9
15
  lastCursor?: string | null;
10
16
  lastSequence?: number | null;
@@ -22,6 +28,12 @@ export const defaultLoggieRuntimeState = createDefaultChannelRuntimeState<Loggie
22
28
  connected: false,
23
29
  authenticated: false,
24
30
  caughtUp: false,
31
+ cursorStoreMode: null,
32
+ cursorPersistenceFailed: false,
33
+ lastCursorPersistenceAt: null,
34
+ lastCursorPersistenceError: null,
35
+ lastCursorPersistenceEventId: null,
36
+ lastCursorPersistenceCursor: null,
25
37
  lastEventId: null,
26
38
  lastCursor: null,
27
39
  lastSequence: null,
@@ -58,6 +70,12 @@ export function buildLoggieAccountStatusSnapshot(params: {
58
70
  connected: params.runtime?.connected ?? false,
59
71
  authenticated: params.runtime?.authenticated ?? false,
60
72
  caughtUp: params.runtime?.caughtUp ?? false,
73
+ cursorStoreMode: params.runtime?.cursorStoreMode ?? null,
74
+ cursorPersistenceFailed: params.runtime?.cursorPersistenceFailed ?? false,
75
+ lastCursorPersistenceAt: params.runtime?.lastCursorPersistenceAt ?? null,
76
+ lastCursorPersistenceError: params.runtime?.lastCursorPersistenceError ?? null,
77
+ lastCursorPersistenceEventId: params.runtime?.lastCursorPersistenceEventId ?? null,
78
+ lastCursorPersistenceCursor: params.runtime?.lastCursorPersistenceCursor ?? null,
61
79
  lastEventId: params.runtime?.lastEventId ?? null,
62
80
  lastCursor: params.runtime?.lastCursor ?? null,
63
81
  lastSequence: params.runtime?.lastSequence ?? null,