@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.
- package/package.json +1 -1
- package/src/hooks.ts +201 -44
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
|
|
@@ -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
|
|
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 =
|
|
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)
|
|
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
|
-
//
|
|
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
|
|
1384
|
-
|
|
1385
|
-
|
|
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))
|
|
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
|
|
1426
|
-
|
|
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 (
|
|
1457
|
-
|
|
1458
|
-
|
|
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)
|
|
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
|
|