@loggie-ai/openclaw-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +205 -0
  2. package/dist/index.d.ts +8 -0
  3. package/dist/index.js +12 -0
  4. package/dist/setup-entry.d.ts +73 -0
  5. package/dist/setup-entry.js +3 -0
  6. package/dist/src/account.d.ts +70 -0
  7. package/dist/src/account.js +182 -0
  8. package/dist/src/channel.d.ts +72 -0
  9. package/dist/src/channel.js +105 -0
  10. package/dist/src/config-schema.d.ts +98 -0
  11. package/dist/src/config-schema.js +55 -0
  12. package/dist/src/cursor-store.d.ts +86 -0
  13. package/dist/src/cursor-store.js +141 -0
  14. package/dist/src/doctor.d.ts +11 -0
  15. package/dist/src/doctor.js +29 -0
  16. package/dist/src/event-types.d.ts +113 -0
  17. package/dist/src/event-types.js +86 -0
  18. package/dist/src/loggie-client.d.ts +33 -0
  19. package/dist/src/loggie-client.js +74 -0
  20. package/dist/src/monitor/connection.d.ts +30 -0
  21. package/dist/src/monitor/connection.js +289 -0
  22. package/dist/src/monitor/event-handler.d.ts +27 -0
  23. package/dist/src/monitor/event-handler.js +90 -0
  24. package/dist/src/monitor/transcript-dispatch.d.ts +16 -0
  25. package/dist/src/monitor/transcript-dispatch.js +124 -0
  26. package/dist/src/monitor/transcript-format.d.ts +2 -0
  27. package/dist/src/monitor/transcript-format.js +41 -0
  28. package/dist/src/object.d.ts +4 -0
  29. package/dist/src/object.js +12 -0
  30. package/dist/src/routing.d.ts +11 -0
  31. package/dist/src/routing.js +13 -0
  32. package/dist/src/runtime.d.ts +3 -0
  33. package/dist/src/runtime.js +6 -0
  34. package/dist/src/status.d.ts +45 -0
  35. package/dist/src/status.js +45 -0
  36. package/index.ts +13 -0
  37. package/openclaw.plugin.json +71 -0
  38. package/package.json +93 -0
  39. package/plugin-inspector.config.json +15 -0
  40. package/setup-entry.ts +4 -0
  41. package/src/account.ts +265 -0
  42. package/src/channel.ts +148 -0
  43. package/src/config-schema.ts +57 -0
  44. package/src/cursor-store.ts +233 -0
  45. package/src/doctor.ts +39 -0
  46. package/src/event-types.ts +105 -0
  47. package/src/loggie-client.ts +111 -0
  48. package/src/monitor/connection.ts +349 -0
  49. package/src/monitor/event-handler.ts +133 -0
  50. package/src/monitor/transcript-dispatch.ts +145 -0
  51. package/src/monitor/transcript-format.ts +49 -0
  52. package/src/object.ts +15 -0
  53. package/src/routing.ts +27 -0
  54. package/src/runtime.ts +13 -0
  55. package/src/status.ts +72 -0
package/src/account.ts ADDED
@@ -0,0 +1,265 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
2
+ import {
3
+ DEFAULT_LOGGIE_ACCOUNT_ID,
4
+ DEFAULT_LOGGIE_AGENT_ID,
5
+ DEFAULT_LOGGIE_BASE_URL_ENV,
6
+ DEFAULT_LOGGIE_HEARTBEAT_TIMEOUT_MS,
7
+ DEFAULT_LOGGIE_SOCKET_PATH,
8
+ DEFAULT_LOGGIE_TOKEN_ENV,
9
+ } from "./config-schema.js";
10
+ import { isRecord, readBoolean, readInteger, readString } from "./object.js";
11
+
12
+ export type LoggieAuthHeader = "authorization" | "x-api-key";
13
+
14
+ export type LoggieSecretStatus =
15
+ | { status: "available"; source: string; value: string }
16
+ | { status: "configured_unavailable"; source: string; value?: undefined }
17
+ | { status: "missing"; source: "missing"; value?: undefined };
18
+
19
+ export type ResolvedLoggieAccount = {
20
+ accountId: string;
21
+ name?: string;
22
+ enabled: boolean;
23
+ configured: boolean;
24
+ baseUrl: string;
25
+ socketPath: string;
26
+ agentId: string;
27
+ agentProfileId: string;
28
+ authHeader: LoggieAuthHeader;
29
+ tokenStatus: LoggieSecretStatus;
30
+ reconnect: {
31
+ minMs: number;
32
+ maxMs: number;
33
+ };
34
+ heartbeat: {
35
+ timeoutMs: number;
36
+ };
37
+ transcript: {
38
+ activation: "final-only";
39
+ debounceMs: number;
40
+ };
41
+ };
42
+
43
+ type RawLoggieAccountConfig = Record<string, unknown>;
44
+
45
+ function getLoggieSection(cfg: OpenClawConfig): Record<string, unknown> {
46
+ const channels = isRecord((cfg as Record<string, unknown>).channels)
47
+ ? ((cfg as Record<string, unknown>).channels as Record<string, unknown>)
48
+ : {};
49
+ return isRecord(channels.loggie) ? channels.loggie : {};
50
+ }
51
+
52
+ function getAccounts(section: Record<string, unknown>): Record<string, RawLoggieAccountConfig> {
53
+ if (!isRecord(section.accounts)) {
54
+ return {};
55
+ }
56
+ const entries: Record<string, RawLoggieAccountConfig> = {};
57
+ for (const [id, raw] of Object.entries(section.accounts)) {
58
+ if (isRecord(raw)) {
59
+ entries[id] = raw;
60
+ }
61
+ }
62
+ return entries;
63
+ }
64
+
65
+ function mergeAccountConfig(
66
+ section: Record<string, unknown>,
67
+ accountId: string,
68
+ ): RawLoggieAccountConfig {
69
+ const { accounts: _accounts, ...topLevel } = section;
70
+ return {
71
+ ...topLevel,
72
+ ...(getAccounts(section)[accountId] ?? {}),
73
+ };
74
+ }
75
+
76
+ function normalizeAuthHeader(value: unknown): LoggieAuthHeader {
77
+ return value === "x-api-key" ? "x-api-key" : "authorization";
78
+ }
79
+
80
+ function readEnvSecretRefName(value: unknown): string | undefined {
81
+ if (typeof value === "string") {
82
+ const trimmed = value.trim();
83
+ if (trimmed.length === 0) {
84
+ return undefined;
85
+ }
86
+ const envName = trimmed.startsWith("$")
87
+ ? trimmed.replace(/^\$\{?([^}]+)\}?$/, "$1")
88
+ : undefined;
89
+ return envName && /^[A-Z][A-Z0-9_]{0,127}$/.test(envName) ? envName : undefined;
90
+ }
91
+ if (
92
+ isRecord(value) &&
93
+ value.source === "env" &&
94
+ typeof value.id === "string" &&
95
+ value.id.trim().length > 0
96
+ ) {
97
+ return value.id.trim();
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function readSecretRefLabel(value: unknown): string | undefined {
103
+ if (
104
+ isRecord(value) &&
105
+ typeof value.source === "string" &&
106
+ typeof value.provider === "string" &&
107
+ typeof value.id === "string"
108
+ ) {
109
+ return `${value.source}:${value.provider}:${value.id}`;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ function resolveToken(params: {
115
+ raw: RawLoggieAccountConfig;
116
+ env: NodeJS.ProcessEnv;
117
+ }): LoggieSecretStatus {
118
+ const literalToken = readString(params.raw.token);
119
+ if (literalToken) {
120
+ return { status: "available", source: "literal", value: literalToken };
121
+ }
122
+
123
+ if (params.raw.credentialRef === undefined) {
124
+ const value = readString(params.env[DEFAULT_LOGGIE_TOKEN_ENV]);
125
+ return value
126
+ ? { status: "available", source: `env:${DEFAULT_LOGGIE_TOKEN_ENV}`, value }
127
+ : { status: "configured_unavailable", source: `env:${DEFAULT_LOGGIE_TOKEN_ENV}` };
128
+ }
129
+
130
+ const credentialRef = params.raw.credentialRef;
131
+ const envName = readEnvSecretRefName(credentialRef);
132
+ if (envName) {
133
+ const value = readString(params.env[envName]);
134
+ return value
135
+ ? { status: "available", source: `env:${envName}`, value }
136
+ : { status: "configured_unavailable", source: `env:${envName}` };
137
+ }
138
+
139
+ if (typeof credentialRef === "string") {
140
+ const resolvedSecretValue = readString(credentialRef);
141
+ return resolvedSecretValue
142
+ ? { status: "available", source: "credentialRef", value: resolvedSecretValue }
143
+ : { status: "missing", source: "missing" };
144
+ }
145
+
146
+ const secretRefLabel = readSecretRefLabel(credentialRef);
147
+ if (!secretRefLabel) {
148
+ return { status: "missing", source: "missing" };
149
+ }
150
+ return { status: "configured_unavailable", source: secretRefLabel };
151
+ }
152
+
153
+ function normalizeReconnect(raw: RawLoggieAccountConfig) {
154
+ const reconnect = isRecord(raw.reconnect) ? raw.reconnect : {};
155
+ const minMs = Math.max(100, readInteger(reconnect.minMs, 1000));
156
+ const maxMs = Math.max(minMs, readInteger(reconnect.maxMs, 60_000));
157
+ return { minMs, maxMs };
158
+ }
159
+
160
+ function normalizeHeartbeat(raw: RawLoggieAccountConfig) {
161
+ const heartbeat = isRecord(raw.heartbeat) ? raw.heartbeat : {};
162
+ return {
163
+ timeoutMs: Math.max(
164
+ 1000,
165
+ readInteger(heartbeat.timeoutMs, DEFAULT_LOGGIE_HEARTBEAT_TIMEOUT_MS),
166
+ ),
167
+ };
168
+ }
169
+
170
+ function normalizeTranscript(raw: RawLoggieAccountConfig) {
171
+ const transcript = isRecord(raw.transcript) ? raw.transcript : {};
172
+ return {
173
+ activation: "final-only" as const,
174
+ debounceMs: Math.max(0, readInteger(transcript.debounceMs, 5000)),
175
+ };
176
+ }
177
+
178
+ export function listLoggieAccountIds(cfg: OpenClawConfig): string[] {
179
+ const section = getLoggieSection(cfg);
180
+ const accounts = Object.keys(getAccounts(section));
181
+ if (accounts.length > 0) {
182
+ return accounts;
183
+ }
184
+ return [readString(section.accountId) ?? DEFAULT_LOGGIE_ACCOUNT_ID];
185
+ }
186
+
187
+ export function resolveDefaultLoggieAccountId(cfg: OpenClawConfig): string {
188
+ const section = getLoggieSection(cfg);
189
+ return readString(section.accountId) ?? DEFAULT_LOGGIE_ACCOUNT_ID;
190
+ }
191
+
192
+ export function resolveLoggieAccount(
193
+ cfg: OpenClawConfig,
194
+ accountId?: string | null,
195
+ env: NodeJS.ProcessEnv = process.env,
196
+ ): ResolvedLoggieAccount {
197
+ const section = getLoggieSection(cfg);
198
+ const resolvedAccountId = accountId?.trim() || resolveDefaultLoggieAccountId(cfg);
199
+ const raw = mergeAccountConfig(section, resolvedAccountId);
200
+ const baseUrl =
201
+ readString(raw.baseUrl) ?? readString(env[DEFAULT_LOGGIE_BASE_URL_ENV]) ?? "http://127.0.0.1:8787";
202
+ const agentProfileId = readString(raw.agentProfileId) ?? "";
203
+ const tokenStatus = resolveToken({ raw, env });
204
+ const enabled = readBoolean(raw.enabled, true);
205
+ const configured = Boolean(baseUrl && agentProfileId && tokenStatus.status === "available");
206
+
207
+ return {
208
+ accountId: resolvedAccountId,
209
+ name: readString(raw.name),
210
+ enabled,
211
+ configured,
212
+ baseUrl,
213
+ socketPath: readString(raw.socketPath) ?? DEFAULT_LOGGIE_SOCKET_PATH,
214
+ agentId: readString(raw.agentId) ?? DEFAULT_LOGGIE_AGENT_ID,
215
+ agentProfileId,
216
+ authHeader: normalizeAuthHeader(raw.authHeader),
217
+ tokenStatus,
218
+ reconnect: normalizeReconnect(raw),
219
+ heartbeat: normalizeHeartbeat(raw),
220
+ transcript: normalizeTranscript(raw),
221
+ };
222
+ }
223
+
224
+ export function inspectLoggieAccount(cfg: OpenClawConfig, accountId?: string | null) {
225
+ const account = resolveLoggieAccount(cfg, accountId);
226
+ return {
227
+ accountId: account.accountId,
228
+ name: account.name,
229
+ enabled: account.enabled,
230
+ configured: account.configured,
231
+ baseUrl: account.baseUrl,
232
+ socketPath: account.socketPath,
233
+ agentId: account.agentId,
234
+ agentProfileId: account.agentProfileId,
235
+ authHeader: account.authHeader,
236
+ tokenStatus: account.tokenStatus.status,
237
+ tokenSource: account.tokenStatus.source,
238
+ reconnect: account.reconnect,
239
+ heartbeat: account.heartbeat,
240
+ transcript: account.transcript,
241
+ };
242
+ }
243
+
244
+ export function applyLoggieAccountConfig(params: {
245
+ cfg: OpenClawConfig;
246
+ accountId: string;
247
+ input: Record<string, unknown>;
248
+ }): OpenClawConfig {
249
+ const next = structuredClone(params.cfg) as Record<string, unknown>;
250
+ const channels = isRecord(next.channels) ? next.channels : {};
251
+ const loggie = isRecord(channels.loggie) ? channels.loggie : {};
252
+ channels.loggie = {
253
+ ...loggie,
254
+ accountId: params.accountId,
255
+ accounts: {
256
+ ...(isRecord(loggie.accounts) ? loggie.accounts : {}),
257
+ [params.accountId]: {
258
+ enabled: true,
259
+ ...params.input,
260
+ },
261
+ },
262
+ };
263
+ next.channels = channels;
264
+ return next as OpenClawConfig;
265
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,148 @@
1
+ import {
2
+ createChannelPluginBase,
3
+ type ChannelPlugin,
4
+ type OpenClawConfig,
5
+ } from "openclaw/plugin-sdk/channel-core";
6
+ import {
7
+ applyLoggieAccountConfig,
8
+ inspectLoggieAccount,
9
+ listLoggieAccountIds,
10
+ resolveDefaultLoggieAccountId,
11
+ resolveLoggieAccount,
12
+ type ResolvedLoggieAccount,
13
+ } from "./account.js";
14
+ import { loggieChannelConfigSchema } from "./config-schema.js";
15
+ import { loggieDoctor } from "./doctor.js";
16
+ import {
17
+ buildLoggieAccountStatusSnapshot,
18
+ defaultLoggieRuntimeState,
19
+ } from "./status.js";
20
+
21
+ const base = createChannelPluginBase<ResolvedLoggieAccount>({
22
+ id: "loggie",
23
+ meta: {
24
+ label: "Loggie",
25
+ selectionLabel: "Loggie",
26
+ blurb: "Receives durable Loggie meeting transcript events over WebSocket.",
27
+ docsPath: "/plugins/loggie",
28
+ },
29
+ capabilities: {
30
+ chatTypes: ["group"],
31
+ threads: false,
32
+ media: false,
33
+ reply: false,
34
+ },
35
+ configSchema: loggieChannelConfigSchema,
36
+ setup: {
37
+ resolveAccountId: ({ accountId }: { accountId?: string }) => accountId ?? "default",
38
+ applyAccountConfig: ({
39
+ cfg,
40
+ accountId,
41
+ input,
42
+ }: {
43
+ cfg: OpenClawConfig;
44
+ accountId: string;
45
+ input: unknown;
46
+ }) =>
47
+ applyLoggieAccountConfig({
48
+ cfg,
49
+ accountId,
50
+ input: input as Record<string, unknown>,
51
+ }),
52
+ },
53
+ config: {
54
+ listAccountIds: listLoggieAccountIds,
55
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
56
+ resolveLoggieAccount(cfg, accountId),
57
+ inspectAccount: inspectLoggieAccount,
58
+ defaultAccountId: resolveDefaultLoggieAccountId,
59
+ isEnabled: (account: ResolvedLoggieAccount) => account.enabled,
60
+ isConfigured: (account: ResolvedLoggieAccount) => account.configured,
61
+ unconfiguredReason: (account: ResolvedLoggieAccount) =>
62
+ account.tokenStatus.status !== "available"
63
+ ? `Loggie token ${account.tokenStatus.status}`
64
+ : "Loggie baseUrl and agentProfileId are required",
65
+ describeAccount: (account: ResolvedLoggieAccount) => ({
66
+ accountId: account.accountId,
67
+ name: account.name,
68
+ enabled: account.enabled,
69
+ configured: account.configured,
70
+ tokenStatus: account.tokenStatus.status,
71
+ tokenSource: account.tokenStatus.source,
72
+ extra: {
73
+ baseUrl: account.baseUrl,
74
+ agentId: account.agentId,
75
+ agentProfileId: account.agentProfileId,
76
+ },
77
+ }),
78
+ },
79
+ });
80
+
81
+ export const loggiePlugin = {
82
+ ...base,
83
+ status: {
84
+ defaultRuntime: defaultLoggieRuntimeState,
85
+ buildAccountSnapshot: ({
86
+ account,
87
+ runtime,
88
+ }: {
89
+ account: ResolvedLoggieAccount;
90
+ runtime?: Record<string, unknown> | null;
91
+ }) =>
92
+ buildLoggieAccountStatusSnapshot({
93
+ account,
94
+ runtime: runtime as Record<string, unknown> | null | undefined,
95
+ }),
96
+ },
97
+ doctor: loggieDoctor,
98
+ secrets: {
99
+ secretTargetRegistryEntries: [
100
+ {
101
+ id: "channels.loggie.credentialRef",
102
+ targetType: "channels.loggie.credentialRef",
103
+ configFile: "openclaw.json",
104
+ pathPattern: "channels.loggie.credentialRef",
105
+ secretShape: "secret_input",
106
+ expectedResolvedValue: "string",
107
+ includeInPlan: true,
108
+ includeInConfigure: true,
109
+ includeInAudit: true,
110
+ },
111
+ {
112
+ id: "channels.loggie.accounts.*.credentialRef",
113
+ targetType: "channels.loggie.accounts.*.credentialRef",
114
+ configFile: "openclaw.json",
115
+ pathPattern: "channels.loggie.accounts.*.credentialRef",
116
+ secretShape: "secret_input",
117
+ expectedResolvedValue: "string",
118
+ includeInPlan: true,
119
+ includeInConfigure: true,
120
+ includeInAudit: true,
121
+ },
122
+ ],
123
+ },
124
+ gateway: {
125
+ startAccount: async (ctx: {
126
+ account: ResolvedLoggieAccount;
127
+ cfg: OpenClawConfig;
128
+ runtime: unknown;
129
+ channelRuntime?: unknown;
130
+ abortSignal?: AbortSignal;
131
+ setStatus: (next: unknown) => void;
132
+ getStatus: () => unknown;
133
+ log?: { info?: (message: string) => void; warn?: (message: string) => void };
134
+ }) => {
135
+ const { monitorLoggieAccount } = await import("./monitor/connection.js");
136
+ return monitorLoggieAccount({
137
+ account: ctx.account,
138
+ cfg: ctx.cfg,
139
+ runtime: ctx.runtime as Parameters<typeof monitorLoggieAccount>[0]["runtime"],
140
+ channelRuntime: ctx.channelRuntime as Parameters<typeof monitorLoggieAccount>[0]["channelRuntime"],
141
+ abortSignal: ctx.abortSignal,
142
+ setStatus: ctx.setStatus as (next: Record<string, unknown>) => void,
143
+ getStatus: ctx.getStatus as () => Record<string, unknown>,
144
+ log: ctx.log,
145
+ });
146
+ },
147
+ },
148
+ } satisfies ChannelPlugin<ResolvedLoggieAccount>;
@@ -0,0 +1,57 @@
1
+ export const LOGGIE_CHANNEL_ID = "loggie";
2
+ export const DEFAULT_LOGGIE_ACCOUNT_ID = "default";
3
+ export const DEFAULT_LOGGIE_AGENT_ID = "main";
4
+ export const DEFAULT_LOGGIE_SOCKET_PATH = "/api/events/socket";
5
+ export const DEFAULT_LOGGIE_TOKEN_ENV = "LOGGIE_AGENT_TOKEN";
6
+ export const DEFAULT_LOGGIE_BASE_URL_ENV = "LOGGIE_BASE_URL";
7
+ export const DEFAULT_LOGGIE_HEARTBEAT_TIMEOUT_MS = 120_000;
8
+
9
+ export const loggieEntryConfigSchema = {
10
+ type: "object",
11
+ additionalProperties: false,
12
+ properties: {},
13
+ } as const;
14
+
15
+ export const loggieChannelConfigSchema = {
16
+ type: "object",
17
+ additionalProperties: true,
18
+ properties: {
19
+ enabled: { type: "boolean", default: true },
20
+ baseUrl: { type: "string" },
21
+ socketPath: { type: "string", default: DEFAULT_LOGGIE_SOCKET_PATH },
22
+ accountId: { type: "string", default: DEFAULT_LOGGIE_ACCOUNT_ID },
23
+ agentId: { type: "string", default: DEFAULT_LOGGIE_AGENT_ID },
24
+ agentProfileId: { type: "string" },
25
+ credentialRef: {},
26
+ token: { type: "string" },
27
+ authHeader: { type: "string", enum: ["authorization", "x-api-key"], default: "authorization" },
28
+ accounts: { type: "object", additionalProperties: { type: "object" } },
29
+ reconnect: {
30
+ type: "object",
31
+ additionalProperties: false,
32
+ properties: {
33
+ minMs: { type: "integer", minimum: 100, default: 1000 },
34
+ maxMs: { type: "integer", minimum: 1000, default: 60000 },
35
+ },
36
+ },
37
+ heartbeat: {
38
+ type: "object",
39
+ additionalProperties: false,
40
+ properties: {
41
+ timeoutMs: {
42
+ type: "integer",
43
+ minimum: 1000,
44
+ default: DEFAULT_LOGGIE_HEARTBEAT_TIMEOUT_MS,
45
+ },
46
+ },
47
+ },
48
+ transcript: {
49
+ type: "object",
50
+ additionalProperties: false,
51
+ properties: {
52
+ activation: { type: "string", enum: ["final-only"], default: "final-only" },
53
+ debounceMs: { type: "integer", minimum: 0, default: 5000 },
54
+ },
55
+ },
56
+ },
57
+ } as const;
@@ -0,0 +1,233 @@
1
+ import type { LoggieEventEnvelope } from "./event-types.js";
2
+
3
+ export type LoggieCursorRecord = {
4
+ accountId: string;
5
+ agentProfileId: string;
6
+ lastEventId?: string;
7
+ lastCursor?: string;
8
+ lastSequence?: number;
9
+ lastConnectedAt?: number;
10
+ lastCaughtUpAt?: number;
11
+ lastError?: string;
12
+ failedEvents?: LoggieFailedEvent[];
13
+ deadLetteredEvents?: LoggieDeadLetteredEvent[];
14
+ seenEventIds: string[];
15
+ updatedAt: string;
16
+ };
17
+
18
+ export type LoggieFailedEvent = {
19
+ eventId: string;
20
+ cursor?: string;
21
+ eventType: string;
22
+ attempts: number;
23
+ firstFailedAt: string;
24
+ lastFailedAt: string;
25
+ lastReason: string;
26
+ };
27
+
28
+ export type LoggieDeadLetteredEvent = {
29
+ eventId: string;
30
+ cursor?: string;
31
+ eventType: string;
32
+ attempts: number;
33
+ reason: string;
34
+ deadLetteredAt: string;
35
+ };
36
+
37
+ export type LoggieCursorDecision =
38
+ | { action: "process" }
39
+ | { action: "skip"; reason: "duplicate_event" | "cursor_regression" };
40
+
41
+ export type LoggieCursorStore = {
42
+ load(accountId: string, agentProfileId: string): Promise<LoggieCursorRecord | undefined>;
43
+ save(record: LoggieCursorRecord): Promise<void>;
44
+ };
45
+
46
+ const MAX_SEEN_EVENT_IDS = 500;
47
+ const MAX_FAILED_EVENTS = 50;
48
+ const MAX_DEAD_LETTERED_EVENTS = 100;
49
+
50
+ export function createEmptyCursorRecord(params: {
51
+ accountId: string;
52
+ agentProfileId: string;
53
+ }): LoggieCursorRecord {
54
+ return {
55
+ accountId: params.accountId,
56
+ agentProfileId: params.agentProfileId,
57
+ seenEventIds: [],
58
+ updatedAt: new Date(0).toISOString(),
59
+ };
60
+ }
61
+
62
+ export function decideCursor(record: LoggieCursorRecord | undefined, event: LoggieEventEnvelope): LoggieCursorDecision {
63
+ if (record?.seenEventIds.includes(event.eventId)) {
64
+ return { action: "skip", reason: "duplicate_event" };
65
+ }
66
+ const eventCursor = parseCursor(event.cursor);
67
+ const recordCursor = parseCursor(readStoredCursor(record));
68
+ if (eventCursor !== undefined && recordCursor !== undefined && eventCursor < recordCursor) {
69
+ return { action: "skip", reason: "cursor_regression" };
70
+ }
71
+ return { action: "process" };
72
+ }
73
+
74
+ export function advanceCursor(params: {
75
+ record: LoggieCursorRecord | undefined;
76
+ event: LoggieEventEnvelope;
77
+ accountId: string;
78
+ agentProfileId: string;
79
+ now?: Date;
80
+ }): LoggieCursorRecord {
81
+ const base =
82
+ params.record ??
83
+ createEmptyCursorRecord({
84
+ accountId: params.accountId,
85
+ agentProfileId: params.agentProfileId,
86
+ });
87
+ const seen = [params.event.eventId, ...base.seenEventIds.filter((id) => id !== params.event.eventId)].slice(
88
+ 0,
89
+ MAX_SEEN_EVENT_IDS,
90
+ );
91
+ const failedEvents = base.failedEvents?.filter((entry) => entry.eventId !== params.event.eventId);
92
+ return {
93
+ ...base,
94
+ lastEventId: params.event.eventId,
95
+ lastCursor: params.event.cursor ?? base.lastCursor,
96
+ lastSequence:
97
+ typeof params.event.sequence === "number"
98
+ ? params.event.sequence
99
+ : base.lastSequence,
100
+ lastCaughtUpAt: (params.now ?? new Date()).getTime(),
101
+ lastError: undefined,
102
+ failedEvents: failedEvents?.length ? failedEvents : undefined,
103
+ seenEventIds: seen,
104
+ updatedAt: (params.now ?? new Date()).toISOString(),
105
+ };
106
+ }
107
+
108
+ export function recordEventFailure(params: {
109
+ record: LoggieCursorRecord | undefined;
110
+ event: LoggieEventEnvelope;
111
+ accountId: string;
112
+ agentProfileId: string;
113
+ reason: string;
114
+ now?: Date;
115
+ }): { record: LoggieCursorRecord; attempts: number } {
116
+ const now = (params.now ?? new Date()).toISOString();
117
+ const base =
118
+ params.record ??
119
+ createEmptyCursorRecord({
120
+ accountId: params.accountId,
121
+ agentProfileId: params.agentProfileId,
122
+ });
123
+ const existing = base.failedEvents?.find((entry) => entry.eventId === params.event.eventId);
124
+ const nextFailure: LoggieFailedEvent = {
125
+ eventId: params.event.eventId,
126
+ cursor: params.event.cursor,
127
+ eventType: params.event.eventType,
128
+ attempts: (existing?.attempts ?? 0) + 1,
129
+ firstFailedAt: existing?.firstFailedAt ?? now,
130
+ lastFailedAt: now,
131
+ lastReason: params.reason,
132
+ };
133
+ const failedEvents = [
134
+ nextFailure,
135
+ ...(base.failedEvents ?? []).filter((entry) => entry.eventId !== params.event.eventId),
136
+ ].slice(0, MAX_FAILED_EVENTS);
137
+ return {
138
+ attempts: nextFailure.attempts,
139
+ record: {
140
+ ...base,
141
+ lastError: params.reason,
142
+ failedEvents,
143
+ updatedAt: now,
144
+ },
145
+ };
146
+ }
147
+
148
+ export function deadLetterEvent(params: {
149
+ record: LoggieCursorRecord | undefined;
150
+ event: LoggieEventEnvelope;
151
+ accountId: string;
152
+ agentProfileId: string;
153
+ reason: string;
154
+ attempts: number;
155
+ now?: Date;
156
+ }): LoggieCursorRecord {
157
+ const now = params.now ?? new Date();
158
+ const advanced = advanceCursor({
159
+ record: params.record,
160
+ event: params.event,
161
+ accountId: params.accountId,
162
+ agentProfileId: params.agentProfileId,
163
+ now,
164
+ });
165
+ return {
166
+ ...advanced,
167
+ deadLetteredEvents: [
168
+ {
169
+ eventId: params.event.eventId,
170
+ cursor: params.event.cursor,
171
+ eventType: params.event.eventType,
172
+ attempts: params.attempts,
173
+ reason: params.reason,
174
+ deadLetteredAt: now.toISOString(),
175
+ },
176
+ ...(advanced.deadLetteredEvents ?? []),
177
+ ].slice(0, MAX_DEAD_LETTERED_EVENTS),
178
+ };
179
+ }
180
+
181
+ export function createMemoryCursorStore(initial?: LoggieCursorRecord[]): LoggieCursorStore {
182
+ const records = new Map<string, LoggieCursorRecord>();
183
+ for (const record of initial ?? []) {
184
+ records.set(cursorKey(record.accountId, record.agentProfileId), record);
185
+ }
186
+ return {
187
+ async load(accountId, agentProfileId) {
188
+ return records.get(cursorKey(accountId, agentProfileId));
189
+ },
190
+ async save(record) {
191
+ records.set(cursorKey(record.accountId, record.agentProfileId), record);
192
+ },
193
+ };
194
+ }
195
+
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
+ };
202
+ };
203
+ }): LoggieCursorStore {
204
+ const store = runtime.state?.openKeyedStore?.<LoggieCursorRecord>({
205
+ namespace: "loggie-cursors",
206
+ maxEntries: 1000,
207
+ });
208
+ if (!store) {
209
+ throw new Error("Loggie durable cursor store is unavailable");
210
+ }
211
+ return {
212
+ load: (accountId, agentProfileId) => store.lookup(cursorKey(accountId, agentProfileId)),
213
+ save: (record) => store.register(cursorKey(record.accountId, record.agentProfileId), record),
214
+ };
215
+ }
216
+
217
+ function cursorKey(accountId: string, agentProfileId: string): string {
218
+ return `${accountId}:${agentProfileId}`;
219
+ }
220
+
221
+ function parseCursor(value: string | undefined): bigint | undefined {
222
+ if (!value || !/^\d+$/.test(value)) {
223
+ return undefined;
224
+ }
225
+ return BigInt(value);
226
+ }
227
+
228
+ function readStoredCursor(record: LoggieCursorRecord | undefined): string | undefined {
229
+ if (!record) {
230
+ return undefined;
231
+ }
232
+ return record.lastCursor ?? (typeof record.lastSequence === "number" ? String(record.lastSequence) : undefined);
233
+ }