@byte5ai/palaia 2.1.0 → 2.2.1

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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Emoji reaction sending, Slack token resolution, and nudge tracking.
3
+ *
4
+ * Extracted from hooks.ts during Phase 1.5 decomposition.
5
+ * No logic changes — pure structural refactoring.
6
+ */
7
+
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+ import { REACTION_SUPPORTED_PROVIDERS } from "./state.js";
12
+
13
+ // ============================================================================
14
+ // Logger (Issue: api.logger integration)
15
+ // ============================================================================
16
+
17
+ /** Module-level logger — defaults to console, replaced via setLogger(). */
18
+ let logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void } = {
19
+ info: (...args: any[]) => console.log(...args),
20
+ warn: (...args: any[]) => console.warn(...args),
21
+ };
22
+
23
+ /** Set the module-level logger (called from hooks/index.ts). */
24
+ export function setLogger(l: { info: (...args: any[]) => void; warn: (...args: any[]) => void }): void {
25
+ logger = l;
26
+ }
27
+
28
+ // ============================================================================
29
+ // Emoji Reaction Helpers (Issue #87: Reactions)
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Send an emoji reaction to a message via the Slack Web API (or Discord API).
34
+ * Only fires for supported channels (slack, discord). Silently no-ops for others.
35
+ *
36
+ * For Slack, calls reactions.add via the @slack/web-api client.
37
+ * Requires SLACK_BOT_TOKEN in the environment.
38
+ */
39
+ export async function sendReaction(
40
+ channelId: string,
41
+ messageId: string,
42
+ emoji: string,
43
+ provider: string,
44
+ ): Promise<void> {
45
+ if (!channelId || !messageId || !emoji) return;
46
+ if (!REACTION_SUPPORTED_PROVIDERS.has(provider)) return;
47
+
48
+ if (provider === "slack") {
49
+ await sendSlackReaction(channelId, messageId, emoji);
50
+ }
51
+ // Discord: future implementation
52
+ }
53
+
54
+ /** Cached Slack bot token resolved from env or OpenClaw config. */
55
+ let _cachedSlackToken: string | null | undefined;
56
+ /** Timestamp when the token was cached (for TTL expiry). */
57
+ let _slackTokenCachedAt = 0;
58
+ /** TTL for cached Slack bot token in milliseconds (5 minutes). */
59
+ const SLACK_TOKEN_CACHE_TTL_MS = 5 * 60 * 1000;
60
+
61
+ /**
62
+ * Resolve the Slack bot token from environment or OpenClaw config file.
63
+ * Caches the result with a 5-minute TTL — re-resolves after expiry.
64
+ *
65
+ * Resolution order:
66
+ * 1. SLACK_BOT_TOKEN env var (explicit override)
67
+ * 2. OpenClaw config: channels.slack.botToken (standard single-account)
68
+ * 3. OpenClaw config: channels.slack.accounts.default.botToken (multi-account)
69
+ *
70
+ * Config path: OPENCLAW_CONFIG env var -> ~/.openclaw/openclaw.json
71
+ */
72
+ async function resolveSlackBotToken(): Promise<string | null> {
73
+ if (_cachedSlackToken !== undefined && (Date.now() - _slackTokenCachedAt) < SLACK_TOKEN_CACHE_TTL_MS) {
74
+ return _cachedSlackToken;
75
+ }
76
+
77
+ // 1) Environment variable
78
+ const envToken = process.env.SLACK_BOT_TOKEN?.trim();
79
+ if (envToken) {
80
+ _cachedSlackToken = envToken;
81
+ _slackTokenCachedAt = Date.now();
82
+ return envToken;
83
+ }
84
+
85
+ // 2) OpenClaw config file — OPENCLAW_CONFIG takes precedence over default path
86
+ const configPaths = [
87
+ process.env.OPENCLAW_CONFIG || "",
88
+ path.join(os.homedir(), ".openclaw", "openclaw.json"),
89
+ ].filter(Boolean);
90
+
91
+ for (const configPath of configPaths) {
92
+ try {
93
+ const raw = await fs.readFile(configPath, "utf-8");
94
+ const config = JSON.parse(raw);
95
+
96
+ // 2a) Standard path: channels.slack.botToken
97
+ const directToken = config?.channels?.slack?.botToken?.trim();
98
+ if (directToken) {
99
+ _cachedSlackToken = directToken;
100
+ _slackTokenCachedAt = Date.now();
101
+ return directToken;
102
+ }
103
+
104
+ // 2b) Multi-account path: channels.slack.accounts.default.botToken
105
+ const accountToken = config?.channels?.slack?.accounts?.default?.botToken?.trim();
106
+ if (accountToken) {
107
+ _cachedSlackToken = accountToken;
108
+ _slackTokenCachedAt = Date.now();
109
+ return accountToken;
110
+ }
111
+ } catch {
112
+ // Try next path
113
+ }
114
+ }
115
+
116
+ _cachedSlackToken = null;
117
+ _slackTokenCachedAt = Date.now();
118
+ return null;
119
+ }
120
+
121
+ /** Reset cached token (for testing). */
122
+ export function resetSlackTokenCache(): void {
123
+ _cachedSlackToken = undefined;
124
+ _slackTokenCachedAt = 0;
125
+ }
126
+
127
+ async function sendSlackReaction(
128
+ channelId: string,
129
+ messageId: string,
130
+ emoji: string,
131
+ ): Promise<void> {
132
+ const token = await resolveSlackBotToken();
133
+ if (!token) {
134
+ logger.warn("[palaia] Cannot send Slack reaction: no bot token found");
135
+ return;
136
+ }
137
+
138
+ const normalizedEmoji = emoji.replace(/^:/, "").replace(/:$/, "");
139
+
140
+ const controller = new AbortController();
141
+ const timeout = setTimeout(() => controller.abort(), 5000);
142
+
143
+ try {
144
+ const response = await fetch("https://slack.com/api/reactions.add", {
145
+ method: "POST",
146
+ headers: {
147
+ "Content-Type": "application/json; charset=utf-8",
148
+ Authorization: `Bearer ${token}`,
149
+ },
150
+ body: JSON.stringify({
151
+ channel: channelId,
152
+ timestamp: messageId,
153
+ name: normalizedEmoji,
154
+ }),
155
+ signal: controller.signal,
156
+ });
157
+ const data = await response.json() as { ok: boolean; error?: string };
158
+ if (!data.ok && data.error !== "already_reacted") {
159
+ logger.warn(`[palaia] Slack reaction failed: ${data.error} (${normalizedEmoji} on ${channelId})`);
160
+ }
161
+ } catch (err) {
162
+ if ((err as Error).name !== "AbortError") {
163
+ logger.warn(`[palaia] Slack reaction error (${normalizedEmoji}): ${err}`);
164
+ }
165
+ } finally {
166
+ clearTimeout(timeout);
167
+ }
168
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Query-based recall logic for the before_prompt_build hook.
3
+ *
4
+ * Extracted from hooks.ts during Phase 1.5 decomposition.
5
+ * No logic changes — pure structural refactoring.
6
+ */
7
+
8
+ import type { RecallTypeWeights } from "../config.js";
9
+ import { stripPalaiaInjectedContext } from "./capture.js";
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ /** Shape returned by `palaia query --json` or `palaia list --json` */
16
+ export interface QueryResult {
17
+ results: Array<{
18
+ id: string;
19
+ body?: string;
20
+ content?: string;
21
+ score: number;
22
+ bm25_score?: number;
23
+ embed_score?: number;
24
+ tier: string;
25
+ scope: string;
26
+ title?: string;
27
+ type?: string;
28
+ tags?: string[];
29
+ }>;
30
+ }
31
+
32
+ /** Message shape from OpenClaw event.messages */
33
+ export interface Message {
34
+ role?: string;
35
+ content?: string | Array<{ type?: string; text?: string }>;
36
+ }
37
+
38
+ // ============================================================================
39
+ // Footnote Helpers (Issue #87)
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Check if an injected entry is relevant to the response text.
44
+ * Simple keyword overlap: split title into words, check if >=2 words appear
45
+ * in the response (case-insensitive). Words shorter than 3 chars are skipped.
46
+ */
47
+ export function isEntryRelevant(title: string, responseText: string): boolean {
48
+ const responseLower = responseText.toLowerCase();
49
+ const titleWords = title
50
+ .toLowerCase()
51
+ .split(/[\s\-_/]+/)
52
+ .filter((w) => w.length >= 3);
53
+ if (titleWords.length === 0) return false;
54
+ const threshold = titleWords.length === 1 ? 1 : 2;
55
+ let matches = 0;
56
+ for (const word of titleWords) {
57
+ if (responseLower.includes(word)) {
58
+ matches++;
59
+ if (matches >= threshold) return true;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ /**
66
+ * Build a memory source footnote string from injected entries and response text.
67
+ * Returns null if no relevant entries found.
68
+ */
69
+ export function buildFootnote(
70
+ entries: Array<{ title: string; date: string }>,
71
+ _responseText: string,
72
+ maxEntries = 3,
73
+ ): string | null {
74
+ if (entries.length === 0) return null;
75
+
76
+ const display = entries.slice(0, maxEntries);
77
+ const parts = display.map((e) => {
78
+ const dateStr = formatShortDate(e.date);
79
+ return dateStr ? `"${e.title}" (${dateStr})` : `"${e.title}"`;
80
+ });
81
+ return `\n\n\u{1f4ce} Palaia: ${parts.join(", ")}`;
82
+ }
83
+
84
+ // Re-export formatShortDate from state for use here
85
+ import { formatShortDate } from "./state.js";
86
+
87
+ // ============================================================================
88
+ // Satisfaction / Transparency Nudge Helpers (Issue #87)
89
+ // ============================================================================
90
+
91
+ import type { PluginState } from "./state.js";
92
+
93
+ const SATISFACTION_THRESHOLD = 10;
94
+ const TRANSPARENCY_RECALL_THRESHOLD = 50;
95
+ const TRANSPARENCY_DAYS_THRESHOLD = 7;
96
+
97
+ const SATISFACTION_NUDGE_TEXT =
98
+ "Your user has been using Palaia for a while now. " +
99
+ "Ask them casually if they're happy with the memory system. " +
100
+ "If there are issues, suggest `palaia doctor`.";
101
+
102
+ const TRANSPARENCY_NUDGE_TEXT =
103
+ "Your user has been seeing memory Footnotes and capture confirmations for several days. " +
104
+ "Ask them once: 'Would you like to keep seeing memory source references and capture " +
105
+ "confirmations, or should I hide them? You can change this anytime.' " +
106
+ "Based on their answer: `palaia config set showMemorySources true/false` and " +
107
+ "`palaia config set showCaptureConfirm true/false`";
108
+
109
+ /**
110
+ * Check which nudges (if any) should fire based on plugin state.
111
+ * Returns nudge texts to prepend, and updates state accordingly.
112
+ */
113
+ export function checkNudges(state: PluginState): { nudges: string[]; updated: boolean } {
114
+ const nudges: string[] = [];
115
+ let updated = false;
116
+
117
+ if (!state.satisfactionNudged && state.successfulRecalls >= SATISFACTION_THRESHOLD) {
118
+ nudges.push(SATISFACTION_NUDGE_TEXT);
119
+ state.satisfactionNudged = true;
120
+ updated = true;
121
+ }
122
+
123
+ if (!state.transparencyNudged && state.firstRecallTimestamp) {
124
+ const daysSinceFirst = (Date.now() - new Date(state.firstRecallTimestamp).getTime()) / (1000 * 60 * 60 * 24);
125
+ if (state.successfulRecalls >= TRANSPARENCY_RECALL_THRESHOLD || daysSinceFirst >= TRANSPARENCY_DAYS_THRESHOLD) {
126
+ nudges.push(TRANSPARENCY_NUDGE_TEXT);
127
+ state.transparencyNudged = true;
128
+ updated = true;
129
+ }
130
+ }
131
+
132
+ return { nudges, updated };
133
+ }
134
+
135
+ // ============================================================================
136
+ // Message Text Extraction
137
+ // ============================================================================
138
+
139
+ export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string; provenance?: string }> {
140
+ const result: Array<{ role: string; text: string; provenance?: string }> = [];
141
+
142
+ for (const msg of messages) {
143
+ if (!msg || typeof msg !== "object") continue;
144
+ const m = msg as Message;
145
+ const role = m.role;
146
+ if (!role || typeof role !== "string") continue;
147
+
148
+ // Extract provenance kind (string or object with .kind)
149
+ const rawProvenance = (m as any).provenance?.kind ?? (m as any).provenance;
150
+ const provenance = typeof rawProvenance === "string" ? rawProvenance : undefined;
151
+
152
+ if (typeof m.content === "string" && m.content.trim()) {
153
+ result.push({ role, text: m.content.trim(), provenance });
154
+ continue;
155
+ }
156
+
157
+ if (Array.isArray(m.content)) {
158
+ for (const block of m.content) {
159
+ if (
160
+ block &&
161
+ typeof block === "object" &&
162
+ block.type === "text" &&
163
+ typeof block.text === "string" &&
164
+ block.text.trim()
165
+ ) {
166
+ result.push({ role, text: block.text.trim(), provenance });
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ return result;
173
+ }
174
+
175
+ export function getLastUserMessage(messages: unknown[]): string | null {
176
+ const texts = extractMessageTexts(messages);
177
+ // Prefer external_user provenance (real human input)
178
+ for (let i = texts.length - 1; i >= 0; i--) {
179
+ if (texts[i].role === "user" && texts[i].provenance === "external_user")
180
+ return texts[i].text;
181
+ }
182
+ // Fallback: any user message (backward compat for OpenClaw without provenance)
183
+ for (let i = texts.length - 1; i >= 0; i--) {
184
+ if (texts[i].role === "user") return texts[i].text;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ // ============================================================================
190
+ // Channel Envelope Stripping (v2.0.6)
191
+ // ============================================================================
192
+
193
+ /**
194
+ * Strip OpenClaw channel envelope from message text.
195
+ * Matches the pattern: [TIMESTAMP] or [CHANNEL TIMESTAMP] prefix
196
+ * that OpenClaw adds to inbound messages from all channels.
197
+ * Based on OpenClaw's internal stripEnvelope() logic.
198
+ */
199
+ const ENVELOPE_PREFIX_RE = /^\[([^\]]+)\]\s*/;
200
+ const ENVELOPE_CHANNELS = [
201
+ "WebChat", "WhatsApp", "Telegram", "Signal", "Slack",
202
+ "Discord", "Google Chat", "iMessage", "Teams", "Matrix",
203
+ "Zalo", "Zalo Personal", "BlueBubbles",
204
+ ];
205
+
206
+ function looksLikeEnvelopeHeader(header: string): boolean {
207
+ // ISO timestamp pattern
208
+ if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
209
+ // Space-separated timestamp
210
+ if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
211
+ // Channel prefix
212
+ return ENVELOPE_CHANNELS.some(ch => header.startsWith(`${ch} `));
213
+ }
214
+
215
+ export function stripChannelEnvelope(text: string): string {
216
+ const match = text.match(ENVELOPE_PREFIX_RE);
217
+ if (!match) return text;
218
+ if (!looksLikeEnvelopeHeader(match[1] ?? "")) return text;
219
+ return text.slice(match[0].length);
220
+ }
221
+
222
+ /**
223
+ * Strip "System: [timestamp] Channel message in #channel from User: " prefix.
224
+ * OpenClaw wraps inbound messages with this pattern for all channel providers.
225
+ */
226
+ const SYSTEM_PREFIX_RE = /^System:\s*\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*(?:Slack message|Telegram message|Discord message|WhatsApp message|Signal message|message).*?(?:from \w+:\s*)?/i;
227
+
228
+ export function stripSystemPrefix(text: string): string {
229
+ const match = text.match(SYSTEM_PREFIX_RE);
230
+ if (match) return text.slice(match[0].length);
231
+ return text;
232
+ }
233
+
234
+ // ============================================================================
235
+ // Recall Query Builder (v2.0.6: envelope-aware, provenance-based)
236
+ // ============================================================================
237
+
238
+ /**
239
+ * Messages that are purely system content (no user text).
240
+ * Used to skip edited notifications, sync events, inter-session messages, etc.
241
+ */
242
+ function isSystemOnlyContent(text: string): boolean {
243
+ if (!text) return true;
244
+ if (text.startsWith("System:")) return true;
245
+ if (text.startsWith("[Queued")) return true;
246
+ if (text.startsWith("[Inter-session")) return true;
247
+ if (/^Slack message (edited|deleted)/.test(text)) return true;
248
+ if (/^\[auto\]/.test(text)) return true;
249
+ if (text.length < 3) return true;
250
+ return false;
251
+ }
252
+
253
+ /**
254
+ * Build a recall query from message history.
255
+ *
256
+ * v2.0.6: Strips OpenClaw channel envelopes (System: [...] Slack message from ...:)
257
+ * and inter-session prefixes before building the query. This prevents envelope
258
+ * metadata from polluting semantic search and causing timeouts / false-high scores.
259
+ *
260
+ * - Filters out inter_session and internal_system provenance messages.
261
+ * - Falls back to any user message for backward compat (OpenClaw without provenance).
262
+ * - Strips channel envelopes and system prefixes from message text.
263
+ * - Skips system-only content (edited notifications, sync events).
264
+ * - Short messages (< 30 chars): prepends previous for context.
265
+ * - Hard-caps at 500 characters.
266
+ */
267
+ export function buildRecallQuery(messages: unknown[]): string {
268
+ const texts = extractMessageTexts(messages).map(t =>
269
+ t.role === "user"
270
+ ? { ...t, text: stripPalaiaInjectedContext(t.text) }
271
+ : t
272
+ );
273
+
274
+ // Step 1: Filter out inter_session messages (sub-agent results, sessions_send)
275
+ const candidates = texts.filter(
276
+ t => t.role === "user" && t.provenance !== "inter_session" && t.provenance !== "internal_system"
277
+ );
278
+
279
+ // Fallback: if no messages without provenance, use all user messages
280
+ const allUserMsgs = candidates.length > 0
281
+ ? candidates
282
+ : texts.filter(t => t.role === "user");
283
+
284
+ if (allUserMsgs.length === 0) return "";
285
+
286
+ // Early exit: only scan the last 3 user messages or 2000 chars, whichever comes first
287
+ const MAX_SCAN_MSGS = 3;
288
+ const MAX_SCAN_CHARS = 2000;
289
+ let userMsgs: typeof allUserMsgs;
290
+ if (allUserMsgs.length <= MAX_SCAN_MSGS) {
291
+ userMsgs = allUserMsgs;
292
+ } else {
293
+ userMsgs = allUserMsgs.slice(-MAX_SCAN_MSGS);
294
+ // Extend backwards if total chars < MAX_SCAN_CHARS and more messages available
295
+ let totalChars = userMsgs.reduce((sum, m) => sum + m.text.length, 0);
296
+ let startIdx = allUserMsgs.length - MAX_SCAN_MSGS;
297
+ while (startIdx > 0 && totalChars < MAX_SCAN_CHARS) {
298
+ startIdx--;
299
+ totalChars += allUserMsgs[startIdx].text.length;
300
+ userMsgs = allUserMsgs.slice(startIdx);
301
+ }
302
+ }
303
+
304
+ // Step 2: Strip envelopes from the last user message(s)
305
+ let lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[userMsgs.length - 1].text.trim()));
306
+
307
+ // Skip system-only messages (edited notifications, sync events, etc.)
308
+ // Walk backwards to find a message with actual content
309
+ let idx = userMsgs.length - 1;
310
+ while (idx >= 0 && (!lastText || isSystemOnlyContent(lastText))) {
311
+ idx--;
312
+ if (idx >= 0) {
313
+ lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[idx].text.trim()));
314
+ }
315
+ }
316
+
317
+ if (!lastText) return "";
318
+
319
+ // Step 3: Short messages -> include previous for context
320
+ if (lastText.length < 30 && idx > 0) {
321
+ const prevText = stripSystemPrefix(stripChannelEnvelope(userMsgs[idx - 1].text.trim()));
322
+ if (prevText && !isSystemOnlyContent(prevText)) {
323
+ return `${prevText} ${lastText}`.slice(0, 500);
324
+ }
325
+ }
326
+
327
+ return lastText.slice(0, 500);
328
+ }
329
+
330
+ // ============================================================================
331
+ // Query-based Recall: Type-weighted reranking (Issue #65)
332
+ // ============================================================================
333
+
334
+ export interface RankedEntry {
335
+ id: string;
336
+ body: string;
337
+ title: string;
338
+ scope: string;
339
+ tier: string;
340
+ type: string;
341
+ score: number;
342
+ bm25Score?: number;
343
+ embedScore?: number;
344
+ weightedScore: number;
345
+ }
346
+
347
+ export function rerankByTypeWeight(
348
+ results: QueryResult["results"],
349
+ weights: RecallTypeWeights,
350
+ ): RankedEntry[] {
351
+ return results
352
+ .map((r) => {
353
+ const type = r.type || "memory";
354
+ const weight = weights[type] ?? 1.0;
355
+ return {
356
+ id: r.id,
357
+ body: r.content || r.body || "",
358
+ title: r.title || "(untitled)",
359
+ scope: r.scope,
360
+ tier: r.tier,
361
+ type,
362
+ score: r.score,
363
+ bm25Score: r.bm25_score,
364
+ embedScore: r.embed_score,
365
+ weightedScore: r.score * weight,
366
+ };
367
+ })
368
+ .sort((a, b) => b.weightedScore - a.weightedScore);
369
+ }