@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.
- package/package.json +1 -1
- package/src/hooks.ts +195 -43
package/package.json
CHANGED
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
|
745
|
-
|
|
746
|
-
|
|
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 (
|
|
753
|
-
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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)
|
|
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
|
-
//
|
|
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
|
|
1384
|
-
|
|
1385
|
-
|
|
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))
|
|
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
|
|
1426
|
-
|
|
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 (
|
|
1457
|
-
|
|
1458
|
-
|
|
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)
|
|
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
|
|