@byte5ai/palaia 2.0.0 → 2.0.3

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 +201 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byte5ai/palaia",
3
- "version": "2.0.0",
3
+ "version": "2.0.3",
4
4
  "description": "Palaia memory backend for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
package/src/hooks.ts CHANGED
@@ -207,6 +207,25 @@ export function extractSlackChannelIdFromSessionKey(sessionKey: string): string
207
207
  return undefined;
208
208
  }
209
209
 
210
+ /**
211
+ * Extract the real Slack channel ID from event metadata or ctx.
212
+ * OpenClaw stores the channel in "channel:C0AKE2G15HV" format in:
213
+ * - event.metadata.to
214
+ * - event.metadata.originatingTo
215
+ * - ctx.conversationId
216
+ *
217
+ * ctx.channelId is the PROVIDER NAME ("slack"), not the channel ID.
218
+ * ctx.sessionKey is null during message_received.
219
+ */
220
+ export function extractChannelIdFromEvent(event: any, ctx: any): string | undefined {
221
+ const rawTo = event?.metadata?.to
222
+ ?? event?.metadata?.originatingTo
223
+ ?? ctx?.conversationId
224
+ ?? "";
225
+ const match = String(rawTo).match(/^(?:channel|dm|group):([A-Z0-9]+)$/i);
226
+ return match ? match[1].toUpperCase() : undefined;
227
+ }
228
+
210
229
  /**
211
230
  * Resolve the session key for the current turn from available ctx.
212
231
  * Tries ctx.sessionKey first, then falls back to sessionId.
@@ -713,21 +732,38 @@ function buildExtractionPrompt(projects: CachedProject[]): string {
713
732
  return `${EXTRACTION_SYSTEM_PROMPT_BASE}\n\nKnown projects: ${projectList}`;
714
733
  }
715
734
 
716
- const CHEAP_MODELS: Record<string, string> = {
717
- anthropic: "claude-haiku-4",
718
- openai: "gpt-4.1-mini",
719
- google: "gemini-2.0-flash",
720
- };
735
+ /** Whether the captureModel fallback warning has already been logged (to avoid spam). */
736
+ let _captureModelFallbackWarned = false;
737
+
738
+ /** Whether the captureModel→primary model fallback warning has been logged (max 1x per gateway lifetime). */
739
+ let _captureModelFailoverWarned = false;
721
740
 
741
+ /** Reset captureModel fallback warning flag (for testing). */
742
+ export function resetCaptureModelFallbackWarning(): void {
743
+ _captureModelFallbackWarned = false;
744
+ _captureModelFailoverWarned = false;
745
+ }
746
+
747
+ /**
748
+ * Resolve the model to use for LLM-based capture extraction.
749
+ *
750
+ * Strategy (no static model mapping — user config is the source of truth):
751
+ * 1. If captureModel is set explicitly (e.g. "anthropic/claude-haiku-4-5"): use it directly.
752
+ * 2. If captureModel is unset: use the primary model from user config.
753
+ * Log a one-time warning recommending to set a cheaper captureModel.
754
+ * 3. Never fall back to static model IDs — model IDs change and not every user has Anthropic.
755
+ */
722
756
  export function resolveCaptureModel(
723
757
  config: any,
724
758
  captureModel?: string,
725
759
  ): { provider: string; model: string } | undefined {
760
+ // Case 1: explicit model ID provided (not "cheap")
726
761
  if (captureModel && captureModel !== "cheap") {
727
762
  const parts = captureModel.split("/");
728
763
  if (parts.length >= 2) {
729
764
  return { provider: parts[0], model: parts.slice(1).join("/") };
730
765
  }
766
+ // No slash — treat as model name with provider from primary config
731
767
  const defaultsModel = config?.agents?.defaults?.model;
732
768
  const primary = typeof defaultsModel === "string"
733
769
  ? defaultsModel.trim()
@@ -738,19 +774,24 @@ export function resolveCaptureModel(
738
774
  }
739
775
  }
740
776
 
777
+ // Case 2: "cheap" or unset — use primary model from user config
741
778
  const defaultsModel = config?.agents?.defaults?.model;
779
+
742
780
  const primary = typeof defaultsModel === "string"
743
781
  ? defaultsModel.trim()
744
- : (defaultsModel?.primary?.trim() ?? "");
745
- const defaultProvider = primary.split("/")[0];
746
- const defaultModel = primary.split("/").slice(1).join("/");
747
-
748
- if (defaultProvider && CHEAP_MODELS[defaultProvider]) {
749
- return { provider: defaultProvider, model: CHEAP_MODELS[defaultProvider] };
750
- }
782
+ : (typeof defaultsModel === "object" && defaultsModel !== null
783
+ ? String(defaultsModel.primary ?? "").trim()
784
+ : "");
751
785
 
752
- if (defaultProvider && defaultModel) {
753
- return { provider: defaultProvider, model: defaultModel };
786
+ if (primary) {
787
+ const parts = primary.split("/");
788
+ if (parts.length >= 2) {
789
+ if (!_captureModelFallbackWarned) {
790
+ _captureModelFallbackWarned = true;
791
+ console.warn(`[palaia] No captureModel configured — using primary model. Set captureModel in plugin config for cost savings.`);
792
+ }
793
+ return { provider: parts[0], model: parts.slice(1).join("/") };
794
+ }
754
795
  }
755
796
 
756
797
  return undefined;
@@ -771,6 +812,52 @@ function collectText(payloads: Array<{ text?: string; isError?: boolean }> | und
771
812
  .trim();
772
813
  }
773
814
 
815
+ /**
816
+ * Trim message texts to a recent window for LLM extraction.
817
+ * Only extract from recent exchanges — full history causes LLM timeouts
818
+ * and dilutes extraction quality.
819
+ *
820
+ * Strategy: keep last N user+assistant pairs (skip toolResult roles),
821
+ * then hard-cap at maxChars from the end (newest messages kept).
822
+ */
823
+ export function trimToRecentExchanges(
824
+ texts: Array<{ role: string; text: string }>,
825
+ maxPairs = 5,
826
+ maxChars = 10_000,
827
+ ): Array<{ role: string; text: string }> {
828
+ // Filter to only user + assistant messages (skip tool, toolResult, system, etc.)
829
+ const exchanges = texts.filter((t) => t.role === "user" || t.role === "assistant");
830
+
831
+ // Keep the last N pairs (a pair = one user + one assistant message)
832
+ // Walk backwards, count pairs
833
+ let pairCount = 0;
834
+ let lastRole = "";
835
+ let cutIndex = 0; // default: keep everything
836
+ for (let i = exchanges.length - 1; i >= 0; i--) {
837
+ // Count a new pair when we see a user message after having seen an assistant
838
+ if (exchanges[i].role === "user" && lastRole === "assistant") {
839
+ pairCount++;
840
+ if (pairCount > maxPairs) {
841
+ cutIndex = i + 1; // keep from next message onwards
842
+ break;
843
+ }
844
+ }
845
+ if (exchanges[i].role !== lastRole) {
846
+ lastRole = exchanges[i].role;
847
+ }
848
+ }
849
+ let trimmed = exchanges.slice(cutIndex);
850
+
851
+ // Hard cap: max chars from the end (keep newest)
852
+ let totalChars = trimmed.reduce((sum, t) => sum + t.text.length + t.role.length + 5, 0);
853
+ while (totalChars > maxChars && trimmed.length > 1) {
854
+ const removed = trimmed.shift()!;
855
+ totalChars -= removed.text.length + removed.role.length + 5;
856
+ }
857
+
858
+ return trimmed;
859
+ }
860
+
774
861
  export async function extractWithLLM(
775
862
  messages: unknown[],
776
863
  config: any,
@@ -784,9 +871,11 @@ export async function extractWithLLM(
784
871
  throw new Error("No model available for LLM extraction");
785
872
  }
786
873
 
787
- const texts = extractMessageTexts(messages);
788
- const exchangeText = texts
789
- .filter((t) => t.role === "user" || t.role === "assistant")
874
+ const allTexts = extractMessageTexts(messages);
875
+ // Only extract from recent exchanges — full history causes LLM timeouts
876
+ // and dilutes extraction quality
877
+ const recentTexts = trimToRecentExchanges(allTexts);
878
+ const exchangeText = recentTexts
790
879
  .map((t) => `[${t.role}]: ${t.text}`)
791
880
  .join("\n");
792
881
 
@@ -1212,14 +1301,32 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1212
1301
  try {
1213
1302
  const messageId = event?.metadata?.messageId;
1214
1303
  const provider = event?.metadata?.provider;
1215
- const channelId = ctx?.channelId;
1304
+
1305
+ // ctx.channelId returns the provider name ("slack"), NOT the actual channel ID.
1306
+ // ctx.sessionKey is null during message_received.
1307
+ // Extract the real channel ID from event.metadata.to / ctx.conversationId.
1308
+ const channelId = extractChannelIdFromEvent(event, ctx)
1309
+ ?? (resolveSessionKeyFromCtx(ctx) ? extractSlackChannelIdFromSessionKey(resolveSessionKeyFromCtx(ctx)!) : undefined);
1310
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
1311
+
1216
1312
 
1217
1313
  if (messageId && channelId && provider && REACTION_SUPPORTED_PROVIDERS.has(provider)) {
1218
- lastInboundMessageByChannel.set(channelId, {
1314
+ // Normalize channelId to UPPERCASE for consistent lookups
1315
+ // (extractSlackChannelIdFromSessionKey returns uppercase)
1316
+ const normalizedChannelId = String(channelId).toUpperCase();
1317
+ lastInboundMessageByChannel.set(normalizedChannelId, {
1219
1318
  messageId: String(messageId),
1220
1319
  provider,
1221
1320
  timestamp: Date.now(),
1222
1321
  });
1322
+
1323
+ // Also populate turnState if sessionKey is available
1324
+ if (sessionKey) {
1325
+ const turnState = getOrCreateTurnState(sessionKey);
1326
+ turnState.lastInboundMessageId = String(messageId);
1327
+ turnState.lastInboundChannelId = normalizedChannelId;
1328
+ turnState.channelProvider = provider;
1329
+ }
1223
1330
  }
1224
1331
  } catch {
1225
1332
  // Non-fatal — never block message flow
@@ -1309,15 +1416,21 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1309
1416
  }
1310
1417
 
1311
1418
  // Track recall in session-isolated turn state for emoji reactions
1419
+ // Only flag recall as meaningful if at least one result scores above threshold
1420
+ const RECALL_RELEVANCE_THRESHOLD = 0.7;
1421
+ const hasRelevantRecall = entries.some(
1422
+ (e) => typeof e.score === "number" && e.score >= RECALL_RELEVANCE_THRESHOLD,
1423
+ );
1312
1424
  const sessionKey = resolveSessionKeyFromCtx(ctx);
1313
- if (sessionKey) {
1425
+ if (sessionKey && hasRelevantRecall) {
1314
1426
  const turnState = getOrCreateTurnState(sessionKey);
1315
1427
  turnState.recallOccurred = true;
1316
1428
 
1317
- // Populate channel info from sessionKey for reaction routing
1429
+ // Populate channel info prefer event metadata, fall back to sessionKey
1318
1430
  const provider = extractChannelFromSessionKey(sessionKey);
1319
1431
  if (provider) turnState.channelProvider = provider;
1320
- const slackChannel = extractSlackChannelIdFromSessionKey(sessionKey);
1432
+ const slackChannel = extractChannelIdFromEvent(event, ctx)
1433
+ ?? extractSlackChannelIdFromSessionKey(sessionKey);
1321
1434
  if (slackChannel) turnState.lastInboundChannelId = slackChannel;
1322
1435
 
1323
1436
  // Try to get the inbound message ID from the message_received store
@@ -1356,20 +1469,24 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1356
1469
  // ── agent_end (Issue #64 + #81: Auto-Capture with Metadata + Reactions) ───
1357
1470
  if (config.autoCapture) {
1358
1471
  api.on("agent_end", async (event: any, ctx: any) => {
1472
+ // Resolve session key for turn state
1473
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
1474
+
1475
+ // DEBUG: always log agent_end firing
1476
+
1359
1477
  if (!event.success || !event.messages || event.messages.length === 0) {
1360
1478
  return;
1361
1479
  }
1362
1480
 
1363
- // Resolve session key for turn state
1364
- const sessionKey = resolveSessionKeyFromCtx(ctx);
1365
-
1366
1481
  try {
1367
1482
  const agentName = process.env.PALAIA_AGENT || undefined;
1368
1483
 
1369
1484
  const allTexts = extractMessageTexts(event.messages);
1370
1485
 
1371
1486
  const userTurns = allTexts.filter((t) => t.role === "user").length;
1372
- if (userTurns < config.captureMinTurns) return;
1487
+ if (userTurns < config.captureMinTurns) {
1488
+ return;
1489
+ }
1373
1490
 
1374
1491
  // Parse capture hints from all messages (Issue #81)
1375
1492
  const collectedHints: PalaiaHint[] = [];
@@ -1378,17 +1495,21 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1378
1495
  collectedHints.push(...hints);
1379
1496
  }
1380
1497
 
1381
- // Build exchange text
1498
+ // Only extract from recent exchanges — full history causes LLM timeouts
1499
+ // and dilutes extraction quality
1500
+ const recentTexts = trimToRecentExchanges(allTexts);
1501
+
1502
+ // Build exchange text from recent window only
1382
1503
  const exchangeParts: string[] = [];
1383
- for (const t of allTexts) {
1384
- if (t.role === "user" || t.role === "assistant") {
1385
- const { cleanedText } = parsePalaiaHints(t.text);
1386
- exchangeParts.push(`[${t.role}]: ${cleanedText}`);
1387
- }
1504
+ for (const t of recentTexts) {
1505
+ const { cleanedText } = parsePalaiaHints(t.text);
1506
+ exchangeParts.push(`[${t.role}]: ${cleanedText}`);
1388
1507
  }
1389
1508
  const exchangeText = exchangeParts.join("\n");
1390
1509
 
1391
- if (!shouldAttemptCapture(exchangeText)) return;
1510
+ if (!shouldAttemptCapture(exchangeText)) {
1511
+ return;
1512
+ }
1392
1513
 
1393
1514
  const knownProjects = await loadProjects(opts);
1394
1515
 
@@ -1422,13 +1543,8 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1422
1543
  return args;
1423
1544
  };
1424
1545
 
1425
- // LLM-based extraction (primary)
1426
- let llmHandled = false;
1427
- try {
1428
- const results = await extractWithLLM(event.messages, api.config, {
1429
- captureModel: config.captureModel,
1430
- }, knownProjects);
1431
-
1546
+ // Helper: store LLM extraction results
1547
+ const storeLLMResults = async (results: ExtractionResult[]) => {
1432
1548
  for (const r of results) {
1433
1549
  if (r.significance >= config.captureMinSignificance) {
1434
1550
  const hintForProject = collectedHints.find((h) => h.project);
@@ -1450,12 +1566,46 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1450
1566
  );
1451
1567
  }
1452
1568
  }
1569
+ };
1570
+
1571
+ // LLM-based extraction (primary)
1572
+ let llmHandled = false;
1573
+ try {
1574
+ const results = await extractWithLLM(event.messages, api.config, {
1575
+ captureModel: config.captureModel,
1576
+ }, knownProjects);
1453
1577
 
1578
+ await storeLLMResults(results);
1454
1579
  llmHandled = true;
1455
1580
  } catch (llmError) {
1456
- if (!_llmImportFailureLogged) {
1457
- console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1458
- _llmImportFailureLogged = true;
1581
+ // Check if this is a model-availability error (not a generic import failure)
1582
+ const errStr = String(llmError);
1583
+ const isModelError = /FailoverError|Unknown model|unknown model|401|403|model.*not found|not_found|model_not_found/i.test(errStr);
1584
+
1585
+ if (isModelError && config.captureModel) {
1586
+ // captureModel is broken — try primary model as fallback
1587
+ if (!_captureModelFailoverWarned) {
1588
+ _captureModelFailoverWarned = true;
1589
+ console.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
1590
+ }
1591
+ try {
1592
+ // Retry without captureModel → resolveCaptureModel will use primary model
1593
+ const fallbackResults = await extractWithLLM(event.messages, api.config, {
1594
+ captureModel: undefined,
1595
+ }, knownProjects);
1596
+ await storeLLMResults(fallbackResults);
1597
+ llmHandled = true;
1598
+ } catch (fallbackError) {
1599
+ if (!_llmImportFailureLogged) {
1600
+ console.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
1601
+ _llmImportFailureLogged = true;
1602
+ }
1603
+ }
1604
+ } else {
1605
+ if (!_llmImportFailureLogged) {
1606
+ console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1607
+ _llmImportFailureLogged = true;
1608
+ }
1459
1609
  }
1460
1610
  }
1461
1611
 
@@ -1465,7 +1615,9 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1465
1615
 
1466
1616
  if (config.captureFrequency === "significant") {
1467
1617
  const significance = extractSignificance(exchangeText);
1468
- if (!significance) return;
1618
+ if (!significance) {
1619
+ return;
1620
+ }
1469
1621
  captureData = significance;
1470
1622
  } else {
1471
1623
  const summary = exchangeParts
@@ -1497,6 +1649,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1497
1649
  if (sessionKey) {
1498
1650
  const turnState = getOrCreateTurnState(sessionKey);
1499
1651
  turnState.capturedInThisTurn = true;
1652
+ } else {
1500
1653
  }
1501
1654
  } catch (error) {
1502
1655
  console.warn(`[palaia] Auto-capture failed: ${error}`);
@@ -1512,9 +1665,11 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1512
1665
  || extractChannelFromSessionKey(sessionKey)
1513
1666
  || (ctx?.channelId as string | undefined);
1514
1667
  const channelId = turnState.lastInboundChannelId
1668
+ || extractChannelIdFromEvent(event, ctx)
1515
1669
  || extractSlackChannelIdFromSessionKey(sessionKey);
1516
1670
  const messageId = turnState.lastInboundMessageId;
1517
1671
 
1672
+
1518
1673
  if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
1519
1674
  // Capture confirmation: 💾
1520
1675
  if (turnState.capturedInThisTurn && config.showCaptureConfirm) {
@@ -1525,6 +1680,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1525
1680
  if (turnState.recallOccurred && config.showMemorySources) {
1526
1681
  await sendReaction(channelId, messageId, "brain", provider);
1527
1682
  }
1683
+ } else {
1528
1684
  }
1529
1685
  }
1530
1686
  } catch (reactionError) {
@@ -1549,6 +1705,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1549
1705
  const provider = turnState.channelProvider
1550
1706
  || extractChannelFromSessionKey(sessionKey);
1551
1707
  const channelId = turnState.lastInboundChannelId
1708
+ || extractChannelIdFromEvent(_event, ctx)
1552
1709
  || extractSlackChannelIdFromSessionKey(sessionKey);
1553
1710
  const messageId = turnState.lastInboundMessageId;
1554
1711