@byte5ai/palaia 2.0.4 → 2.0.5

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 +96 -88
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byte5ai/palaia",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
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,44 @@ 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
+ // Recall Query Builder (provenance-based, Issue #65 upgrade)
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 "];
1136
-
1137
1160
  /**
1138
- * Clean a raw message string by removing system markers, JSON blocks,
1139
- * and other noise that degrades semantic search quality.
1140
- */
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();
1162
-
1163
- return cleaned;
1164
- }
1165
-
1166
- /**
1167
- * Build a recall query from message history.
1161
+ * Build a recall query from message history using provenance to identify real user input.
1168
1162
  *
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.
1163
+ * - Prefers external_user messages (real human input from Slack/Telegram).
1164
+ * - Falls back to any user message for backward compat (OpenClaw without provenance).
1165
+ * - If the last user message is short (< 30 chars), prepends the previous for context.
1173
1166
  * - Hard-caps at 500 characters.
1174
1167
  *
1175
- * Returns empty string if nothing usable remains.
1168
+ * Provenance makes the old heuristic cleaners (DAY_PREFIXES, system marker stripping) obsolete.
1176
1169
  */
1177
1170
  export function buildRecallQuery(messages: unknown[]): string {
1178
1171
  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
1172
 
1187
- if (userMessages.length === 0) return "";
1173
+ // Prefer external_user messages (real human input)
1174
+ const externalUserMsgs = texts.filter(
1175
+ t => t.role === "user" && t.provenance === "external_user"
1176
+ );
1177
+
1178
+ // Fallback: any user message (backward compat for OpenClaw without provenance)
1179
+ const userMsgs = externalUserMsgs.length > 0
1180
+ ? externalUserMsgs
1181
+ : texts.filter(t => t.role === "user");
1188
1182
 
1189
- const lastMsg = userMessages[userMessages.length - 1];
1183
+ if (userMsgs.length === 0) return "";
1190
1184
 
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;
1197
- }
1185
+ const lastMsg = userMsgs[userMsgs.length - 1].text.trim();
1198
1186
 
1199
- // Hard cap at 500 characters
1200
- if (query.length > 500) {
1201
- query = query.slice(0, 500);
1187
+ // Short messages: include previous for context
1188
+ if (lastMsg.length < 30 && userMsgs.length > 1) {
1189
+ const prevMsg = userMsgs[userMsgs.length - 2].text.trim();
1190
+ const combined = `${prevMsg} ${lastMsg}`.slice(0, 500);
1191
+ return combined;
1202
1192
  }
1203
1193
 
1204
- return query.trim();
1194
+ return lastMsg.slice(0, 500);
1205
1195
  }
1206
1196
 
1207
1197
  // ============================================================================
@@ -1304,22 +1294,27 @@ export function resetTurnState(): void {
1304
1294
  * Register lifecycle hooks on the plugin API.
1305
1295
  */
1306
1296
  export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1297
+ // Store api.logger for module-wide use (integrates into OpenClaw log system)
1298
+ if (api.logger && typeof api.logger.info === "function") {
1299
+ logger = api.logger;
1300
+ }
1301
+
1307
1302
  const opts = buildRunnerOpts(config);
1308
1303
 
1309
- // ── Startup checks (H-2, H-3) ─────────────────────────────────
1304
+ // ── Startup checks (H-2, H-3, captureModel validation) ────────
1310
1305
  (async () => {
1311
1306
  // H-2: Warn if no agent is configured
1312
1307
  if (!process.env.PALAIA_AGENT) {
1313
1308
  try {
1314
1309
  const statusOut = await run(["config", "get", "agent"], { ...opts, timeoutMs: 3000 });
1315
1310
  if (!statusOut.trim()) {
1316
- console.warn(
1311
+ logger.warn(
1317
1312
  "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
1318
1313
  "Auto-captured entries will have no agent attribution."
1319
1314
  );
1320
1315
  }
1321
1316
  } catch {
1322
- console.warn(
1317
+ logger.warn(
1323
1318
  "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
1324
1319
  "Auto-captured entries will have no agent attribution."
1325
1320
  );
@@ -1346,7 +1341,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1346
1341
  || status.config?.embedding_provider
1347
1342
  );
1348
1343
  if (!hasSemanticProvider && !hasProviderConfig) {
1349
- console.warn(
1344
+ logger.warn(
1350
1345
  "[palaia] No embedding provider configured. Semantic search is inactive (BM25 keyword-only). " +
1351
1346
  "Run 'pip install palaia[fastembed]' and 'palaia doctor --fix' for better recall quality."
1352
1347
  );
@@ -1356,6 +1351,19 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1356
1351
  } catch {
1357
1352
  // Non-fatal — status check failed, skip warning (avoid false positive)
1358
1353
  }
1354
+
1355
+ // Validate captureModel auth at plugin startup via modelAuth API
1356
+ if (config.captureModel && api.runtime?.modelAuth) {
1357
+ try {
1358
+ const resolved = resolveCaptureModel(api.config, config.captureModel);
1359
+ if (resolved?.provider) {
1360
+ const key = await api.runtime.modelAuth.resolveApiKeyForProvider({ provider: resolved.provider, cfg: api.config });
1361
+ if (!key) {
1362
+ logger.warn(`[palaia] captureModel provider "${resolved.provider}" has no API key — auto-capture LLM extraction will fail`);
1363
+ }
1364
+ }
1365
+ } catch { /* non-fatal */ }
1366
+ }
1359
1367
  })();
1360
1368
 
1361
1369
  // ── /palaia status command ─────────────────────────────────────
@@ -1445,7 +1453,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1445
1453
  entries = result.results;
1446
1454
  }
1447
1455
  } catch (queryError) {
1448
- console.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
1456
+ logger.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
1449
1457
  }
1450
1458
  }
1451
1459
  }
@@ -1556,7 +1564,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1556
1564
  : undefined,
1557
1565
  };
1558
1566
  } catch (error) {
1559
- console.warn(`[palaia] Memory injection failed: ${error}`);
1567
+ logger.warn(`[palaia] Memory injection failed: ${error}`);
1560
1568
  }
1561
1569
  });
1562
1570
  }
@@ -1669,7 +1677,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1669
1677
  (p) => p.name.toLowerCase() === validatedProject!.toLowerCase(),
1670
1678
  );
1671
1679
  if (!isKnown) {
1672
- console.log(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
1680
+ logger.info(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
1673
1681
  validatedProject = null;
1674
1682
  }
1675
1683
  }
@@ -1686,7 +1694,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1686
1694
  effectiveScope,
1687
1695
  );
1688
1696
  await run(args, { ...opts, timeoutMs: 10_000 });
1689
- console.log(
1697
+ logger.info(
1690
1698
  `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
1691
1699
  );
1692
1700
  }
@@ -1711,7 +1719,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1711
1719
  // captureModel is broken — try primary model as fallback
1712
1720
  if (!_captureModelFailoverWarned) {
1713
1721
  _captureModelFailoverWarned = true;
1714
- console.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
1722
+ logger.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
1715
1723
  }
1716
1724
  try {
1717
1725
  // Retry without captureModel → resolveCaptureModel will use primary model
@@ -1722,13 +1730,13 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1722
1730
  llmHandled = true;
1723
1731
  } catch (fallbackError) {
1724
1732
  if (!_llmImportFailureLogged) {
1725
- console.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
1733
+ logger.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
1726
1734
  _llmImportFailureLogged = true;
1727
1735
  }
1728
1736
  }
1729
1737
  } else {
1730
1738
  if (!_llmImportFailureLogged) {
1731
- console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1739
+ logger.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1732
1740
  _llmImportFailureLogged = true;
1733
1741
  }
1734
1742
  }
@@ -1770,7 +1778,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1770
1778
  );
1771
1779
 
1772
1780
  await run(args, { ...opts, timeoutMs: 10_000 });
1773
- console.log(
1781
+ logger.info(
1774
1782
  `[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
1775
1783
  );
1776
1784
  }
@@ -1782,7 +1790,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1782
1790
  } else {
1783
1791
  }
1784
1792
  } catch (error) {
1785
- console.warn(`[palaia] Auto-capture failed: ${error}`);
1793
+ logger.warn(`[palaia] Auto-capture failed: ${error}`);
1786
1794
  }
1787
1795
 
1788
1796
  // ── Emoji Reactions (Issue #87) ──────────────────────────
@@ -1814,7 +1822,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1814
1822
  }
1815
1823
  }
1816
1824
  } catch (reactionError) {
1817
- console.warn(`[palaia] Reaction sending failed: ${reactionError}`);
1825
+ logger.warn(`[palaia] Reaction sending failed: ${reactionError}`);
1818
1826
  } finally {
1819
1827
  // Always clean up turn state
1820
1828
  deleteTurnState(sessionKey);
@@ -1844,7 +1852,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1844
1852
  }
1845
1853
  }
1846
1854
  } catch (err) {
1847
- console.warn(`[palaia] Recall reaction failed: ${err}`);
1855
+ logger.warn(`[palaia] Recall reaction failed: ${err}`);
1848
1856
  } finally {
1849
1857
  deleteTurnState(sessionKey);
1850
1858
  }
@@ -1857,10 +1865,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1857
1865
  start: async () => {
1858
1866
  const result = await recover(opts);
1859
1867
  if (result.replayed > 0) {
1860
- console.log(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
1868
+ logger.info(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
1861
1869
  }
1862
1870
  if (result.errors > 0) {
1863
- console.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
1871
+ logger.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
1864
1872
  }
1865
1873
  },
1866
1874
  });