@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.
- package/index.ts +40 -21
- package/package.json +2 -2
- package/skill/SKILL.md +284 -706
- package/src/context-engine.ts +439 -0
- package/src/hooks/capture.ts +740 -0
- package/src/hooks/index.ts +823 -0
- package/src/hooks/reactions.ts +168 -0
- package/src/hooks/recall.ts +369 -0
- package/src/hooks/state.ts +317 -0
- package/src/priorities.ts +221 -0
- package/src/tools.ts +3 -2
- package/src/types.ts +119 -0
- package/src/hooks.ts +0 -2091
|
@@ -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
|
+
}
|