@byte5ai/palaia 2.0.0 → 2.0.2

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 +195 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byte5ai/palaia",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
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
@@ -1314,10 +1421,11 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1314
1421
  const turnState = getOrCreateTurnState(sessionKey);
1315
1422
  turnState.recallOccurred = true;
1316
1423
 
1317
- // Populate channel info from sessionKey for reaction routing
1424
+ // Populate channel info prefer event metadata, fall back to sessionKey
1318
1425
  const provider = extractChannelFromSessionKey(sessionKey);
1319
1426
  if (provider) turnState.channelProvider = provider;
1320
- const slackChannel = extractSlackChannelIdFromSessionKey(sessionKey);
1427
+ const slackChannel = extractChannelIdFromEvent(event, ctx)
1428
+ ?? extractSlackChannelIdFromSessionKey(sessionKey);
1321
1429
  if (slackChannel) turnState.lastInboundChannelId = slackChannel;
1322
1430
 
1323
1431
  // Try to get the inbound message ID from the message_received store
@@ -1356,20 +1464,24 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1356
1464
  // ── agent_end (Issue #64 + #81: Auto-Capture with Metadata + Reactions) ───
1357
1465
  if (config.autoCapture) {
1358
1466
  api.on("agent_end", async (event: any, ctx: any) => {
1467
+ // Resolve session key for turn state
1468
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
1469
+
1470
+ // DEBUG: always log agent_end firing
1471
+
1359
1472
  if (!event.success || !event.messages || event.messages.length === 0) {
1360
1473
  return;
1361
1474
  }
1362
1475
 
1363
- // Resolve session key for turn state
1364
- const sessionKey = resolveSessionKeyFromCtx(ctx);
1365
-
1366
1476
  try {
1367
1477
  const agentName = process.env.PALAIA_AGENT || undefined;
1368
1478
 
1369
1479
  const allTexts = extractMessageTexts(event.messages);
1370
1480
 
1371
1481
  const userTurns = allTexts.filter((t) => t.role === "user").length;
1372
- if (userTurns < config.captureMinTurns) return;
1482
+ if (userTurns < config.captureMinTurns) {
1483
+ return;
1484
+ }
1373
1485
 
1374
1486
  // Parse capture hints from all messages (Issue #81)
1375
1487
  const collectedHints: PalaiaHint[] = [];
@@ -1378,17 +1490,21 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1378
1490
  collectedHints.push(...hints);
1379
1491
  }
1380
1492
 
1381
- // Build exchange text
1493
+ // Only extract from recent exchanges — full history causes LLM timeouts
1494
+ // and dilutes extraction quality
1495
+ const recentTexts = trimToRecentExchanges(allTexts);
1496
+
1497
+ // Build exchange text from recent window only
1382
1498
  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
- }
1499
+ for (const t of recentTexts) {
1500
+ const { cleanedText } = parsePalaiaHints(t.text);
1501
+ exchangeParts.push(`[${t.role}]: ${cleanedText}`);
1388
1502
  }
1389
1503
  const exchangeText = exchangeParts.join("\n");
1390
1504
 
1391
- if (!shouldAttemptCapture(exchangeText)) return;
1505
+ if (!shouldAttemptCapture(exchangeText)) {
1506
+ return;
1507
+ }
1392
1508
 
1393
1509
  const knownProjects = await loadProjects(opts);
1394
1510
 
@@ -1422,13 +1538,8 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1422
1538
  return args;
1423
1539
  };
1424
1540
 
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
-
1541
+ // Helper: store LLM extraction results
1542
+ const storeLLMResults = async (results: ExtractionResult[]) => {
1432
1543
  for (const r of results) {
1433
1544
  if (r.significance >= config.captureMinSignificance) {
1434
1545
  const hintForProject = collectedHints.find((h) => h.project);
@@ -1450,12 +1561,46 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1450
1561
  );
1451
1562
  }
1452
1563
  }
1564
+ };
1565
+
1566
+ // LLM-based extraction (primary)
1567
+ let llmHandled = false;
1568
+ try {
1569
+ const results = await extractWithLLM(event.messages, api.config, {
1570
+ captureModel: config.captureModel,
1571
+ }, knownProjects);
1453
1572
 
1573
+ await storeLLMResults(results);
1454
1574
  llmHandled = true;
1455
1575
  } catch (llmError) {
1456
- if (!_llmImportFailureLogged) {
1457
- console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1458
- _llmImportFailureLogged = true;
1576
+ // Check if this is a model-availability error (not a generic import failure)
1577
+ const errStr = String(llmError);
1578
+ const isModelError = /FailoverError|Unknown model|unknown model|401|403|model.*not found|not_found|model_not_found/i.test(errStr);
1579
+
1580
+ if (isModelError && config.captureModel) {
1581
+ // captureModel is broken — try primary model as fallback
1582
+ if (!_captureModelFailoverWarned) {
1583
+ _captureModelFailoverWarned = true;
1584
+ console.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
1585
+ }
1586
+ try {
1587
+ // Retry without captureModel → resolveCaptureModel will use primary model
1588
+ const fallbackResults = await extractWithLLM(event.messages, api.config, {
1589
+ captureModel: undefined,
1590
+ }, knownProjects);
1591
+ await storeLLMResults(fallbackResults);
1592
+ llmHandled = true;
1593
+ } catch (fallbackError) {
1594
+ if (!_llmImportFailureLogged) {
1595
+ console.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
1596
+ _llmImportFailureLogged = true;
1597
+ }
1598
+ }
1599
+ } else {
1600
+ if (!_llmImportFailureLogged) {
1601
+ console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1602
+ _llmImportFailureLogged = true;
1603
+ }
1459
1604
  }
1460
1605
  }
1461
1606
 
@@ -1465,7 +1610,9 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1465
1610
 
1466
1611
  if (config.captureFrequency === "significant") {
1467
1612
  const significance = extractSignificance(exchangeText);
1468
- if (!significance) return;
1613
+ if (!significance) {
1614
+ return;
1615
+ }
1469
1616
  captureData = significance;
1470
1617
  } else {
1471
1618
  const summary = exchangeParts
@@ -1497,6 +1644,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1497
1644
  if (sessionKey) {
1498
1645
  const turnState = getOrCreateTurnState(sessionKey);
1499
1646
  turnState.capturedInThisTurn = true;
1647
+ } else {
1500
1648
  }
1501
1649
  } catch (error) {
1502
1650
  console.warn(`[palaia] Auto-capture failed: ${error}`);
@@ -1512,9 +1660,11 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1512
1660
  || extractChannelFromSessionKey(sessionKey)
1513
1661
  || (ctx?.channelId as string | undefined);
1514
1662
  const channelId = turnState.lastInboundChannelId
1663
+ || extractChannelIdFromEvent(event, ctx)
1515
1664
  || extractSlackChannelIdFromSessionKey(sessionKey);
1516
1665
  const messageId = turnState.lastInboundMessageId;
1517
1666
 
1667
+
1518
1668
  if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
1519
1669
  // Capture confirmation: 💾
1520
1670
  if (turnState.capturedInThisTurn && config.showCaptureConfirm) {
@@ -1525,6 +1675,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1525
1675
  if (turnState.recallOccurred && config.showMemorySources) {
1526
1676
  await sendReaction(channelId, messageId, "brain", provider);
1527
1677
  }
1678
+ } else {
1528
1679
  }
1529
1680
  }
1530
1681
  } catch (reactionError) {
@@ -1549,6 +1700,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1549
1700
  const provider = turnState.channelProvider
1550
1701
  || extractChannelFromSessionKey(sessionKey);
1551
1702
  const channelId = turnState.lastInboundChannelId
1703
+ || extractChannelIdFromEvent(_event, ctx)
1552
1704
  || extractSlackChannelIdFromSessionKey(sessionKey);
1553
1705
  const messageId = turnState.lastInboundMessageId;
1554
1706