@byte5ai/palaia 2.0.4 → 2.0.6

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/hooks.ts +167 -81
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byte5ai/palaia",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Palaia memory backend for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
package/src/hooks.ts CHANGED
@@ -137,6 +137,16 @@ const lastInboundMessageByChannel = new Map<string, { messageId: string; provide
137
137
  /** Channels that support emoji reactions. */
138
138
  const REACTION_SUPPORTED_PROVIDERS = new Set(["slack", "discord"]);
139
139
 
140
+ // ============================================================================
141
+ // Logger (Issue: api.logger integration)
142
+ // ============================================================================
143
+
144
+ /** Module-level logger — defaults to console, replaced by api.logger in registerHooks. */
145
+ let logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void } = {
146
+ info: (...args: any[]) => console.log(...args),
147
+ warn: (...args: any[]) => console.warn(...args),
148
+ };
149
+
140
150
  // ============================================================================
141
151
  // Scope Validation (Issue #90)
142
152
  // ============================================================================
@@ -350,7 +360,7 @@ async function sendSlackReaction(
350
360
  ): Promise<void> {
351
361
  const token = await resolveSlackBotToken();
352
362
  if (!token) {
353
- console.warn("[palaia] Cannot send Slack reaction: no bot token found");
363
+ logger.warn("[palaia] Cannot send Slack reaction: no bot token found");
354
364
  return;
355
365
  }
356
366
 
@@ -375,11 +385,11 @@ async function sendSlackReaction(
375
385
  });
376
386
  const data = await response.json() as { ok: boolean; error?: string };
377
387
  if (!data.ok && data.error !== "already_reacted") {
378
- console.warn(`[palaia] Slack reaction failed: ${data.error} (${normalizedEmoji} on ${channelId})`);
388
+ logger.warn(`[palaia] Slack reaction failed: ${data.error} (${normalizedEmoji} on ${channelId})`);
379
389
  }
380
390
  } catch (err) {
381
391
  if ((err as Error).name !== "AbortError") {
382
- console.warn(`[palaia] Slack reaction error (${normalizedEmoji}): ${err}`);
392
+ logger.warn(`[palaia] Slack reaction error (${normalizedEmoji}): ${err}`);
383
393
  }
384
394
  } finally {
385
395
  clearTimeout(timeout);
@@ -792,7 +802,7 @@ export function resolveCaptureModel(
792
802
  if (parts.length >= 2) {
793
803
  if (!_captureModelFallbackWarned) {
794
804
  _captureModelFallbackWarned = true;
795
- console.warn(`[palaia] No captureModel configured — using primary model. Set captureModel in plugin config for cost savings.`);
805
+ logger.warn(`[palaia] No captureModel configured — using primary model. Set captureModel in plugin config for cost savings.`);
796
806
  }
797
807
  return { provider: parts[0], model: parts.slice(1).join("/") };
798
808
  }
@@ -825,21 +835,27 @@ function collectText(payloads: Array<{ text?: string; isError?: boolean }> | und
825
835
  * then hard-cap at maxChars from the end (newest messages kept).
826
836
  */
827
837
  export function trimToRecentExchanges(
828
- texts: Array<{ role: string; text: string }>,
838
+ texts: Array<{ role: string; text: string; provenance?: string }>,
829
839
  maxPairs = 5,
830
840
  maxChars = 10_000,
831
- ): Array<{ role: string; text: string }> {
841
+ ): Array<{ role: string; text: string; provenance?: string }> {
832
842
  // Filter to only user + assistant messages (skip tool, toolResult, system, etc.)
833
843
  const exchanges = texts.filter((t) => t.role === "user" || t.role === "assistant");
834
844
 
835
845
  // Keep the last N pairs (a pair = one user + one assistant message)
846
+ // Only count external_user messages as real user turns.
847
+ // System-injected user messages (inter_session, internal_system) don't count as conversation turns.
836
848
  // Walk backwards, count pairs
837
849
  let pairCount = 0;
838
850
  let lastRole = "";
839
851
  let cutIndex = 0; // default: keep everything
840
852
  for (let i = exchanges.length - 1; i >= 0; i--) {
841
- // Count a new pair when we see a user message after having seen an assistant
842
- if (exchanges[i].role === "user" && lastRole === "assistant") {
853
+ const isRealUser = exchanges[i].role === "user" && (
854
+ exchanges[i].provenance === "external_user" ||
855
+ !exchanges[i].provenance // backward compat: no provenance = treat as real user
856
+ );
857
+ // Count a new pair when we see a real user message after having seen an assistant
858
+ if (isRealUser && lastRole === "assistant") {
843
859
  pairCount++;
844
860
  if (pairCount > maxPairs) {
845
861
  cutIndex = i + 1; // keep from next message onwards
@@ -1087,8 +1103,8 @@ export function extractSignificance(
1087
1103
  return { tags, type: primaryType, summary };
1088
1104
  }
1089
1105
 
1090
- export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string }> {
1091
- const result: Array<{ role: string; text: string }> = [];
1106
+ export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string; provenance?: string }> {
1107
+ const result: Array<{ role: string; text: string; provenance?: string }> = [];
1092
1108
 
1093
1109
  for (const msg of messages) {
1094
1110
  if (!msg || typeof msg !== "object") continue;
@@ -1096,8 +1112,12 @@ export function extractMessageTexts(messages: unknown[]): Array<{ role: string;
1096
1112
  const role = m.role;
1097
1113
  if (!role || typeof role !== "string") continue;
1098
1114
 
1115
+ // Extract provenance kind (string or object with .kind)
1116
+ const rawProvenance = (m as any).provenance?.kind ?? (m as any).provenance;
1117
+ const provenance = typeof rawProvenance === "string" ? rawProvenance : undefined;
1118
+
1099
1119
  if (typeof m.content === "string" && m.content.trim()) {
1100
- result.push({ role, text: m.content.trim() });
1120
+ result.push({ role, text: m.content.trim(), provenance });
1101
1121
  continue;
1102
1122
  }
1103
1123
 
@@ -1110,7 +1130,7 @@ export function extractMessageTexts(messages: unknown[]): Array<{ role: string;
1110
1130
  typeof block.text === "string" &&
1111
1131
  block.text.trim()
1112
1132
  ) {
1113
- result.push({ role, text: block.text.trim() });
1133
+ result.push({ role, text: block.text.trim(), provenance });
1114
1134
  }
1115
1135
  }
1116
1136
  }
@@ -1121,6 +1141,12 @@ export function extractMessageTexts(messages: unknown[]): Array<{ role: string;
1121
1141
 
1122
1142
  export function getLastUserMessage(messages: unknown[]): string | null {
1123
1143
  const texts = extractMessageTexts(messages);
1144
+ // Prefer external_user provenance (real human input)
1145
+ for (let i = texts.length - 1; i >= 0; i--) {
1146
+ if (texts[i].role === "user" && texts[i].provenance === "external_user")
1147
+ return texts[i].text;
1148
+ }
1149
+ // Fallback: any user message (backward compat for OpenClaw without provenance)
1124
1150
  for (let i = texts.length - 1; i >= 0; i--) {
1125
1151
  if (texts[i].role === "user") return texts[i].text;
1126
1152
  }
@@ -1128,80 +1154,122 @@ export function getLastUserMessage(messages: unknown[]): string | null {
1128
1154
  }
1129
1155
 
1130
1156
  // ============================================================================
1131
- // Recall Query Builder (Issue #65 upgrade: robust user message extraction)
1157
+ // Channel Envelope Stripping (v2.0.6)
1132
1158
  // ============================================================================
1133
1159
 
1134
- /** Day-of-week prefixes used as system markers in messages. */
1135
- const DAY_PREFIXES = ["[Mon ", "[Tue ", "[Wed ", "[Thu ", "[Fri ", "[Sat ", "[Sun "];
1160
+ /**
1161
+ * Strip OpenClaw channel envelope from message text.
1162
+ * Matches the pattern: [TIMESTAMP] or [CHANNEL TIMESTAMP] prefix
1163
+ * that OpenClaw adds to inbound messages from all channels.
1164
+ * Based on OpenClaw's internal stripEnvelope() logic.
1165
+ */
1166
+ const ENVELOPE_PREFIX_RE = /^\[([^\]]+)\]\s*/;
1167
+ const ENVELOPE_CHANNELS = [
1168
+ "WebChat", "WhatsApp", "Telegram", "Signal", "Slack",
1169
+ "Discord", "Google Chat", "iMessage", "Teams", "Matrix",
1170
+ "Zalo", "Zalo Personal", "BlueBubbles",
1171
+ ];
1172
+
1173
+ function looksLikeEnvelopeHeader(header: string): boolean {
1174
+ // ISO timestamp pattern
1175
+ if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
1176
+ // Space-separated timestamp
1177
+ if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
1178
+ // Channel prefix
1179
+ return ENVELOPE_CHANNELS.some(ch => header.startsWith(`${ch} `));
1180
+ }
1181
+
1182
+ export function stripChannelEnvelope(text: string): string {
1183
+ const match = text.match(ENVELOPE_PREFIX_RE);
1184
+ if (!match) return text;
1185
+ if (!looksLikeEnvelopeHeader(match[1] ?? "")) return text;
1186
+ return text.slice(match[0].length);
1187
+ }
1136
1188
 
1137
1189
  /**
1138
- * Clean a raw message string by removing system markers, JSON blocks,
1139
- * and other noise that degrades semantic search quality.
1190
+ * Strip "System: [timestamp] Channel message in #channel from User: " prefix.
1191
+ * OpenClaw wraps inbound messages with this pattern for all channel providers.
1140
1192
  */
1141
- export function cleanMessageForQuery(text: string): string {
1142
- let cleaned = text;
1143
-
1144
- // Remove JSON code blocks (```json ... ```)
1145
- cleaned = cleaned.replace(/```json[\s\S]*?```/gi, "");
1146
-
1147
- // Remove lines starting with system markers
1148
- cleaned = cleaned
1149
- .split("\n")
1150
- .filter((line) => {
1151
- const trimmed = line.trimStart();
1152
- if (trimmed.startsWith("System:")) return false;
1153
- if (trimmed.startsWith("[Queued")) return false;
1154
- if (trimmed.startsWith("[Inter-session")) return false;
1155
- for (const prefix of DAY_PREFIXES) {
1156
- if (trimmed.startsWith(prefix)) return false;
1157
- }
1158
- return true;
1159
- })
1160
- .join("\n")
1161
- .trim();
1193
+ const SYSTEM_PREFIX_RE = /^System:\s*\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*(?:Slack message|Telegram message|Discord message|WhatsApp message|Signal message|message).*?(?:from \w+:\s*)?/i;
1162
1194
 
1163
- return cleaned;
1195
+ export function stripSystemPrefix(text: string): string {
1196
+ const match = text.match(SYSTEM_PREFIX_RE);
1197
+ if (match) return text.slice(match[0].length);
1198
+ return text;
1199
+ }
1200
+
1201
+ // ============================================================================
1202
+ // Recall Query Builder (v2.0.6: envelope-aware, provenance-based)
1203
+ // ============================================================================
1204
+
1205
+ /**
1206
+ * Messages that are purely system content (no user text).
1207
+ * Used to skip edited notifications, sync events, inter-session messages, etc.
1208
+ */
1209
+ function isSystemOnlyContent(text: string): boolean {
1210
+ if (!text) return true;
1211
+ if (text.startsWith("System:")) return true;
1212
+ if (text.startsWith("[Queued")) return true;
1213
+ if (text.startsWith("[Inter-session")) return true;
1214
+ if (/^Slack message (edited|deleted)/.test(text)) return true;
1215
+ if (/^\[auto\]/.test(text)) return true;
1216
+ if (text.length < 3) return true;
1217
+ return false;
1164
1218
  }
1165
1219
 
1166
1220
  /**
1167
1221
  * Build a recall query from message history.
1168
1222
  *
1169
- * - Always uses actual user messages (ignores event.prompt which may be stale/synthetic).
1170
- * - If the last user message is short (< 30 chars), prepends the previous user message
1171
- * for better semantic context ("Ja", "OK", "Status" alone are poor queries).
1172
- * - Strips system markers, JSON blocks, and other noise.
1173
- * - Hard-caps at 500 characters.
1223
+ * v2.0.6: Strips OpenClaw channel envelopes (System: [...] Slack message from ...:)
1224
+ * and inter-session prefixes before building the query. This prevents envelope
1225
+ * metadata from polluting semantic search and causing timeouts / false-high scores.
1174
1226
  *
1175
- * Returns empty string if nothing usable remains.
1227
+ * - Filters out inter_session and internal_system provenance messages.
1228
+ * - Falls back to any user message for backward compat (OpenClaw without provenance).
1229
+ * - Strips channel envelopes and system prefixes from message text.
1230
+ * - Skips system-only content (edited notifications, sync events).
1231
+ * - Short messages (< 30 chars): prepends previous for context.
1232
+ * - Hard-caps at 500 characters.
1176
1233
  */
1177
1234
  export function buildRecallQuery(messages: unknown[]): string {
1178
1235
  const texts = extractMessageTexts(messages);
1179
- const userMessages: string[] = [];
1180
- for (let i = texts.length - 1; i >= 0 && userMessages.length < 2; i--) {
1181
- if (texts[i].role === "user") {
1182
- const cleaned = cleanMessageForQuery(texts[i].text);
1183
- if (cleaned) userMessages.unshift(cleaned);
1184
- }
1185
- }
1186
1236
 
1187
- if (userMessages.length === 0) return "";
1237
+ // Step 1: Filter out inter_session messages (sub-agent results, sessions_send)
1238
+ const candidates = texts.filter(
1239
+ t => t.role === "user" && t.provenance !== "inter_session" && t.provenance !== "internal_system"
1240
+ );
1241
+
1242
+ // Fallback: if no messages without provenance, use all user messages
1243
+ const userMsgs = candidates.length > 0
1244
+ ? candidates
1245
+ : texts.filter(t => t.role === "user");
1188
1246
 
1189
- const lastMsg = userMessages[userMessages.length - 1];
1247
+ if (userMsgs.length === 0) return "";
1190
1248
 
1191
- // If the last user message is very short, include previous for context
1192
- let query: string;
1193
- if (lastMsg.length < 30 && userMessages.length > 1) {
1194
- query = `${userMessages[userMessages.length - 2]} ${lastMsg}`;
1195
- } else {
1196
- query = lastMsg;
1249
+ // Step 2: Strip envelopes from the last user message(s)
1250
+ let lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[userMsgs.length - 1].text.trim()));
1251
+
1252
+ // Skip system-only messages (edited notifications, sync events, etc.)
1253
+ // Walk backwards to find a message with actual content
1254
+ let idx = userMsgs.length - 1;
1255
+ while (idx >= 0 && (!lastText || isSystemOnlyContent(lastText))) {
1256
+ idx--;
1257
+ if (idx >= 0) {
1258
+ lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[idx].text.trim()));
1259
+ }
1197
1260
  }
1198
1261
 
1199
- // Hard cap at 500 characters
1200
- if (query.length > 500) {
1201
- query = query.slice(0, 500);
1262
+ if (!lastText) return "";
1263
+
1264
+ // Step 3: Short messages → include previous for context
1265
+ if (lastText.length < 30 && idx > 0) {
1266
+ const prevText = stripSystemPrefix(stripChannelEnvelope(userMsgs[idx - 1].text.trim()));
1267
+ if (prevText && !isSystemOnlyContent(prevText)) {
1268
+ return `${prevText} ${lastText}`.slice(0, 500);
1269
+ }
1202
1270
  }
1203
1271
 
1204
- return query.trim();
1272
+ return lastText.slice(0, 500);
1205
1273
  }
1206
1274
 
1207
1275
  // ============================================================================
@@ -1304,22 +1372,27 @@ export function resetTurnState(): void {
1304
1372
  * Register lifecycle hooks on the plugin API.
1305
1373
  */
1306
1374
  export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1375
+ // Store api.logger for module-wide use (integrates into OpenClaw log system)
1376
+ if (api.logger && typeof api.logger.info === "function") {
1377
+ logger = api.logger;
1378
+ }
1379
+
1307
1380
  const opts = buildRunnerOpts(config);
1308
1381
 
1309
- // ── Startup checks (H-2, H-3) ─────────────────────────────────
1382
+ // ── Startup checks (H-2, H-3, captureModel validation) ────────
1310
1383
  (async () => {
1311
1384
  // H-2: Warn if no agent is configured
1312
1385
  if (!process.env.PALAIA_AGENT) {
1313
1386
  try {
1314
1387
  const statusOut = await run(["config", "get", "agent"], { ...opts, timeoutMs: 3000 });
1315
1388
  if (!statusOut.trim()) {
1316
- console.warn(
1389
+ logger.warn(
1317
1390
  "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
1318
1391
  "Auto-captured entries will have no agent attribution."
1319
1392
  );
1320
1393
  }
1321
1394
  } catch {
1322
- console.warn(
1395
+ logger.warn(
1323
1396
  "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
1324
1397
  "Auto-captured entries will have no agent attribution."
1325
1398
  );
@@ -1346,7 +1419,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1346
1419
  || status.config?.embedding_provider
1347
1420
  );
1348
1421
  if (!hasSemanticProvider && !hasProviderConfig) {
1349
- console.warn(
1422
+ logger.warn(
1350
1423
  "[palaia] No embedding provider configured. Semantic search is inactive (BM25 keyword-only). " +
1351
1424
  "Run 'pip install palaia[fastembed]' and 'palaia doctor --fix' for better recall quality."
1352
1425
  );
@@ -1356,6 +1429,19 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1356
1429
  } catch {
1357
1430
  // Non-fatal — status check failed, skip warning (avoid false positive)
1358
1431
  }
1432
+
1433
+ // Validate captureModel auth at plugin startup via modelAuth API
1434
+ if (config.captureModel && api.runtime?.modelAuth) {
1435
+ try {
1436
+ const resolved = resolveCaptureModel(api.config, config.captureModel);
1437
+ if (resolved?.provider) {
1438
+ const key = await api.runtime.modelAuth.resolveApiKeyForProvider({ provider: resolved.provider, cfg: api.config });
1439
+ if (!key) {
1440
+ logger.warn(`[palaia] captureModel provider "${resolved.provider}" has no API key — auto-capture LLM extraction will fail`);
1441
+ }
1442
+ }
1443
+ } catch { /* non-fatal */ }
1444
+ }
1359
1445
  })();
1360
1446
 
1361
1447
  // ── /palaia status command ─────────────────────────────────────
@@ -1445,7 +1531,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1445
1531
  entries = result.results;
1446
1532
  }
1447
1533
  } catch (queryError) {
1448
- console.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
1534
+ logger.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
1449
1535
  }
1450
1536
  }
1451
1537
  }
@@ -1556,7 +1642,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1556
1642
  : undefined,
1557
1643
  };
1558
1644
  } catch (error) {
1559
- console.warn(`[palaia] Memory injection failed: ${error}`);
1645
+ logger.warn(`[palaia] Memory injection failed: ${error}`);
1560
1646
  }
1561
1647
  });
1562
1648
  }
@@ -1669,7 +1755,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1669
1755
  (p) => p.name.toLowerCase() === validatedProject!.toLowerCase(),
1670
1756
  );
1671
1757
  if (!isKnown) {
1672
- console.log(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
1758
+ logger.info(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
1673
1759
  validatedProject = null;
1674
1760
  }
1675
1761
  }
@@ -1686,7 +1772,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1686
1772
  effectiveScope,
1687
1773
  );
1688
1774
  await run(args, { ...opts, timeoutMs: 10_000 });
1689
- console.log(
1775
+ logger.info(
1690
1776
  `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
1691
1777
  );
1692
1778
  }
@@ -1711,7 +1797,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1711
1797
  // captureModel is broken — try primary model as fallback
1712
1798
  if (!_captureModelFailoverWarned) {
1713
1799
  _captureModelFailoverWarned = true;
1714
- console.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
1800
+ logger.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
1715
1801
  }
1716
1802
  try {
1717
1803
  // Retry without captureModel → resolveCaptureModel will use primary model
@@ -1722,13 +1808,13 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1722
1808
  llmHandled = true;
1723
1809
  } catch (fallbackError) {
1724
1810
  if (!_llmImportFailureLogged) {
1725
- console.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
1811
+ logger.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
1726
1812
  _llmImportFailureLogged = true;
1727
1813
  }
1728
1814
  }
1729
1815
  } else {
1730
1816
  if (!_llmImportFailureLogged) {
1731
- console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1817
+ logger.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1732
1818
  _llmImportFailureLogged = true;
1733
1819
  }
1734
1820
  }
@@ -1770,7 +1856,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1770
1856
  );
1771
1857
 
1772
1858
  await run(args, { ...opts, timeoutMs: 10_000 });
1773
- console.log(
1859
+ logger.info(
1774
1860
  `[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
1775
1861
  );
1776
1862
  }
@@ -1782,7 +1868,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1782
1868
  } else {
1783
1869
  }
1784
1870
  } catch (error) {
1785
- console.warn(`[palaia] Auto-capture failed: ${error}`);
1871
+ logger.warn(`[palaia] Auto-capture failed: ${error}`);
1786
1872
  }
1787
1873
 
1788
1874
  // ── Emoji Reactions (Issue #87) ──────────────────────────
@@ -1814,7 +1900,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1814
1900
  }
1815
1901
  }
1816
1902
  } catch (reactionError) {
1817
- console.warn(`[palaia] Reaction sending failed: ${reactionError}`);
1903
+ logger.warn(`[palaia] Reaction sending failed: ${reactionError}`);
1818
1904
  } finally {
1819
1905
  // Always clean up turn state
1820
1906
  deleteTurnState(sessionKey);
@@ -1844,7 +1930,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1844
1930
  }
1845
1931
  }
1846
1932
  } catch (err) {
1847
- console.warn(`[palaia] Recall reaction failed: ${err}`);
1933
+ logger.warn(`[palaia] Recall reaction failed: ${err}`);
1848
1934
  } finally {
1849
1935
  deleteTurnState(sessionKey);
1850
1936
  }
@@ -1857,10 +1943,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1857
1943
  start: async () => {
1858
1944
  const result = await recover(opts);
1859
1945
  if (result.replayed > 0) {
1860
- console.log(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
1946
+ logger.info(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
1861
1947
  }
1862
1948
  if (result.errors > 0) {
1863
- console.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
1949
+ logger.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
1864
1950
  }
1865
1951
  },
1866
1952
  });