@byte5ai/palaia 2.1.0 → 2.2.0
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
package/src/hooks.ts
DELETED
|
@@ -1,2091 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lifecycle hooks for the Palaia OpenClaw plugin.
|
|
3
|
-
*
|
|
4
|
-
* - before_prompt_build: Query-based contextual recall (Issue #65).
|
|
5
|
-
* Returns appendSystemContext with 🧠 instruction when memory is used.
|
|
6
|
-
* - agent_end: Auto-capture of significant exchanges (Issue #64).
|
|
7
|
-
* Now with LLM-based extraction via OpenClaw's runEmbeddedPiAgent,
|
|
8
|
-
* falling back to rule-based extraction if the LLM is unavailable.
|
|
9
|
-
* - message_received: Captures inbound message ID for emoji reactions.
|
|
10
|
-
* - palaia-recovery service: Replays WAL on startup.
|
|
11
|
-
* - /palaia command: Show memory status.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import fs from "node:fs/promises";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
import os from "node:os";
|
|
17
|
-
import { run, runJson, recover, type RunnerOpts, getEmbedServerManager } from "./runner.js";
|
|
18
|
-
import type { PalaiaPluginConfig, RecallTypeWeights } from "./config.js";
|
|
19
|
-
|
|
20
|
-
// ============================================================================
|
|
21
|
-
// Plugin State Persistence (Issue #87: Recall counter for nudges)
|
|
22
|
-
// ============================================================================
|
|
23
|
-
|
|
24
|
-
interface PluginState {
|
|
25
|
-
successfulRecalls: number;
|
|
26
|
-
satisfactionNudged: boolean;
|
|
27
|
-
transparencyNudged: boolean;
|
|
28
|
-
firstRecallTimestamp: string | null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const DEFAULT_PLUGIN_STATE: PluginState = {
|
|
32
|
-
successfulRecalls: 0,
|
|
33
|
-
satisfactionNudged: false,
|
|
34
|
-
transparencyNudged: false,
|
|
35
|
-
firstRecallTimestamp: null,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Load plugin state from disk.
|
|
40
|
-
*
|
|
41
|
-
* Note: No file locking is applied here. The plugin-state.json file stores
|
|
42
|
-
* non-critical counters (recall count, nudge flags). In the worst case of a
|
|
43
|
-
* race condition between multiple agents, a nudge fires one recall too early
|
|
44
|
-
* or too late. This is acceptable given the low-stakes nature of the data
|
|
45
|
-
* and the complexity cost of adding advisory locks in Node.js.
|
|
46
|
-
*/
|
|
47
|
-
async function loadPluginState(workspace?: string): Promise<PluginState> {
|
|
48
|
-
const dir = workspace || process.cwd();
|
|
49
|
-
const statePath = path.join(dir, ".palaia", "plugin-state.json");
|
|
50
|
-
try {
|
|
51
|
-
const raw = await fs.readFile(statePath, "utf-8");
|
|
52
|
-
return { ...DEFAULT_PLUGIN_STATE, ...JSON.parse(raw) };
|
|
53
|
-
} catch {
|
|
54
|
-
return { ...DEFAULT_PLUGIN_STATE };
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function savePluginState(state: PluginState, workspace?: string): Promise<void> {
|
|
59
|
-
const dir = workspace || process.cwd();
|
|
60
|
-
const statePath = path.join(dir, ".palaia", "plugin-state.json");
|
|
61
|
-
try {
|
|
62
|
-
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
|
|
63
|
-
} catch {
|
|
64
|
-
// Non-fatal
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ============================================================================
|
|
69
|
-
// Session-isolated Turn State (Issue #87: Emoji Reactions)
|
|
70
|
-
// ============================================================================
|
|
71
|
-
|
|
72
|
-
/** Per-session turn state for tracking recall/capture across hooks. */
|
|
73
|
-
interface TurnState {
|
|
74
|
-
recallOccurred: boolean;
|
|
75
|
-
lastInboundMessageId: string | null;
|
|
76
|
-
lastInboundChannelId: string | null;
|
|
77
|
-
channelProvider: string | null;
|
|
78
|
-
capturedInThisTurn: boolean;
|
|
79
|
-
/** Timestamp when this entry was created (for TTL-based pruning). */
|
|
80
|
-
createdAt: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function createDefaultTurnState(): TurnState {
|
|
84
|
-
return {
|
|
85
|
-
recallOccurred: false,
|
|
86
|
-
lastInboundMessageId: null,
|
|
87
|
-
lastInboundChannelId: null,
|
|
88
|
-
channelProvider: null,
|
|
89
|
-
capturedInThisTurn: false,
|
|
90
|
-
createdAt: Date.now(),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Maximum age for turn state entries before they are pruned (5 minutes). */
|
|
95
|
-
const TURN_STATE_TTL_MS = 5 * 60 * 1000;
|
|
96
|
-
/** Maximum age for inbound message entries before they are pruned (5 minutes). */
|
|
97
|
-
const INBOUND_MESSAGE_TTL_MS = 5 * 60 * 1000;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Remove stale entries from turnStateBySession and lastInboundMessageByChannel.
|
|
101
|
-
* Called at the start of before_prompt_build to prevent memory leaks from
|
|
102
|
-
* sessions that were killed/crashed without firing agent_end.
|
|
103
|
-
*/
|
|
104
|
-
export function pruneStaleEntries(): void {
|
|
105
|
-
const now = Date.now();
|
|
106
|
-
for (const [key, state] of turnStateBySession) {
|
|
107
|
-
if (now - state.createdAt > TURN_STATE_TTL_MS) {
|
|
108
|
-
turnStateBySession.delete(key);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
for (const [key, entry] of lastInboundMessageByChannel) {
|
|
112
|
-
if (now - entry.timestamp > INBOUND_MESSAGE_TTL_MS) {
|
|
113
|
-
lastInboundMessageByChannel.delete(key);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Session-isolated turn state map. Keyed by sessionKey.
|
|
120
|
-
* Set in before_prompt_build / message_received, consumed + deleted in agent_end.
|
|
121
|
-
* NEVER use global variables for turn data — race condition with multi-agent.
|
|
122
|
-
*/
|
|
123
|
-
const turnStateBySession = new Map<string, TurnState>();
|
|
124
|
-
|
|
125
|
-
// ============================================================================
|
|
126
|
-
// Inbound Message ID Store (for emoji reactions)
|
|
127
|
-
// ============================================================================
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Stores the most recent inbound message ID per channel.
|
|
131
|
-
* Keyed by channelId (e.g. "C0AKE2G15HV"), value is the message ts.
|
|
132
|
-
* Written by message_received, consumed by agent_end.
|
|
133
|
-
* Entries are short-lived and cleaned up after agent_end.
|
|
134
|
-
*/
|
|
135
|
-
const lastInboundMessageByChannel = new Map<string, { messageId: string; provider: string; timestamp: number }>();
|
|
136
|
-
|
|
137
|
-
/** Channels that support emoji reactions. */
|
|
138
|
-
const REACTION_SUPPORTED_PROVIDERS = new Set(["slack", "discord"]);
|
|
139
|
-
|
|
140
|
-
// ============================================================================
|
|
141
|
-
// Logger (Issue: api.logger integration)
|
|
142
|
-
// ============================================================================
|
|
143
|
-
|
|
144
|
-
/** Module-level logger — defaults to console, replaced by api.logger in registerHooks. */
|
|
145
|
-
let logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void } = {
|
|
146
|
-
info: (...args: any[]) => console.log(...args),
|
|
147
|
-
warn: (...args: any[]) => console.warn(...args),
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
// ============================================================================
|
|
151
|
-
// Scope Validation (Issue #90)
|
|
152
|
-
// ============================================================================
|
|
153
|
-
|
|
154
|
-
const VALID_SCOPES = ["private", "team", "public"];
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Check if a scope string is valid for palaia write.
|
|
158
|
-
* Valid: "private", "team", "public", or any "shared:*" prefix.
|
|
159
|
-
*/
|
|
160
|
-
export function isValidScope(s: string): boolean {
|
|
161
|
-
return VALID_SCOPES.includes(s) || s.startsWith("shared:");
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Sanitize a scope value — returns the value if valid, otherwise fallback.
|
|
166
|
-
* Enforces: LLM may suggest private or team, but NEVER public (unless explicitly configured).
|
|
167
|
-
*/
|
|
168
|
-
export function sanitizeScope(rawScope: string | null | undefined, fallback = "team", allowPublic = false): string {
|
|
169
|
-
if (!rawScope || !isValidScope(rawScope)) return fallback;
|
|
170
|
-
// Block public scope unless explicitly allowed (config-level override)
|
|
171
|
-
if (rawScope === "public" && !allowPublic) return fallback;
|
|
172
|
-
return rawScope;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ============================================================================
|
|
176
|
-
// Session Key Helpers
|
|
177
|
-
// ============================================================================
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Extract channel target from a session key.
|
|
181
|
-
* e.g. "agent:main:slack:channel:c0ake2g15hv" → "channel:C0AKE2G15HV"
|
|
182
|
-
*/
|
|
183
|
-
export function extractTargetFromSessionKey(sessionKey: string): string | undefined {
|
|
184
|
-
const parts = sessionKey.split(":");
|
|
185
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
186
|
-
if (parts[i] === "channel" || parts[i] === "dm" || parts[i] === "group") {
|
|
187
|
-
return `${parts[i]}:${parts[i + 1].toUpperCase()}`;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Extract channel provider from a session key.
|
|
195
|
-
* e.g. "agent:main:slack:channel:c0ake2g15hv" → "slack"
|
|
196
|
-
*/
|
|
197
|
-
export function extractChannelFromSessionKey(sessionKey: string): string | undefined {
|
|
198
|
-
const parts = sessionKey.split(":");
|
|
199
|
-
if (parts.length >= 5 && parts[0] === "agent") {
|
|
200
|
-
return parts[2];
|
|
201
|
-
}
|
|
202
|
-
return undefined;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// ============================================================================
|
|
206
|
-
// Emoji Reaction Helpers (Issue #87: Reactions)
|
|
207
|
-
// ============================================================================
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Extract the Slack channel ID from a session key.
|
|
211
|
-
* e.g. "agent:main:slack:channel:c0ake2g15hv" → "C0AKE2G15HV"
|
|
212
|
-
*/
|
|
213
|
-
export function extractSlackChannelIdFromSessionKey(sessionKey: string): string | undefined {
|
|
214
|
-
const parts = sessionKey.split(":");
|
|
215
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
216
|
-
if (parts[i] === "channel" || parts[i] === "dm") {
|
|
217
|
-
return parts[i + 1].toUpperCase();
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return undefined;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Extract the real Slack channel ID from event metadata or ctx.
|
|
225
|
-
* OpenClaw stores the channel in "channel:C0AKE2G15HV" format in:
|
|
226
|
-
* - event.metadata.to
|
|
227
|
-
* - event.metadata.originatingTo
|
|
228
|
-
* - ctx.conversationId
|
|
229
|
-
*
|
|
230
|
-
* ctx.channelId is the PROVIDER NAME ("slack"), not the channel ID.
|
|
231
|
-
* ctx.sessionKey is null during message_received.
|
|
232
|
-
*/
|
|
233
|
-
export function extractChannelIdFromEvent(event: any, ctx: any): string | undefined {
|
|
234
|
-
const rawTo = event?.metadata?.to
|
|
235
|
-
?? event?.metadata?.originatingTo
|
|
236
|
-
?? ctx?.conversationId
|
|
237
|
-
?? "";
|
|
238
|
-
const match = String(rawTo).match(/^(?:channel|dm|group):([A-Z0-9]+)$/i);
|
|
239
|
-
return match ? match[1].toUpperCase() : undefined;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Resolve the session key for the current turn from available ctx.
|
|
244
|
-
* Tries ctx.sessionKey first, then falls back to sessionId.
|
|
245
|
-
*/
|
|
246
|
-
function resolveSessionKeyFromCtx(ctx: any): string | undefined {
|
|
247
|
-
const sk = ctx?.sessionKey?.trim?.();
|
|
248
|
-
if (sk) return sk;
|
|
249
|
-
const sid = ctx?.sessionId?.trim?.();
|
|
250
|
-
return sid || undefined;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Get or create turn state for a session.
|
|
255
|
-
*/
|
|
256
|
-
export function getOrCreateTurnState(sessionKey: string): TurnState {
|
|
257
|
-
let state = turnStateBySession.get(sessionKey);
|
|
258
|
-
if (!state) {
|
|
259
|
-
state = createDefaultTurnState();
|
|
260
|
-
turnStateBySession.set(sessionKey, state);
|
|
261
|
-
}
|
|
262
|
-
return state;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Delete turn state for a session (cleanup after agent_end).
|
|
267
|
-
*/
|
|
268
|
-
export function deleteTurnState(sessionKey: string): void {
|
|
269
|
-
turnStateBySession.delete(sessionKey);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Send an emoji reaction to a message via the Slack Web API (or Discord API).
|
|
274
|
-
* Only fires for supported channels (slack, discord). Silently no-ops for others.
|
|
275
|
-
*
|
|
276
|
-
* For Slack, calls reactions.add via the @slack/web-api client.
|
|
277
|
-
* Requires SLACK_BOT_TOKEN in the environment.
|
|
278
|
-
*/
|
|
279
|
-
export async function sendReaction(
|
|
280
|
-
channelId: string,
|
|
281
|
-
messageId: string,
|
|
282
|
-
emoji: string,
|
|
283
|
-
provider: string,
|
|
284
|
-
): Promise<void> {
|
|
285
|
-
if (!channelId || !messageId || !emoji) return;
|
|
286
|
-
if (!REACTION_SUPPORTED_PROVIDERS.has(provider)) return;
|
|
287
|
-
|
|
288
|
-
if (provider === "slack") {
|
|
289
|
-
await sendSlackReaction(channelId, messageId, emoji);
|
|
290
|
-
}
|
|
291
|
-
// Discord: future implementation
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** Cached Slack bot token resolved from env or OpenClaw config. */
|
|
295
|
-
let _cachedSlackToken: string | null | undefined;
|
|
296
|
-
/** Timestamp when the token was cached (for TTL expiry). */
|
|
297
|
-
let _slackTokenCachedAt = 0;
|
|
298
|
-
/** TTL for cached Slack bot token in milliseconds (5 minutes). */
|
|
299
|
-
const SLACK_TOKEN_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Resolve the Slack bot token from environment or OpenClaw config file.
|
|
303
|
-
* Caches the result with a 5-minute TTL — re-resolves after expiry.
|
|
304
|
-
*
|
|
305
|
-
* Resolution order:
|
|
306
|
-
* 1. SLACK_BOT_TOKEN env var (explicit override)
|
|
307
|
-
* 2. OpenClaw config: channels.slack.botToken (standard single-account)
|
|
308
|
-
* 3. OpenClaw config: channels.slack.accounts.default.botToken (multi-account)
|
|
309
|
-
*
|
|
310
|
-
* Config path: OPENCLAW_CONFIG env var → ~/.openclaw/openclaw.json
|
|
311
|
-
*/
|
|
312
|
-
async function resolveSlackBotToken(): Promise<string | null> {
|
|
313
|
-
if (_cachedSlackToken !== undefined && (Date.now() - _slackTokenCachedAt) < SLACK_TOKEN_CACHE_TTL_MS) {
|
|
314
|
-
return _cachedSlackToken;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// 1) Environment variable
|
|
318
|
-
const envToken = process.env.SLACK_BOT_TOKEN?.trim();
|
|
319
|
-
if (envToken) {
|
|
320
|
-
_cachedSlackToken = envToken;
|
|
321
|
-
_slackTokenCachedAt = Date.now();
|
|
322
|
-
return envToken;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// 2) OpenClaw config file — OPENCLAW_CONFIG takes precedence over default path
|
|
326
|
-
const configPaths = [
|
|
327
|
-
process.env.OPENCLAW_CONFIG || "",
|
|
328
|
-
path.join(os.homedir(), ".openclaw", "openclaw.json"),
|
|
329
|
-
].filter(Boolean);
|
|
330
|
-
|
|
331
|
-
for (const configPath of configPaths) {
|
|
332
|
-
try {
|
|
333
|
-
const raw = await fs.readFile(configPath, "utf-8");
|
|
334
|
-
const config = JSON.parse(raw);
|
|
335
|
-
|
|
336
|
-
// 2a) Standard path: channels.slack.botToken
|
|
337
|
-
const directToken = config?.channels?.slack?.botToken?.trim();
|
|
338
|
-
if (directToken) {
|
|
339
|
-
_cachedSlackToken = directToken;
|
|
340
|
-
_slackTokenCachedAt = Date.now();
|
|
341
|
-
return directToken;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// 2b) Multi-account path: channels.slack.accounts.default.botToken
|
|
345
|
-
const accountToken = config?.channels?.slack?.accounts?.default?.botToken?.trim();
|
|
346
|
-
if (accountToken) {
|
|
347
|
-
_cachedSlackToken = accountToken;
|
|
348
|
-
_slackTokenCachedAt = Date.now();
|
|
349
|
-
return accountToken;
|
|
350
|
-
}
|
|
351
|
-
} catch {
|
|
352
|
-
// Try next path
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
_cachedSlackToken = null;
|
|
357
|
-
_slackTokenCachedAt = Date.now();
|
|
358
|
-
return null;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Reset cached token (for testing). */
|
|
362
|
-
export function resetSlackTokenCache(): void {
|
|
363
|
-
_cachedSlackToken = undefined;
|
|
364
|
-
_slackTokenCachedAt = 0;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function sendSlackReaction(
|
|
368
|
-
channelId: string,
|
|
369
|
-
messageId: string,
|
|
370
|
-
emoji: string,
|
|
371
|
-
): Promise<void> {
|
|
372
|
-
const token = await resolveSlackBotToken();
|
|
373
|
-
if (!token) {
|
|
374
|
-
logger.warn("[palaia] Cannot send Slack reaction: no bot token found");
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const normalizedEmoji = emoji.replace(/^:/, "").replace(/:$/, "");
|
|
379
|
-
|
|
380
|
-
const controller = new AbortController();
|
|
381
|
-
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
const response = await fetch("https://slack.com/api/reactions.add", {
|
|
385
|
-
method: "POST",
|
|
386
|
-
headers: {
|
|
387
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
388
|
-
Authorization: `Bearer ${token}`,
|
|
389
|
-
},
|
|
390
|
-
body: JSON.stringify({
|
|
391
|
-
channel: channelId,
|
|
392
|
-
timestamp: messageId,
|
|
393
|
-
name: normalizedEmoji,
|
|
394
|
-
}),
|
|
395
|
-
signal: controller.signal,
|
|
396
|
-
});
|
|
397
|
-
const data = await response.json() as { ok: boolean; error?: string };
|
|
398
|
-
if (!data.ok && data.error !== "already_reacted") {
|
|
399
|
-
logger.warn(`[palaia] Slack reaction failed: ${data.error} (${normalizedEmoji} on ${channelId})`);
|
|
400
|
-
}
|
|
401
|
-
} catch (err) {
|
|
402
|
-
if ((err as Error).name !== "AbortError") {
|
|
403
|
-
logger.warn(`[palaia] Slack reaction error (${normalizedEmoji}): ${err}`);
|
|
404
|
-
}
|
|
405
|
-
} finally {
|
|
406
|
-
clearTimeout(timeout);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// ============================================================================
|
|
411
|
-
// Footnote Helpers (Issue #87)
|
|
412
|
-
// ============================================================================
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Format an ISO date string as a short date: "Mar 16", "Feb 10".
|
|
416
|
-
*/
|
|
417
|
-
export function formatShortDate(isoDate: string): string {
|
|
418
|
-
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
419
|
-
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
420
|
-
try {
|
|
421
|
-
const d = new Date(isoDate);
|
|
422
|
-
if (isNaN(d.getTime())) return "";
|
|
423
|
-
return `${months[d.getMonth()]} ${d.getDate()}`;
|
|
424
|
-
} catch {
|
|
425
|
-
return "";
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Check if an injected entry is relevant to the response text.
|
|
431
|
-
* Simple keyword overlap: split title into words, check if >=2 words appear
|
|
432
|
-
* in the response (case-insensitive). Words shorter than 3 chars are skipped.
|
|
433
|
-
*/
|
|
434
|
-
export function isEntryRelevant(title: string, responseText: string): boolean {
|
|
435
|
-
const responseLower = responseText.toLowerCase();
|
|
436
|
-
const titleWords = title
|
|
437
|
-
.toLowerCase()
|
|
438
|
-
.split(/[\s\-_/]+/)
|
|
439
|
-
.filter((w) => w.length >= 3);
|
|
440
|
-
if (titleWords.length === 0) return false;
|
|
441
|
-
const threshold = titleWords.length === 1 ? 1 : 2;
|
|
442
|
-
let matches = 0;
|
|
443
|
-
for (const word of titleWords) {
|
|
444
|
-
if (responseLower.includes(word)) {
|
|
445
|
-
matches++;
|
|
446
|
-
if (matches >= threshold) return true;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
return false;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Build a memory source footnote string from injected entries and response text.
|
|
454
|
-
* Returns null if no relevant entries found.
|
|
455
|
-
*/
|
|
456
|
-
export function buildFootnote(
|
|
457
|
-
entries: Array<{ title: string; date: string }>,
|
|
458
|
-
_responseText: string,
|
|
459
|
-
maxEntries = 3,
|
|
460
|
-
): string | null {
|
|
461
|
-
if (entries.length === 0) return null;
|
|
462
|
-
|
|
463
|
-
const display = entries.slice(0, maxEntries);
|
|
464
|
-
const parts = display.map((e) => {
|
|
465
|
-
const dateStr = formatShortDate(e.date);
|
|
466
|
-
return dateStr ? `"${e.title}" (${dateStr})` : `"${e.title}"`;
|
|
467
|
-
});
|
|
468
|
-
return `\n\n📎 Palaia: ${parts.join(", ")}`;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// ============================================================================
|
|
472
|
-
// Satisfaction / Transparency Nudge Helpers (Issue #87)
|
|
473
|
-
// ============================================================================
|
|
474
|
-
|
|
475
|
-
const SATISFACTION_THRESHOLD = 10;
|
|
476
|
-
const TRANSPARENCY_RECALL_THRESHOLD = 50;
|
|
477
|
-
const TRANSPARENCY_DAYS_THRESHOLD = 7;
|
|
478
|
-
|
|
479
|
-
const SATISFACTION_NUDGE_TEXT =
|
|
480
|
-
"Your user has been using Palaia for a while now. " +
|
|
481
|
-
"Ask them casually if they're happy with the memory system. " +
|
|
482
|
-
"If there are issues, suggest `palaia doctor`.";
|
|
483
|
-
|
|
484
|
-
const TRANSPARENCY_NUDGE_TEXT =
|
|
485
|
-
"Your user has been seeing memory Footnotes and capture confirmations for several days. " +
|
|
486
|
-
"Ask them once: 'Would you like to keep seeing memory source references and capture " +
|
|
487
|
-
"confirmations, or should I hide them? You can change this anytime.' " +
|
|
488
|
-
"Based on their answer: `palaia config set showMemorySources true/false` and " +
|
|
489
|
-
"`palaia config set showCaptureConfirm true/false`";
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Check which nudges (if any) should fire based on plugin state.
|
|
493
|
-
* Returns nudge texts to prepend, and updates state accordingly.
|
|
494
|
-
*/
|
|
495
|
-
export function checkNudges(state: PluginState): { nudges: string[]; updated: boolean } {
|
|
496
|
-
const nudges: string[] = [];
|
|
497
|
-
let updated = false;
|
|
498
|
-
|
|
499
|
-
if (!state.satisfactionNudged && state.successfulRecalls >= SATISFACTION_THRESHOLD) {
|
|
500
|
-
nudges.push(SATISFACTION_NUDGE_TEXT);
|
|
501
|
-
state.satisfactionNudged = true;
|
|
502
|
-
updated = true;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
if (!state.transparencyNudged && state.firstRecallTimestamp) {
|
|
506
|
-
const daysSinceFirst = (Date.now() - new Date(state.firstRecallTimestamp).getTime()) / (1000 * 60 * 60 * 24);
|
|
507
|
-
if (state.successfulRecalls >= TRANSPARENCY_RECALL_THRESHOLD || daysSinceFirst >= TRANSPARENCY_DAYS_THRESHOLD) {
|
|
508
|
-
nudges.push(TRANSPARENCY_NUDGE_TEXT);
|
|
509
|
-
state.transparencyNudged = true;
|
|
510
|
-
updated = true;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
return { nudges, updated };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// ============================================================================
|
|
518
|
-
// Capture Hints (Issue #81)
|
|
519
|
-
// ============================================================================
|
|
520
|
-
|
|
521
|
-
/** Parsed palaia-hint tag attributes */
|
|
522
|
-
export interface PalaiaHint {
|
|
523
|
-
project?: string;
|
|
524
|
-
scope?: string;
|
|
525
|
-
type?: string;
|
|
526
|
-
tags?: string[];
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Parse `<palaia-hint ... />` tags from text.
|
|
531
|
-
* Returns extracted hints and cleaned text with hints removed.
|
|
532
|
-
*/
|
|
533
|
-
export function parsePalaiaHints(text: string): { hints: PalaiaHint[]; cleanedText: string } {
|
|
534
|
-
const hints: PalaiaHint[] = [];
|
|
535
|
-
const regex = /<palaia-hint\s+([^/]*)\s*\/>/gi;
|
|
536
|
-
|
|
537
|
-
let match: RegExpExecArray | null;
|
|
538
|
-
while ((match = regex.exec(text)) !== null) {
|
|
539
|
-
const attrs = match[1];
|
|
540
|
-
const hint: PalaiaHint = {};
|
|
541
|
-
|
|
542
|
-
const projectMatch = attrs.match(/project\s*=\s*"([^"]*)"/i);
|
|
543
|
-
if (projectMatch) hint.project = projectMatch[1];
|
|
544
|
-
|
|
545
|
-
const scopeMatch = attrs.match(/scope\s*=\s*"([^"]*)"/i);
|
|
546
|
-
if (scopeMatch) hint.scope = scopeMatch[1];
|
|
547
|
-
|
|
548
|
-
const typeMatch = attrs.match(/type\s*=\s*"([^"]*)"/i);
|
|
549
|
-
if (typeMatch) hint.type = typeMatch[1];
|
|
550
|
-
|
|
551
|
-
const tagsMatch = attrs.match(/tags\s*=\s*"([^"]*)"/i);
|
|
552
|
-
if (tagsMatch) hint.tags = tagsMatch[1].split(",").map((t) => t.trim()).filter(Boolean);
|
|
553
|
-
|
|
554
|
-
hints.push(hint);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const cleanedText = text.replace(/<palaia-hint\s+[^/]*\s*\/>/gi, "").trim();
|
|
558
|
-
return { hints, cleanedText };
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// ============================================================================
|
|
562
|
-
// Project Cache (Issue #81)
|
|
563
|
-
// ============================================================================
|
|
564
|
-
|
|
565
|
-
interface CachedProject {
|
|
566
|
-
name: string;
|
|
567
|
-
description?: string;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
let _cachedProjects: CachedProject[] | null = null;
|
|
571
|
-
let _projectCacheTime = 0;
|
|
572
|
-
const PROJECT_CACHE_TTL_MS = 60_000;
|
|
573
|
-
|
|
574
|
-
/** Reset project cache (for testing). */
|
|
575
|
-
export function resetProjectCache(): void {
|
|
576
|
-
_cachedProjects = null;
|
|
577
|
-
_projectCacheTime = 0;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* Load known projects from CLI, with caching.
|
|
582
|
-
*/
|
|
583
|
-
async function loadProjects(opts: import("./runner.js").RunnerOpts): Promise<CachedProject[]> {
|
|
584
|
-
const now = Date.now();
|
|
585
|
-
if (_cachedProjects && (now - _projectCacheTime) < PROJECT_CACHE_TTL_MS) {
|
|
586
|
-
return _cachedProjects;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
try {
|
|
590
|
-
const result = await runJson<{ projects: Array<{ name: string; description?: string }> }>(
|
|
591
|
-
["project", "list"],
|
|
592
|
-
opts,
|
|
593
|
-
);
|
|
594
|
-
_cachedProjects = (result.projects || []).map((p) => ({
|
|
595
|
-
name: p.name,
|
|
596
|
-
description: p.description,
|
|
597
|
-
}));
|
|
598
|
-
_projectCacheTime = now;
|
|
599
|
-
return _cachedProjects;
|
|
600
|
-
} catch {
|
|
601
|
-
return _cachedProjects || [];
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// ============================================================================
|
|
606
|
-
// Types
|
|
607
|
-
// ============================================================================
|
|
608
|
-
|
|
609
|
-
/** Shape returned by `palaia query --json` or `palaia list --json` */
|
|
610
|
-
interface QueryResult {
|
|
611
|
-
results: Array<{
|
|
612
|
-
id: string;
|
|
613
|
-
body?: string;
|
|
614
|
-
content?: string;
|
|
615
|
-
score: number;
|
|
616
|
-
tier: string;
|
|
617
|
-
scope: string;
|
|
618
|
-
title?: string;
|
|
619
|
-
type?: string;
|
|
620
|
-
tags?: string[];
|
|
621
|
-
}>;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** Message shape from OpenClaw event.messages */
|
|
625
|
-
interface Message {
|
|
626
|
-
role?: string;
|
|
627
|
-
content?: string | Array<{ type?: string; text?: string }>;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// ============================================================================
|
|
631
|
-
// LLM-based Extraction (Issue #64 upgrade)
|
|
632
|
-
// ============================================================================
|
|
633
|
-
|
|
634
|
-
/** Result from LLM-based knowledge extraction */
|
|
635
|
-
export interface ExtractionResult {
|
|
636
|
-
content: string;
|
|
637
|
-
type: "memory" | "process" | "task";
|
|
638
|
-
tags: string[];
|
|
639
|
-
significance: number;
|
|
640
|
-
project?: string | null;
|
|
641
|
-
scope?: string | null;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
|
|
645
|
-
|
|
646
|
-
let _embeddedPiAgentLoader: Promise<RunEmbeddedPiAgentFn> | null = null;
|
|
647
|
-
/** Whether the LLM import failure has already been logged (to avoid spam). */
|
|
648
|
-
let _llmImportFailureLogged = false;
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Resolve the path to OpenClaw's extensionAPI module.
|
|
652
|
-
* Uses multiple strategies for portability across installation layouts.
|
|
653
|
-
*/
|
|
654
|
-
function resolveExtensionAPIPath(): string | null {
|
|
655
|
-
// Strategy 1: require.resolve with openclaw package exports
|
|
656
|
-
try {
|
|
657
|
-
return require.resolve("openclaw/dist/extensionAPI.js");
|
|
658
|
-
} catch {
|
|
659
|
-
// Not resolvable via standard module resolution
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Strategy 2: Resolve openclaw main entry, then navigate to dist/extensionAPI.js
|
|
663
|
-
try {
|
|
664
|
-
const openclawMain = require.resolve("openclaw");
|
|
665
|
-
const candidate = path.join(path.dirname(openclawMain), "extensionAPI.js");
|
|
666
|
-
if (require("node:fs").existsSync(candidate)) return candidate;
|
|
667
|
-
} catch {
|
|
668
|
-
// openclaw not resolvable at all
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Strategy 3: Sibling in global node_modules (plugin installed alongside openclaw)
|
|
672
|
-
try {
|
|
673
|
-
const thisFile = typeof __dirname !== "undefined" ? __dirname : path.dirname(new URL(import.meta.url).pathname);
|
|
674
|
-
// Walk up from plugin src/dist to node_modules, then into openclaw
|
|
675
|
-
let dir = thisFile;
|
|
676
|
-
for (let i = 0; i < 6; i++) {
|
|
677
|
-
const candidate = path.join(dir, "openclaw", "dist", "extensionAPI.js");
|
|
678
|
-
if (require("node:fs").existsSync(candidate)) return candidate;
|
|
679
|
-
const parent = path.dirname(dir);
|
|
680
|
-
if (parent === dir) break;
|
|
681
|
-
dir = parent;
|
|
682
|
-
}
|
|
683
|
-
} catch {
|
|
684
|
-
// Traversal failed
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Strategy 4: Well-known global install paths
|
|
688
|
-
const globalCandidates = [
|
|
689
|
-
path.join(os.homedir(), ".openclaw", "node_modules", "openclaw", "dist", "extensionAPI.js"),
|
|
690
|
-
"/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw/dist/extensionAPI.js",
|
|
691
|
-
"/usr/local/lib/node_modules/openclaw/dist/extensionAPI.js",
|
|
692
|
-
"/usr/lib/node_modules/openclaw/dist/extensionAPI.js",
|
|
693
|
-
];
|
|
694
|
-
for (const candidate of globalCandidates) {
|
|
695
|
-
try {
|
|
696
|
-
if (require("node:fs").existsSync(candidate)) return candidate;
|
|
697
|
-
} catch {
|
|
698
|
-
// skip
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
return null;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
|
|
706
|
-
const resolved = resolveExtensionAPIPath();
|
|
707
|
-
if (!resolved) {
|
|
708
|
-
throw new Error("Could not locate openclaw/dist/extensionAPI.js — tried module resolution, sibling lookup, and global paths");
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
const mod = (await import(resolved)) as { runEmbeddedPiAgent?: unknown };
|
|
712
|
-
const fn = (mod as any).runEmbeddedPiAgent;
|
|
713
|
-
if (typeof fn !== "function") {
|
|
714
|
-
throw new Error(`runEmbeddedPiAgent not exported from ${resolved}`);
|
|
715
|
-
}
|
|
716
|
-
return fn as RunEmbeddedPiAgentFn;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
export function getEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
|
|
720
|
-
if (!_embeddedPiAgentLoader) {
|
|
721
|
-
_embeddedPiAgentLoader = loadRunEmbeddedPiAgent();
|
|
722
|
-
}
|
|
723
|
-
return _embeddedPiAgentLoader;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/** Reset cached loader (for testing). */
|
|
727
|
-
export function resetEmbeddedPiAgentLoader(): void {
|
|
728
|
-
_embeddedPiAgentLoader = null;
|
|
729
|
-
_llmImportFailureLogged = false;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Override the cached loader with a custom promise (for testing). */
|
|
733
|
-
export function setEmbeddedPiAgentLoader(loader: Promise<RunEmbeddedPiAgentFn> | null): void {
|
|
734
|
-
_embeddedPiAgentLoader = loader;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const EXTRACTION_SYSTEM_PROMPT_BASE = `You are a knowledge extraction engine. Analyze the following conversation exchange and identify information worth remembering long-term.
|
|
738
|
-
|
|
739
|
-
For each piece of knowledge, return a JSON array of objects:
|
|
740
|
-
- "content": concise summary of the knowledge (1-3 sentences)
|
|
741
|
-
- "type": "memory" (facts, decisions, preferences), "process" (workflows, procedures, steps), or "task" (action items, todos, commitments)
|
|
742
|
-
- "tags": array of significance tags from: ["decision", "lesson", "surprise", "commitment", "correction", "preference", "fact"]
|
|
743
|
-
- "significance": 0.0-1.0 how important this is for long-term recall
|
|
744
|
-
- "project": which project this belongs to (from known projects list, or null if unclear)
|
|
745
|
-
- "scope": "private" (personal preference, agent-specific), "team" (shared knowledge), or "public" (documentation)
|
|
746
|
-
|
|
747
|
-
STRICT TASK CLASSIFICATION RULES — a "task" MUST have ALL three of:
|
|
748
|
-
1. A clear, completable action (not just an observation or idea)
|
|
749
|
-
2. An identifiable responsible party (explicitly named or unambiguously inferable from context)
|
|
750
|
-
3. A concrete deliverable or measurable end state
|
|
751
|
-
If ANY of these is missing, classify as "memory" instead of "task". When in doubt, use "memory".
|
|
752
|
-
Observations, learnings, insights, opinions, and general knowledge are ALWAYS "memory", never "task".
|
|
753
|
-
|
|
754
|
-
Only extract genuinely significant knowledge. Skip small talk, acknowledgments, routine exchanges.
|
|
755
|
-
Do NOT extract if similar knowledge was likely captured in a recent exchange. Prefer quality over quantity. Skip routine status updates and acknowledgments.
|
|
756
|
-
Return empty array [] if nothing is worth remembering.
|
|
757
|
-
Return ONLY valid JSON, no markdown fences.`;
|
|
758
|
-
|
|
759
|
-
function buildExtractionPrompt(projects: CachedProject[]): string {
|
|
760
|
-
if (projects.length === 0) return EXTRACTION_SYSTEM_PROMPT_BASE;
|
|
761
|
-
const projectList = projects
|
|
762
|
-
.map((p) => `${p.name}${p.description ? ` (${p.description})` : ""}`)
|
|
763
|
-
.join(", ");
|
|
764
|
-
return `${EXTRACTION_SYSTEM_PROMPT_BASE}\n\nKnown projects: ${projectList}`;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
/** Whether the captureModel fallback warning has already been logged (to avoid spam). */
|
|
768
|
-
let _captureModelFallbackWarned = false;
|
|
769
|
-
|
|
770
|
-
/** Whether the captureModel→primary model fallback warning has been logged (max 1x per gateway lifetime). */
|
|
771
|
-
let _captureModelFailoverWarned = false;
|
|
772
|
-
|
|
773
|
-
/** Reset captureModel fallback warning flag (for testing). */
|
|
774
|
-
export function resetCaptureModelFallbackWarning(): void {
|
|
775
|
-
_captureModelFallbackWarned = false;
|
|
776
|
-
_captureModelFailoverWarned = false;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* Resolve the model to use for LLM-based capture extraction.
|
|
781
|
-
*
|
|
782
|
-
* Strategy (no static model mapping — user config is the source of truth):
|
|
783
|
-
* 1. If captureModel is set explicitly (e.g. "anthropic/claude-haiku-4-5"): use it directly.
|
|
784
|
-
* 2. If captureModel is unset: use the primary model from user config.
|
|
785
|
-
* Log a one-time warning recommending to set a cheaper captureModel.
|
|
786
|
-
* 3. Never fall back to static model IDs — model IDs change and not every user has Anthropic.
|
|
787
|
-
*/
|
|
788
|
-
export function resolveCaptureModel(
|
|
789
|
-
config: any,
|
|
790
|
-
captureModel?: string,
|
|
791
|
-
): { provider: string; model: string } | undefined {
|
|
792
|
-
// Case 1: explicit model ID provided (not "cheap")
|
|
793
|
-
if (captureModel && captureModel !== "cheap") {
|
|
794
|
-
const parts = captureModel.split("/");
|
|
795
|
-
if (parts.length >= 2) {
|
|
796
|
-
return { provider: parts[0], model: parts.slice(1).join("/") };
|
|
797
|
-
}
|
|
798
|
-
// No slash — treat as model name with provider from primary config
|
|
799
|
-
const defaultsModel = config?.agents?.defaults?.model;
|
|
800
|
-
const primary = typeof defaultsModel === "string"
|
|
801
|
-
? defaultsModel.trim()
|
|
802
|
-
: (defaultsModel?.primary?.trim() ?? "");
|
|
803
|
-
const defaultProvider = primary.split("/")[0];
|
|
804
|
-
if (defaultProvider) {
|
|
805
|
-
return { provider: defaultProvider, model: captureModel };
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// Case 2: "cheap" or unset — use primary model from user config
|
|
810
|
-
const defaultsModel = config?.agents?.defaults?.model;
|
|
811
|
-
|
|
812
|
-
const primary = typeof defaultsModel === "string"
|
|
813
|
-
? defaultsModel.trim()
|
|
814
|
-
: (typeof defaultsModel === "object" && defaultsModel !== null
|
|
815
|
-
? String(defaultsModel.primary ?? "").trim()
|
|
816
|
-
: "");
|
|
817
|
-
|
|
818
|
-
if (primary) {
|
|
819
|
-
const parts = primary.split("/");
|
|
820
|
-
if (parts.length >= 2) {
|
|
821
|
-
if (!_captureModelFallbackWarned) {
|
|
822
|
-
_captureModelFallbackWarned = true;
|
|
823
|
-
logger.warn(`[palaia] No captureModel configured — using primary model. Set captureModel in plugin config for cost savings.`);
|
|
824
|
-
}
|
|
825
|
-
return { provider: parts[0], model: parts.slice(1).join("/") };
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
return undefined;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function stripCodeFences(s: string): string {
|
|
833
|
-
const trimmed = s.trim();
|
|
834
|
-
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
835
|
-
if (m) return (m[1] ?? "").trim();
|
|
836
|
-
return trimmed;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string {
|
|
840
|
-
return (payloads ?? [])
|
|
841
|
-
.filter((p) => !p.isError && typeof p.text === "string")
|
|
842
|
-
.map((p) => p.text ?? "")
|
|
843
|
-
.join("\n")
|
|
844
|
-
.trim();
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* Trim message texts to a recent window for LLM extraction.
|
|
849
|
-
* Only extract from recent exchanges — full history causes LLM timeouts
|
|
850
|
-
* and dilutes extraction quality.
|
|
851
|
-
*
|
|
852
|
-
* Strategy: keep last N user+assistant pairs (skip toolResult roles),
|
|
853
|
-
* then hard-cap at maxChars from the end (newest messages kept).
|
|
854
|
-
*/
|
|
855
|
-
export function trimToRecentExchanges(
|
|
856
|
-
texts: Array<{ role: string; text: string; provenance?: string }>,
|
|
857
|
-
maxPairs = 5,
|
|
858
|
-
maxChars = 10_000,
|
|
859
|
-
): Array<{ role: string; text: string; provenance?: string }> {
|
|
860
|
-
// Filter to only user + assistant messages (skip tool, toolResult, system, etc.)
|
|
861
|
-
const exchanges = texts.filter((t) => t.role === "user" || t.role === "assistant");
|
|
862
|
-
|
|
863
|
-
// Keep the last N pairs (a pair = one user + one assistant message)
|
|
864
|
-
// Only count external_user messages as real user turns.
|
|
865
|
-
// System-injected user messages (inter_session, internal_system) don't count as conversation turns.
|
|
866
|
-
// Walk backwards, count pairs
|
|
867
|
-
let pairCount = 0;
|
|
868
|
-
let lastRole = "";
|
|
869
|
-
let cutIndex = 0; // default: keep everything
|
|
870
|
-
for (let i = exchanges.length - 1; i >= 0; i--) {
|
|
871
|
-
const isRealUser = exchanges[i].role === "user" && (
|
|
872
|
-
exchanges[i].provenance === "external_user" ||
|
|
873
|
-
!exchanges[i].provenance // backward compat: no provenance = treat as real user
|
|
874
|
-
);
|
|
875
|
-
// Count a new pair when we see a real user message after having seen an assistant
|
|
876
|
-
if (isRealUser && lastRole === "assistant") {
|
|
877
|
-
pairCount++;
|
|
878
|
-
if (pairCount > maxPairs) {
|
|
879
|
-
cutIndex = i + 1; // keep from next message onwards
|
|
880
|
-
break;
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
if (exchanges[i].role !== lastRole) {
|
|
884
|
-
lastRole = exchanges[i].role;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
let trimmed = exchanges.slice(cutIndex);
|
|
888
|
-
|
|
889
|
-
// Hard cap: max chars from the end (keep newest)
|
|
890
|
-
let totalChars = trimmed.reduce((sum, t) => sum + t.text.length + t.role.length + 5, 0);
|
|
891
|
-
while (totalChars > maxChars && trimmed.length > 1) {
|
|
892
|
-
const removed = trimmed.shift()!;
|
|
893
|
-
totalChars -= removed.text.length + removed.role.length + 5;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
return trimmed;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
export async function extractWithLLM(
|
|
900
|
-
messages: unknown[],
|
|
901
|
-
config: any,
|
|
902
|
-
pluginConfig?: { captureModel?: string },
|
|
903
|
-
knownProjects?: CachedProject[],
|
|
904
|
-
): Promise<ExtractionResult[]> {
|
|
905
|
-
const runEmbeddedPiAgent = await getEmbeddedPiAgent();
|
|
906
|
-
|
|
907
|
-
const resolved = resolveCaptureModel(config, pluginConfig?.captureModel);
|
|
908
|
-
if (!resolved) {
|
|
909
|
-
throw new Error("No model available for LLM extraction");
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const allTexts = extractMessageTexts(messages);
|
|
913
|
-
// Strip Palaia-injected recall context from user messages to prevent feedback loop
|
|
914
|
-
const cleanedTexts = allTexts.map(t =>
|
|
915
|
-
t.role === "user"
|
|
916
|
-
? { ...t, text: stripPalaiaInjectedContext(t.text) }
|
|
917
|
-
: t
|
|
918
|
-
);
|
|
919
|
-
// Only extract from recent exchanges — full history causes LLM timeouts
|
|
920
|
-
// and dilutes extraction quality
|
|
921
|
-
const recentTexts = trimToRecentExchanges(cleanedTexts);
|
|
922
|
-
const exchangeText = recentTexts
|
|
923
|
-
.map((t) => `[${t.role}]: ${t.text}`)
|
|
924
|
-
.join("\n");
|
|
925
|
-
|
|
926
|
-
if (!exchangeText.trim()) {
|
|
927
|
-
return [];
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
const systemPrompt = buildExtractionPrompt(knownProjects || []);
|
|
931
|
-
const prompt = `${systemPrompt}\n\n--- CONVERSATION ---\n${exchangeText}\n--- END ---`;
|
|
932
|
-
|
|
933
|
-
let tmpDir: string | null = null;
|
|
934
|
-
try {
|
|
935
|
-
// Use a fixed base directory for extraction temp dirs and clean up stale ones
|
|
936
|
-
const extractBaseDir = path.join(os.tmpdir(), "palaia-extractions");
|
|
937
|
-
await fs.mkdir(extractBaseDir, { recursive: true });
|
|
938
|
-
// Clean up stale extraction dirs (older than 5 minutes)
|
|
939
|
-
try {
|
|
940
|
-
const entries = await fs.readdir(extractBaseDir, { withFileTypes: true });
|
|
941
|
-
const now = Date.now();
|
|
942
|
-
for (const entry of entries) {
|
|
943
|
-
if (entry.isDirectory()) {
|
|
944
|
-
try {
|
|
945
|
-
const stat = await fs.stat(path.join(extractBaseDir, entry.name));
|
|
946
|
-
if (now - stat.mtimeMs > 5 * 60 * 1000) {
|
|
947
|
-
await fs.rm(path.join(extractBaseDir, entry.name), { recursive: true, force: true });
|
|
948
|
-
}
|
|
949
|
-
} catch { /* ignore individual cleanup errors */ }
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
} catch { /* ignore cleanup errors */ }
|
|
953
|
-
tmpDir = await fs.mkdtemp(path.join(extractBaseDir, "ext-"));
|
|
954
|
-
const sessionId = `palaia-extract-${Date.now()}`;
|
|
955
|
-
const sessionFile = path.join(tmpDir, "session.json");
|
|
956
|
-
|
|
957
|
-
const result = await runEmbeddedPiAgent({
|
|
958
|
-
sessionId,
|
|
959
|
-
sessionFile,
|
|
960
|
-
workspaceDir: config?.agents?.defaults?.workspace ?? process.cwd(),
|
|
961
|
-
config,
|
|
962
|
-
prompt,
|
|
963
|
-
timeoutMs: 15_000,
|
|
964
|
-
runId: `palaia-extract-${Date.now()}`,
|
|
965
|
-
provider: resolved.provider,
|
|
966
|
-
model: resolved.model,
|
|
967
|
-
disableTools: true,
|
|
968
|
-
streamParams: { maxTokens: 2048 },
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
const text = collectText((result as any).payloads);
|
|
972
|
-
if (!text) return [];
|
|
973
|
-
|
|
974
|
-
const raw = stripCodeFences(text);
|
|
975
|
-
let parsed: unknown;
|
|
976
|
-
try {
|
|
977
|
-
parsed = JSON.parse(raw);
|
|
978
|
-
} catch {
|
|
979
|
-
throw new Error(`LLM returned invalid JSON: ${raw.slice(0, 200)}`);
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
if (!Array.isArray(parsed)) {
|
|
983
|
-
throw new Error(`LLM returned non-array: ${typeof parsed}`);
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const results: ExtractionResult[] = [];
|
|
987
|
-
for (const item of parsed) {
|
|
988
|
-
if (!item || typeof item !== "object") continue;
|
|
989
|
-
const content = typeof item.content === "string" ? item.content.trim() : "";
|
|
990
|
-
if (!content) continue;
|
|
991
|
-
|
|
992
|
-
const validTypes = new Set(["memory", "process", "task"]);
|
|
993
|
-
const type = validTypes.has(item.type) ? item.type : "memory";
|
|
994
|
-
|
|
995
|
-
const validTags = new Set([
|
|
996
|
-
"decision", "lesson", "surprise", "commitment",
|
|
997
|
-
"correction", "preference", "fact",
|
|
998
|
-
]);
|
|
999
|
-
const tags = Array.isArray(item.tags)
|
|
1000
|
-
? item.tags.filter((t: unknown) => typeof t === "string" && validTags.has(t))
|
|
1001
|
-
: [];
|
|
1002
|
-
|
|
1003
|
-
const significance = typeof item.significance === "number"
|
|
1004
|
-
? Math.max(0, Math.min(1, item.significance))
|
|
1005
|
-
: 0.5;
|
|
1006
|
-
|
|
1007
|
-
const project = typeof item.project === "string" && item.project.trim()
|
|
1008
|
-
? item.project.trim()
|
|
1009
|
-
: null;
|
|
1010
|
-
|
|
1011
|
-
const scope = typeof item.scope === "string" && isValidScope(item.scope)
|
|
1012
|
-
? item.scope
|
|
1013
|
-
: null;
|
|
1014
|
-
|
|
1015
|
-
results.push({ content, type, tags, significance, project, scope });
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
return results;
|
|
1019
|
-
} finally {
|
|
1020
|
-
if (tmpDir) {
|
|
1021
|
-
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// ============================================================================
|
|
1027
|
-
// Auto-Capture: Rule-based extraction (Issue #64)
|
|
1028
|
-
// ============================================================================
|
|
1029
|
-
|
|
1030
|
-
const TRIVIAL_RESPONSES = new Set([
|
|
1031
|
-
"ok", "ja", "nein", "yes", "no", "sure", "klar", "danke", "thanks",
|
|
1032
|
-
"thx", "k", "👍", "👎", "ack", "nope", "yep", "yup", "alright",
|
|
1033
|
-
"fine", "gut", "passt", "okay", "hmm", "hm", "ah", "aha",
|
|
1034
|
-
]);
|
|
1035
|
-
|
|
1036
|
-
const SIGNIFICANCE_RULES: Array<{
|
|
1037
|
-
pattern: RegExp;
|
|
1038
|
-
tag: string;
|
|
1039
|
-
type: "memory" | "process" | "task";
|
|
1040
|
-
}> = [
|
|
1041
|
-
{ pattern: /(?:we decided|entschieden|decision:|beschlossen|let'?s go with|wir nehmen|agreed on)/i, tag: "decision", type: "memory" },
|
|
1042
|
-
{ pattern: /(?:will use|werden nutzen|going forward|ab jetzt|from now on)/i, tag: "decision", type: "memory" },
|
|
1043
|
-
{ pattern: /(?:learned|gelernt|lesson:|erkenntnis|takeaway|insight|turns out|it seems)/i, tag: "lesson", type: "memory" },
|
|
1044
|
-
{ pattern: /(?:mistake was|fehler war|should have|hätten sollen|next time)/i, tag: "lesson", type: "memory" },
|
|
1045
|
-
{ pattern: /(?:surprising|überraschend|unexpected|unerwartet|didn'?t expect|nicht erwartet|plot twist)/i, tag: "surprise", type: "memory" },
|
|
1046
|
-
{ pattern: /(?:i will|ich werde|todo:|action item|must do|muss noch|need to|commit to|verspreche)/i, tag: "commitment", type: "task" },
|
|
1047
|
-
{ pattern: /(?:deadline|frist|due date|bis zum|by end of|spätestens)/i, tag: "commitment", type: "task" },
|
|
1048
|
-
{ pattern: /(?:the process is|der prozess|steps?:|workflow:|how to|anleitung|recipe:|checklist)/i, tag: "process", type: "process" },
|
|
1049
|
-
{ pattern: /(?:first,?\s.*then|schritt \d|step \d|1\.\s.*2\.\s)/i, tag: "process", type: "process" },
|
|
1050
|
-
];
|
|
1051
|
-
|
|
1052
|
-
const NOISE_PATTERNS: RegExp[] = [
|
|
1053
|
-
/(?:PASSED|FAILED|ERROR)\s+\[?\d+%\]?/i,
|
|
1054
|
-
/(?:test_\w+|tests?\/\w+\.(?:py|ts|js))\s*::/,
|
|
1055
|
-
/(?:pytest|vitest|jest|mocha)\s+(?:run|--)/i,
|
|
1056
|
-
/\d+ passed,?\s*\d* (?:failed|error|warning)/i,
|
|
1057
|
-
/^(?:=+\s*(?:test session|ERRORS|FAILURES|short test summary))/m,
|
|
1058
|
-
/(?:Traceback \(most recent call last\)|^\s+File ".*", line \d+)/m,
|
|
1059
|
-
/^\s+at\s+\S+\s+\(.*:\d+:\d+\)/m,
|
|
1060
|
-
/^(?:\/[\w/.-]+){3,}\s*$/m,
|
|
1061
|
-
/(?:npm\s+(?:ERR|WARN)|pip\s+install|cargo\s+build)/i,
|
|
1062
|
-
/^(?:warning|error)\[?\w*\]?:\s/m,
|
|
1063
|
-
];
|
|
1064
|
-
|
|
1065
|
-
export function isNoiseContent(text: string): boolean {
|
|
1066
|
-
let matchCount = 0;
|
|
1067
|
-
for (const pattern of NOISE_PATTERNS) {
|
|
1068
|
-
if (pattern.test(text)) {
|
|
1069
|
-
matchCount++;
|
|
1070
|
-
if (matchCount >= 2) return true;
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
1075
|
-
if (lines.length > 3) {
|
|
1076
|
-
const pathLines = lines.filter((l) => /^\s*(?:\/[\w/.-]+){2,}/.test(l.trim()));
|
|
1077
|
-
if (pathLines.length / lines.length > 0.5) return true;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
return false;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
export function shouldAttemptCapture(
|
|
1084
|
-
exchangeText: string,
|
|
1085
|
-
minChars = 100,
|
|
1086
|
-
): boolean {
|
|
1087
|
-
const trimmed = exchangeText.trim();
|
|
1088
|
-
|
|
1089
|
-
if (trimmed.length < minChars) return false;
|
|
1090
|
-
|
|
1091
|
-
const words = trimmed.toLowerCase().split(/\s+/);
|
|
1092
|
-
if (words.length <= 3 && words.every((w) => TRIVIAL_RESPONSES.has(w))) {
|
|
1093
|
-
return false;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
if (trimmed.includes("<relevant-memories>")) return false;
|
|
1097
|
-
if (trimmed.startsWith("<") && trimmed.includes("</")) return false;
|
|
1098
|
-
|
|
1099
|
-
if (isNoiseContent(trimmed)) return false;
|
|
1100
|
-
|
|
1101
|
-
return true;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
export function extractSignificance(
|
|
1105
|
-
exchangeText: string,
|
|
1106
|
-
): { tags: string[]; type: "memory" | "process" | "task"; summary: string } | null {
|
|
1107
|
-
const matched: Array<{ tag: string; type: "memory" | "process" | "task" }> = [];
|
|
1108
|
-
|
|
1109
|
-
for (const rule of SIGNIFICANCE_RULES) {
|
|
1110
|
-
if (rule.pattern.test(exchangeText)) {
|
|
1111
|
-
matched.push({ tag: rule.tag, type: rule.type });
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
if (matched.length === 0) return null;
|
|
1116
|
-
|
|
1117
|
-
// Require at least 2 different significance tags for rule-based capture
|
|
1118
|
-
const uniqueTags = new Set(matched.map((m) => m.tag));
|
|
1119
|
-
if (uniqueTags.size < 2) return null;
|
|
1120
|
-
|
|
1121
|
-
const typePriority: Record<string, number> = { task: 3, process: 2, memory: 1 };
|
|
1122
|
-
const primaryType = matched.reduce(
|
|
1123
|
-
(best, m) => (typePriority[m.type] > typePriority[best] ? m.type : best),
|
|
1124
|
-
"memory" as "memory" | "process" | "task",
|
|
1125
|
-
);
|
|
1126
|
-
|
|
1127
|
-
const tags = [...new Set(matched.map((m) => m.tag))];
|
|
1128
|
-
|
|
1129
|
-
const sentences = exchangeText
|
|
1130
|
-
.split(/[.!?\n]+/)
|
|
1131
|
-
.map((s) => s.trim())
|
|
1132
|
-
.filter((s) => s.length > 20 && s.length < 500);
|
|
1133
|
-
|
|
1134
|
-
const relevantSentences = sentences.filter((s) =>
|
|
1135
|
-
SIGNIFICANCE_RULES.some((r) => r.pattern.test(s)),
|
|
1136
|
-
);
|
|
1137
|
-
|
|
1138
|
-
const summary = (relevantSentences.length > 0 ? relevantSentences : sentences)
|
|
1139
|
-
.slice(0, 3)
|
|
1140
|
-
.join(". ")
|
|
1141
|
-
.slice(0, 500);
|
|
1142
|
-
|
|
1143
|
-
if (!summary) return null;
|
|
1144
|
-
|
|
1145
|
-
return { tags, type: primaryType, summary };
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* Strip Palaia-injected recall context from message text.
|
|
1150
|
-
* The recall block is prepended to user messages by before_prompt_build via prependContext.
|
|
1151
|
-
* OpenClaw merges it into the user message, so agent_end sees it as user content.
|
|
1152
|
-
* Without stripping, auto-capture re-captures the injected memories → feedback loop.
|
|
1153
|
-
*
|
|
1154
|
-
* The block has a stable structure:
|
|
1155
|
-
* - Starts with "## Active Memory (Palaia)"
|
|
1156
|
-
* - Contains [t/m], [t/pr], [t/tk] prefixed entries
|
|
1157
|
-
* - Ends with "[palaia] auto-capture=on..." nudge line
|
|
1158
|
-
*/
|
|
1159
|
-
export function stripPalaiaInjectedContext(text: string): string {
|
|
1160
|
-
// Pattern: "## Active Memory (Palaia)" ... "[palaia] auto-capture=on..." + optional trailing newlines
|
|
1161
|
-
// The nudge line is always present and marks the end of the injected block
|
|
1162
|
-
const PALAIA_BLOCK_RE = /## Active Memory \(Palaia\)[\s\S]*?\[palaia\][^\n]*\n*/;
|
|
1163
|
-
return text.replace(PALAIA_BLOCK_RE, '').trim();
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string; provenance?: string }> {
|
|
1167
|
-
const result: Array<{ role: string; text: string; provenance?: string }> = [];
|
|
1168
|
-
|
|
1169
|
-
for (const msg of messages) {
|
|
1170
|
-
if (!msg || typeof msg !== "object") continue;
|
|
1171
|
-
const m = msg as Message;
|
|
1172
|
-
const role = m.role;
|
|
1173
|
-
if (!role || typeof role !== "string") continue;
|
|
1174
|
-
|
|
1175
|
-
// Extract provenance kind (string or object with .kind)
|
|
1176
|
-
const rawProvenance = (m as any).provenance?.kind ?? (m as any).provenance;
|
|
1177
|
-
const provenance = typeof rawProvenance === "string" ? rawProvenance : undefined;
|
|
1178
|
-
|
|
1179
|
-
if (typeof m.content === "string" && m.content.trim()) {
|
|
1180
|
-
result.push({ role, text: m.content.trim(), provenance });
|
|
1181
|
-
continue;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
if (Array.isArray(m.content)) {
|
|
1185
|
-
for (const block of m.content) {
|
|
1186
|
-
if (
|
|
1187
|
-
block &&
|
|
1188
|
-
typeof block === "object" &&
|
|
1189
|
-
block.type === "text" &&
|
|
1190
|
-
typeof block.text === "string" &&
|
|
1191
|
-
block.text.trim()
|
|
1192
|
-
) {
|
|
1193
|
-
result.push({ role, text: block.text.trim(), provenance });
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
return result;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
export function getLastUserMessage(messages: unknown[]): string | null {
|
|
1203
|
-
const texts = extractMessageTexts(messages);
|
|
1204
|
-
// Prefer external_user provenance (real human input)
|
|
1205
|
-
for (let i = texts.length - 1; i >= 0; i--) {
|
|
1206
|
-
if (texts[i].role === "user" && texts[i].provenance === "external_user")
|
|
1207
|
-
return texts[i].text;
|
|
1208
|
-
}
|
|
1209
|
-
// Fallback: any user message (backward compat for OpenClaw without provenance)
|
|
1210
|
-
for (let i = texts.length - 1; i >= 0; i--) {
|
|
1211
|
-
if (texts[i].role === "user") return texts[i].text;
|
|
1212
|
-
}
|
|
1213
|
-
return null;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
// ============================================================================
|
|
1217
|
-
// Channel Envelope Stripping (v2.0.6)
|
|
1218
|
-
// ============================================================================
|
|
1219
|
-
|
|
1220
|
-
/**
|
|
1221
|
-
* Strip OpenClaw channel envelope from message text.
|
|
1222
|
-
* Matches the pattern: [TIMESTAMP] or [CHANNEL TIMESTAMP] prefix
|
|
1223
|
-
* that OpenClaw adds to inbound messages from all channels.
|
|
1224
|
-
* Based on OpenClaw's internal stripEnvelope() logic.
|
|
1225
|
-
*/
|
|
1226
|
-
const ENVELOPE_PREFIX_RE = /^\[([^\]]+)\]\s*/;
|
|
1227
|
-
const ENVELOPE_CHANNELS = [
|
|
1228
|
-
"WebChat", "WhatsApp", "Telegram", "Signal", "Slack",
|
|
1229
|
-
"Discord", "Google Chat", "iMessage", "Teams", "Matrix",
|
|
1230
|
-
"Zalo", "Zalo Personal", "BlueBubbles",
|
|
1231
|
-
];
|
|
1232
|
-
|
|
1233
|
-
function looksLikeEnvelopeHeader(header: string): boolean {
|
|
1234
|
-
// ISO timestamp pattern
|
|
1235
|
-
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
|
|
1236
|
-
// Space-separated timestamp
|
|
1237
|
-
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
|
|
1238
|
-
// Channel prefix
|
|
1239
|
-
return ENVELOPE_CHANNELS.some(ch => header.startsWith(`${ch} `));
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
export function stripChannelEnvelope(text: string): string {
|
|
1243
|
-
const match = text.match(ENVELOPE_PREFIX_RE);
|
|
1244
|
-
if (!match) return text;
|
|
1245
|
-
if (!looksLikeEnvelopeHeader(match[1] ?? "")) return text;
|
|
1246
|
-
return text.slice(match[0].length);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
/**
|
|
1250
|
-
* Strip "System: [timestamp] Channel message in #channel from User: " prefix.
|
|
1251
|
-
* OpenClaw wraps inbound messages with this pattern for all channel providers.
|
|
1252
|
-
*/
|
|
1253
|
-
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;
|
|
1254
|
-
|
|
1255
|
-
export function stripSystemPrefix(text: string): string {
|
|
1256
|
-
const match = text.match(SYSTEM_PREFIX_RE);
|
|
1257
|
-
if (match) return text.slice(match[0].length);
|
|
1258
|
-
return text;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// ============================================================================
|
|
1262
|
-
// Recall Query Builder (v2.0.6: envelope-aware, provenance-based)
|
|
1263
|
-
// ============================================================================
|
|
1264
|
-
|
|
1265
|
-
/**
|
|
1266
|
-
* Messages that are purely system content (no user text).
|
|
1267
|
-
* Used to skip edited notifications, sync events, inter-session messages, etc.
|
|
1268
|
-
*/
|
|
1269
|
-
function isSystemOnlyContent(text: string): boolean {
|
|
1270
|
-
if (!text) return true;
|
|
1271
|
-
if (text.startsWith("System:")) return true;
|
|
1272
|
-
if (text.startsWith("[Queued")) return true;
|
|
1273
|
-
if (text.startsWith("[Inter-session")) return true;
|
|
1274
|
-
if (/^Slack message (edited|deleted)/.test(text)) return true;
|
|
1275
|
-
if (/^\[auto\]/.test(text)) return true;
|
|
1276
|
-
if (text.length < 3) return true;
|
|
1277
|
-
return false;
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
/**
|
|
1281
|
-
* Build a recall query from message history.
|
|
1282
|
-
*
|
|
1283
|
-
* v2.0.6: Strips OpenClaw channel envelopes (System: [...] Slack message from ...:)
|
|
1284
|
-
* and inter-session prefixes before building the query. This prevents envelope
|
|
1285
|
-
* metadata from polluting semantic search and causing timeouts / false-high scores.
|
|
1286
|
-
*
|
|
1287
|
-
* - Filters out inter_session and internal_system provenance messages.
|
|
1288
|
-
* - Falls back to any user message for backward compat (OpenClaw without provenance).
|
|
1289
|
-
* - Strips channel envelopes and system prefixes from message text.
|
|
1290
|
-
* - Skips system-only content (edited notifications, sync events).
|
|
1291
|
-
* - Short messages (< 30 chars): prepends previous for context.
|
|
1292
|
-
* - Hard-caps at 500 characters.
|
|
1293
|
-
*/
|
|
1294
|
-
export function buildRecallQuery(messages: unknown[]): string {
|
|
1295
|
-
const texts = extractMessageTexts(messages).map(t =>
|
|
1296
|
-
t.role === "user"
|
|
1297
|
-
? { ...t, text: stripPalaiaInjectedContext(t.text) }
|
|
1298
|
-
: t
|
|
1299
|
-
);
|
|
1300
|
-
|
|
1301
|
-
// Step 1: Filter out inter_session messages (sub-agent results, sessions_send)
|
|
1302
|
-
const candidates = texts.filter(
|
|
1303
|
-
t => t.role === "user" && t.provenance !== "inter_session" && t.provenance !== "internal_system"
|
|
1304
|
-
);
|
|
1305
|
-
|
|
1306
|
-
// Fallback: if no messages without provenance, use all user messages
|
|
1307
|
-
const allUserMsgs = candidates.length > 0
|
|
1308
|
-
? candidates
|
|
1309
|
-
: texts.filter(t => t.role === "user");
|
|
1310
|
-
|
|
1311
|
-
if (allUserMsgs.length === 0) return "";
|
|
1312
|
-
|
|
1313
|
-
// Early exit: only scan the last 3 user messages or 2000 chars, whichever comes first
|
|
1314
|
-
const MAX_SCAN_MSGS = 3;
|
|
1315
|
-
const MAX_SCAN_CHARS = 2000;
|
|
1316
|
-
let userMsgs: typeof allUserMsgs;
|
|
1317
|
-
if (allUserMsgs.length <= MAX_SCAN_MSGS) {
|
|
1318
|
-
userMsgs = allUserMsgs;
|
|
1319
|
-
} else {
|
|
1320
|
-
userMsgs = allUserMsgs.slice(-MAX_SCAN_MSGS);
|
|
1321
|
-
// Extend backwards if total chars < MAX_SCAN_CHARS and more messages available
|
|
1322
|
-
let totalChars = userMsgs.reduce((sum, m) => sum + m.text.length, 0);
|
|
1323
|
-
let startIdx = allUserMsgs.length - MAX_SCAN_MSGS;
|
|
1324
|
-
while (startIdx > 0 && totalChars < MAX_SCAN_CHARS) {
|
|
1325
|
-
startIdx--;
|
|
1326
|
-
totalChars += allUserMsgs[startIdx].text.length;
|
|
1327
|
-
userMsgs = allUserMsgs.slice(startIdx);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
// Step 2: Strip envelopes from the last user message(s)
|
|
1332
|
-
let lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[userMsgs.length - 1].text.trim()));
|
|
1333
|
-
|
|
1334
|
-
// Skip system-only messages (edited notifications, sync events, etc.)
|
|
1335
|
-
// Walk backwards to find a message with actual content
|
|
1336
|
-
let idx = userMsgs.length - 1;
|
|
1337
|
-
while (idx >= 0 && (!lastText || isSystemOnlyContent(lastText))) {
|
|
1338
|
-
idx--;
|
|
1339
|
-
if (idx >= 0) {
|
|
1340
|
-
lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[idx].text.trim()));
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
if (!lastText) return "";
|
|
1345
|
-
|
|
1346
|
-
// Step 3: Short messages → include previous for context
|
|
1347
|
-
if (lastText.length < 30 && idx > 0) {
|
|
1348
|
-
const prevText = stripSystemPrefix(stripChannelEnvelope(userMsgs[idx - 1].text.trim()));
|
|
1349
|
-
if (prevText && !isSystemOnlyContent(prevText)) {
|
|
1350
|
-
return `${prevText} ${lastText}`.slice(0, 500);
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
return lastText.slice(0, 500);
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
// ============================================================================
|
|
1358
|
-
// Query-based Recall: Type-weighted reranking (Issue #65)
|
|
1359
|
-
// ============================================================================
|
|
1360
|
-
|
|
1361
|
-
interface RankedEntry {
|
|
1362
|
-
id: string;
|
|
1363
|
-
body: string;
|
|
1364
|
-
title: string;
|
|
1365
|
-
scope: string;
|
|
1366
|
-
tier: string;
|
|
1367
|
-
type: string;
|
|
1368
|
-
score: number;
|
|
1369
|
-
weightedScore: number;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
export function rerankByTypeWeight(
|
|
1373
|
-
results: QueryResult["results"],
|
|
1374
|
-
weights: RecallTypeWeights,
|
|
1375
|
-
): RankedEntry[] {
|
|
1376
|
-
return results
|
|
1377
|
-
.map((r) => {
|
|
1378
|
-
const type = r.type || "memory";
|
|
1379
|
-
const weight = weights[type] ?? 1.0;
|
|
1380
|
-
return {
|
|
1381
|
-
id: r.id,
|
|
1382
|
-
body: r.content || r.body || "",
|
|
1383
|
-
title: r.title || "(untitled)",
|
|
1384
|
-
scope: r.scope,
|
|
1385
|
-
tier: r.tier,
|
|
1386
|
-
type,
|
|
1387
|
-
score: r.score,
|
|
1388
|
-
weightedScore: r.score * weight,
|
|
1389
|
-
};
|
|
1390
|
-
})
|
|
1391
|
-
.sort((a, b) => b.weightedScore - a.weightedScore);
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
// ============================================================================
|
|
1395
|
-
// Hook helpers
|
|
1396
|
-
// ============================================================================
|
|
1397
|
-
|
|
1398
|
-
/**
|
|
1399
|
-
* Resolve per-agent workspace and agentId from hook context.
|
|
1400
|
-
* Fallback chain: ctx.workspaceDir → config.workspace → cwd
|
|
1401
|
-
* Agent chain: ctx.agentId → PALAIA_AGENT env var → undefined
|
|
1402
|
-
*/
|
|
1403
|
-
export function resolvePerAgentContext(ctx: any, config: PalaiaPluginConfig) {
|
|
1404
|
-
return {
|
|
1405
|
-
workspace: ctx?.workspaceDir || config.workspace,
|
|
1406
|
-
agentId: ctx?.agentId || process.env.PALAIA_AGENT || undefined,
|
|
1407
|
-
};
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
function buildRunnerOpts(config: PalaiaPluginConfig, overrides?: { workspace?: string }): RunnerOpts {
|
|
1411
|
-
return {
|
|
1412
|
-
binaryPath: config.binaryPath,
|
|
1413
|
-
workspace: overrides?.workspace || config.workspace,
|
|
1414
|
-
timeoutMs: config.timeoutMs,
|
|
1415
|
-
};
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
// ============================================================================
|
|
1419
|
-
// /palaia status command — Format helpers
|
|
1420
|
-
// ============================================================================
|
|
1421
|
-
|
|
1422
|
-
function formatStatusResponse(
|
|
1423
|
-
state: PluginState,
|
|
1424
|
-
stats: Record<string, unknown>,
|
|
1425
|
-
config: PalaiaPluginConfig,
|
|
1426
|
-
): string {
|
|
1427
|
-
const lines: string[] = ["Palaia Memory Status", ""];
|
|
1428
|
-
|
|
1429
|
-
// Recall count
|
|
1430
|
-
const sinceDate = state.firstRecallTimestamp
|
|
1431
|
-
? formatShortDate(state.firstRecallTimestamp)
|
|
1432
|
-
: "n/a";
|
|
1433
|
-
lines.push(`Recalls: ${state.successfulRecalls} successful (since ${sinceDate})`);
|
|
1434
|
-
|
|
1435
|
-
// Store stats from palaia status --json
|
|
1436
|
-
const totalEntries = stats.total_entries ?? stats.totalEntries ?? "?";
|
|
1437
|
-
const hotEntries = stats.hot ?? stats.hotEntries ?? "?";
|
|
1438
|
-
const warmEntries = stats.warm ?? stats.warmEntries ?? "?";
|
|
1439
|
-
lines.push(`Store: ${totalEntries} entries (${hotEntries} hot, ${warmEntries} warm)`);
|
|
1440
|
-
|
|
1441
|
-
// Recall indicator
|
|
1442
|
-
lines.push(`Recall indicator: ${config.showMemorySources ? "ON" : "OFF"}`);
|
|
1443
|
-
|
|
1444
|
-
// Config summary
|
|
1445
|
-
lines.push(`Config: autoCapture=${config.autoCapture}, captureScope=${config.captureScope || "team"}`);
|
|
1446
|
-
|
|
1447
|
-
return lines.join("\n");
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
// ============================================================================
|
|
1451
|
-
// Legacy exports kept for tests
|
|
1452
|
-
// ============================================================================
|
|
1453
|
-
|
|
1454
|
-
/** Reset all turn state, inbound message store, and cached tokens (for testing and cleanup). */
|
|
1455
|
-
export function resetTurnState(): void {
|
|
1456
|
-
turnStateBySession.clear();
|
|
1457
|
-
lastInboundMessageByChannel.clear();
|
|
1458
|
-
resetSlackTokenCache();
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
// ============================================================================
|
|
1462
|
-
// Hook registration
|
|
1463
|
-
// ============================================================================
|
|
1464
|
-
|
|
1465
|
-
/**
|
|
1466
|
-
* Register lifecycle hooks on the plugin API.
|
|
1467
|
-
*/
|
|
1468
|
-
export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
1469
|
-
// Store api.logger for module-wide use (integrates into OpenClaw log system)
|
|
1470
|
-
if (api.logger && typeof api.logger.info === "function") {
|
|
1471
|
-
logger = api.logger;
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
const opts = buildRunnerOpts(config);
|
|
1475
|
-
|
|
1476
|
-
// ── Startup checks (H-2, H-3, captureModel validation) ────────
|
|
1477
|
-
(async () => {
|
|
1478
|
-
// H-2: Warn if no agent is configured
|
|
1479
|
-
if (!process.env.PALAIA_AGENT) {
|
|
1480
|
-
try {
|
|
1481
|
-
const statusOut = await run(["config", "get", "agent"], { ...opts, timeoutMs: 3000 });
|
|
1482
|
-
if (!statusOut.trim()) {
|
|
1483
|
-
logger.warn(
|
|
1484
|
-
"[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
|
|
1485
|
-
"Auto-captured entries will have no agent attribution."
|
|
1486
|
-
);
|
|
1487
|
-
}
|
|
1488
|
-
} catch {
|
|
1489
|
-
logger.warn(
|
|
1490
|
-
"[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
|
|
1491
|
-
"Auto-captured entries will have no agent attribution."
|
|
1492
|
-
);
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
// H-3: Warn if no embedding provider beyond BM25
|
|
1497
|
-
try {
|
|
1498
|
-
const statusJson = await run(["status", "--json"], { ...opts, timeoutMs: 5000 });
|
|
1499
|
-
if (statusJson && statusJson.trim()) {
|
|
1500
|
-
const status = JSON.parse(statusJson);
|
|
1501
|
-
// embedding_chain can be at top level OR nested under config
|
|
1502
|
-
const chain = status.embedding_chain
|
|
1503
|
-
|| status.embeddingChain
|
|
1504
|
-
|| status.config?.embedding_chain
|
|
1505
|
-
|| status.config?.embeddingChain
|
|
1506
|
-
|| [];
|
|
1507
|
-
const hasSemanticProvider = Array.isArray(chain)
|
|
1508
|
-
? chain.some((p: string) => p !== "bm25")
|
|
1509
|
-
: false;
|
|
1510
|
-
// Also check embedding_provider as a fallback signal
|
|
1511
|
-
const hasProviderConfig = !!(
|
|
1512
|
-
status.embedding_provider
|
|
1513
|
-
|| status.config?.embedding_provider
|
|
1514
|
-
);
|
|
1515
|
-
if (!hasSemanticProvider && !hasProviderConfig) {
|
|
1516
|
-
logger.warn(
|
|
1517
|
-
"[palaia] No embedding provider configured. Semantic search is inactive (BM25 keyword-only). " +
|
|
1518
|
-
"Run 'pip install palaia[fastembed]' and 'palaia doctor --fix' for better recall quality."
|
|
1519
|
-
);
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1522
|
-
// If statusJson is empty/null, skip warning (CLI may not be available)
|
|
1523
|
-
} catch {
|
|
1524
|
-
// Non-fatal — status check failed, skip warning (avoid false positive)
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
// Validate captureModel auth at plugin startup via modelAuth API
|
|
1528
|
-
if (config.captureModel && api.runtime?.modelAuth) {
|
|
1529
|
-
try {
|
|
1530
|
-
const resolved = resolveCaptureModel(api.config, config.captureModel);
|
|
1531
|
-
if (resolved?.provider) {
|
|
1532
|
-
const key = await api.runtime.modelAuth.resolveApiKeyForProvider({ provider: resolved.provider, cfg: api.config });
|
|
1533
|
-
if (!key) {
|
|
1534
|
-
logger.warn(`[palaia] captureModel provider "${resolved.provider}" has no API key — auto-capture LLM extraction will fail`);
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
} catch { /* non-fatal */ }
|
|
1538
|
-
}
|
|
1539
|
-
})();
|
|
1540
|
-
|
|
1541
|
-
// ── /palaia status command ─────────────────────────────────────
|
|
1542
|
-
api.registerCommand({
|
|
1543
|
-
name: "palaia-status",
|
|
1544
|
-
description: "Show Palaia memory status",
|
|
1545
|
-
async handler(_args: string) {
|
|
1546
|
-
try {
|
|
1547
|
-
const state = await loadPluginState(config.workspace);
|
|
1548
|
-
|
|
1549
|
-
let stats: Record<string, unknown> = {};
|
|
1550
|
-
try {
|
|
1551
|
-
const statsOutput = await run(["status", "--json"], opts);
|
|
1552
|
-
stats = JSON.parse(statsOutput || "{}");
|
|
1553
|
-
} catch {
|
|
1554
|
-
// Non-fatal
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
return { text: formatStatusResponse(state, stats, config) };
|
|
1558
|
-
} catch (error) {
|
|
1559
|
-
return { text: `Palaia status error: ${error}` };
|
|
1560
|
-
}
|
|
1561
|
-
},
|
|
1562
|
-
});
|
|
1563
|
-
|
|
1564
|
-
// ── message_received (capture inbound message ID for reactions) ─
|
|
1565
|
-
api.on("message_received", (event: any, ctx: any) => {
|
|
1566
|
-
try {
|
|
1567
|
-
const messageId = event?.metadata?.messageId;
|
|
1568
|
-
const provider = event?.metadata?.provider;
|
|
1569
|
-
|
|
1570
|
-
// ctx.channelId returns the provider name ("slack"), NOT the actual channel ID.
|
|
1571
|
-
// ctx.sessionKey is null during message_received.
|
|
1572
|
-
// Extract the real channel ID from event.metadata.to / ctx.conversationId.
|
|
1573
|
-
const channelId = extractChannelIdFromEvent(event, ctx)
|
|
1574
|
-
?? (resolveSessionKeyFromCtx(ctx) ? extractSlackChannelIdFromSessionKey(resolveSessionKeyFromCtx(ctx)!) : undefined);
|
|
1575
|
-
const sessionKey = resolveSessionKeyFromCtx(ctx);
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
if (messageId && channelId && provider && REACTION_SUPPORTED_PROVIDERS.has(provider)) {
|
|
1579
|
-
// Normalize channelId to UPPERCASE for consistent lookups
|
|
1580
|
-
// (extractSlackChannelIdFromSessionKey returns uppercase)
|
|
1581
|
-
const normalizedChannelId = String(channelId).toUpperCase();
|
|
1582
|
-
lastInboundMessageByChannel.set(normalizedChannelId, {
|
|
1583
|
-
messageId: String(messageId),
|
|
1584
|
-
provider,
|
|
1585
|
-
timestamp: Date.now(),
|
|
1586
|
-
});
|
|
1587
|
-
|
|
1588
|
-
// Also populate turnState if sessionKey is available
|
|
1589
|
-
if (sessionKey) {
|
|
1590
|
-
const turnState = getOrCreateTurnState(sessionKey);
|
|
1591
|
-
turnState.lastInboundMessageId = String(messageId);
|
|
1592
|
-
turnState.lastInboundChannelId = normalizedChannelId;
|
|
1593
|
-
turnState.channelProvider = provider;
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
} catch {
|
|
1597
|
-
// Non-fatal — never block message flow
|
|
1598
|
-
}
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
// ── before_prompt_build (Issue #65: Query-based Recall) ────────
|
|
1602
|
-
if (config.memoryInject) {
|
|
1603
|
-
api.on("before_prompt_build", async (event: any, ctx: any) => {
|
|
1604
|
-
// Prune stale entries to prevent memory leaks from crashed sessions (C-2)
|
|
1605
|
-
pruneStaleEntries();
|
|
1606
|
-
|
|
1607
|
-
// Per-agent workspace resolution (Issue #111)
|
|
1608
|
-
const resolved = resolvePerAgentContext(ctx, config);
|
|
1609
|
-
const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
|
|
1610
|
-
|
|
1611
|
-
try {
|
|
1612
|
-
const maxChars = config.maxInjectedChars || 4000;
|
|
1613
|
-
const limit = Math.min(config.maxResults || 10, 20);
|
|
1614
|
-
let entries: QueryResult["results"] = [];
|
|
1615
|
-
|
|
1616
|
-
if (config.recallMode === "query") {
|
|
1617
|
-
const userMessage = event.messages
|
|
1618
|
-
? buildRecallQuery(event.messages)
|
|
1619
|
-
: (event.prompt || null);
|
|
1620
|
-
|
|
1621
|
-
if (userMessage && userMessage.length >= 5) {
|
|
1622
|
-
// Try embed server first (fast path: ~0.5s), then CLI fallback (~3-14s)
|
|
1623
|
-
let serverQueried = false;
|
|
1624
|
-
if (config.embeddingServer) {
|
|
1625
|
-
try {
|
|
1626
|
-
const mgr = getEmbedServerManager(hookOpts);
|
|
1627
|
-
// If embed server workspace differs from resolved workspace, skip server and use CLI
|
|
1628
|
-
const serverWorkspace = hookOpts.workspace;
|
|
1629
|
-
const embedOpts = buildRunnerOpts(config);
|
|
1630
|
-
if (serverWorkspace !== embedOpts.workspace) {
|
|
1631
|
-
logger.info(`[palaia] Embed server workspace mismatch (agent=${resolved.workspace}), falling back to CLI`);
|
|
1632
|
-
} else {
|
|
1633
|
-
const resp = await mgr.query({
|
|
1634
|
-
text: userMessage,
|
|
1635
|
-
top_k: limit,
|
|
1636
|
-
include_cold: config.tier === "all",
|
|
1637
|
-
}, config.timeoutMs || 3000);
|
|
1638
|
-
if (resp?.result?.results && Array.isArray(resp.result.results)) {
|
|
1639
|
-
entries = resp.result.results;
|
|
1640
|
-
serverQueried = true;
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
} catch (serverError) {
|
|
1644
|
-
logger.warn(`[palaia] Embed server query failed, falling back to CLI: ${serverError}`);
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
// CLI fallback
|
|
1649
|
-
if (!serverQueried) {
|
|
1650
|
-
try {
|
|
1651
|
-
const queryArgs: string[] = ["query", userMessage, "--limit", String(limit)];
|
|
1652
|
-
if (config.tier === "all") {
|
|
1653
|
-
queryArgs.push("--all");
|
|
1654
|
-
}
|
|
1655
|
-
const result = await runJson<QueryResult>(queryArgs, { ...hookOpts, timeoutMs: 15000 });
|
|
1656
|
-
if (result && Array.isArray(result.results)) {
|
|
1657
|
-
entries = result.results;
|
|
1658
|
-
}
|
|
1659
|
-
} catch (queryError) {
|
|
1660
|
-
logger.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
// Fallback: list mode (no emoji — list-based recall is not query-relevant)
|
|
1667
|
-
let isListFallback = false;
|
|
1668
|
-
if (entries.length === 0) {
|
|
1669
|
-
isListFallback = true;
|
|
1670
|
-
try {
|
|
1671
|
-
const listArgs: string[] = ["list"];
|
|
1672
|
-
if (config.tier === "all") {
|
|
1673
|
-
listArgs.push("--all");
|
|
1674
|
-
} else {
|
|
1675
|
-
listArgs.push("--tier", config.tier || "hot");
|
|
1676
|
-
}
|
|
1677
|
-
const result = await runJson<QueryResult>(listArgs, hookOpts);
|
|
1678
|
-
if (result && Array.isArray(result.results)) {
|
|
1679
|
-
entries = result.results;
|
|
1680
|
-
}
|
|
1681
|
-
} catch {
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
if (entries.length === 0) return;
|
|
1687
|
-
|
|
1688
|
-
// Apply type-weighted reranking
|
|
1689
|
-
const ranked = rerankByTypeWeight(entries, config.recallTypeWeight);
|
|
1690
|
-
|
|
1691
|
-
// Build context string with char budget (compact format for token efficiency)
|
|
1692
|
-
const SCOPE_SHORT: Record<string, string> = { team: "t", private: "p", public: "pub" };
|
|
1693
|
-
const TYPE_SHORT: Record<string, string> = { memory: "m", process: "pr", task: "tk" };
|
|
1694
|
-
|
|
1695
|
-
let text = "## Active Memory (Palaia)\n\n";
|
|
1696
|
-
let chars = text.length;
|
|
1697
|
-
|
|
1698
|
-
for (const entry of ranked) {
|
|
1699
|
-
const scopeKey = SCOPE_SHORT[entry.scope] || entry.scope;
|
|
1700
|
-
const typeKey = TYPE_SHORT[entry.type] || entry.type;
|
|
1701
|
-
const prefix = `[${scopeKey}/${typeKey}]`;
|
|
1702
|
-
|
|
1703
|
-
// If body starts with title (common), skip title to save tokens
|
|
1704
|
-
let line: string;
|
|
1705
|
-
if (entry.body.toLowerCase().startsWith(entry.title.toLowerCase())) {
|
|
1706
|
-
line = `${prefix} ${entry.body}\n\n`;
|
|
1707
|
-
} else {
|
|
1708
|
-
line = `${prefix} ${entry.title}\n${entry.body}\n\n`;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
if (chars + line.length > maxChars) break;
|
|
1712
|
-
text += line;
|
|
1713
|
-
chars += line.length;
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
// Persistent usage nudge — compact guidance for the agent
|
|
1717
|
-
const USAGE_NUDGE = "[palaia] auto-capture=on. Manual write: --type process (SOPs/checklists) or --type task (todos with assignee/deadline) only. Conversation knowledge is auto-captured — do not duplicate with manual writes.";
|
|
1718
|
-
text += USAGE_NUDGE + "\n\n";
|
|
1719
|
-
|
|
1720
|
-
// Update recall counter for satisfaction/transparency nudges (Issue #87)
|
|
1721
|
-
let nudgeContext = "";
|
|
1722
|
-
try {
|
|
1723
|
-
const pluginState = await loadPluginState(resolved.workspace);
|
|
1724
|
-
pluginState.successfulRecalls++;
|
|
1725
|
-
if (!pluginState.firstRecallTimestamp) {
|
|
1726
|
-
pluginState.firstRecallTimestamp = new Date().toISOString();
|
|
1727
|
-
}
|
|
1728
|
-
const { nudges } = checkNudges(pluginState);
|
|
1729
|
-
if (nudges.length > 0) {
|
|
1730
|
-
nudgeContext = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
|
|
1731
|
-
}
|
|
1732
|
-
await savePluginState(pluginState, resolved.workspace);
|
|
1733
|
-
} catch {
|
|
1734
|
-
// Non-fatal
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
// Track recall in session-isolated turn state for emoji reactions
|
|
1738
|
-
// Only flag recall as meaningful if at least one result scores above threshold
|
|
1739
|
-
// List-fallback never triggers brain emoji (not query-relevant)
|
|
1740
|
-
const hasRelevantRecall = !isListFallback && entries.some(
|
|
1741
|
-
(e) => typeof e.score === "number" && e.score >= config.recallMinScore,
|
|
1742
|
-
);
|
|
1743
|
-
const sessionKey = resolveSessionKeyFromCtx(ctx);
|
|
1744
|
-
if (sessionKey && hasRelevantRecall) {
|
|
1745
|
-
const turnState = getOrCreateTurnState(sessionKey);
|
|
1746
|
-
turnState.recallOccurred = true;
|
|
1747
|
-
|
|
1748
|
-
// Populate channel info — prefer event metadata, fall back to sessionKey
|
|
1749
|
-
const provider = extractChannelFromSessionKey(sessionKey);
|
|
1750
|
-
if (provider) turnState.channelProvider = provider;
|
|
1751
|
-
const slackChannel = extractChannelIdFromEvent(event, ctx)
|
|
1752
|
-
?? extractSlackChannelIdFromSessionKey(sessionKey);
|
|
1753
|
-
if (slackChannel) turnState.lastInboundChannelId = slackChannel;
|
|
1754
|
-
|
|
1755
|
-
// Try to get the inbound message ID from the message_received store
|
|
1756
|
-
if (slackChannel) {
|
|
1757
|
-
const inbound = lastInboundMessageByChannel.get(slackChannel);
|
|
1758
|
-
if (inbound && (Date.now() - inbound.timestamp) < 30_000) {
|
|
1759
|
-
turnState.lastInboundMessageId = inbound.messageId;
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
// Return prependContext + appendSystemContext for recall emoji
|
|
1765
|
-
return {
|
|
1766
|
-
prependContext: text + nudgeContext,
|
|
1767
|
-
appendSystemContext: config.showMemorySources
|
|
1768
|
-
? "You used Palaia memory in this turn. Add 🧠 at the very end of your response (after everything else, on its own line)."
|
|
1769
|
-
: undefined,
|
|
1770
|
-
};
|
|
1771
|
-
} catch (error) {
|
|
1772
|
-
logger.warn(`[palaia] Memory injection failed: ${error}`);
|
|
1773
|
-
}
|
|
1774
|
-
});
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
// ── message_sending (Issue #81: Hint stripping) ──────────────────
|
|
1778
|
-
api.on("message_sending", (_event: any, _ctx: any) => {
|
|
1779
|
-
const content = _event?.content;
|
|
1780
|
-
if (typeof content !== "string") return;
|
|
1781
|
-
|
|
1782
|
-
const { hints, cleanedText } = parsePalaiaHints(content);
|
|
1783
|
-
if (hints.length > 0) {
|
|
1784
|
-
return { content: cleanedText };
|
|
1785
|
-
}
|
|
1786
|
-
});
|
|
1787
|
-
|
|
1788
|
-
// ── agent_end (Issue #64 + #81: Auto-Capture with Metadata + Reactions) ───
|
|
1789
|
-
if (config.autoCapture) {
|
|
1790
|
-
api.on("agent_end", async (event: any, ctx: any) => {
|
|
1791
|
-
// Resolve session key for turn state
|
|
1792
|
-
const sessionKey = resolveSessionKeyFromCtx(ctx);
|
|
1793
|
-
|
|
1794
|
-
// Per-agent workspace resolution (Issue #111)
|
|
1795
|
-
const resolved = resolvePerAgentContext(ctx, config);
|
|
1796
|
-
const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
|
|
1797
|
-
|
|
1798
|
-
if (!event.success || !event.messages || event.messages.length === 0) {
|
|
1799
|
-
return;
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
try {
|
|
1803
|
-
const agentName = resolved.agentId;
|
|
1804
|
-
|
|
1805
|
-
const allTexts = extractMessageTexts(event.messages);
|
|
1806
|
-
|
|
1807
|
-
const userTurns = allTexts.filter((t) => t.role === "user").length;
|
|
1808
|
-
if (userTurns < config.captureMinTurns) {
|
|
1809
|
-
return;
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
// Parse capture hints from all messages (Issue #81)
|
|
1813
|
-
const collectedHints: PalaiaHint[] = [];
|
|
1814
|
-
for (const t of allTexts) {
|
|
1815
|
-
const { hints } = parsePalaiaHints(t.text);
|
|
1816
|
-
collectedHints.push(...hints);
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
// Strip Palaia-injected recall context from user messages to prevent feedback loop.
|
|
1820
|
-
// The recall block is prepended to user messages by before_prompt_build.
|
|
1821
|
-
// Without stripping, auto-capture would re-capture previously recalled memories.
|
|
1822
|
-
const cleanedTexts = allTexts.map(t =>
|
|
1823
|
-
t.role === "user"
|
|
1824
|
-
? { ...t, text: stripPalaiaInjectedContext(t.text) }
|
|
1825
|
-
: t
|
|
1826
|
-
);
|
|
1827
|
-
|
|
1828
|
-
// Only extract from recent exchanges — full history causes LLM timeouts
|
|
1829
|
-
// and dilutes extraction quality
|
|
1830
|
-
const recentTexts = trimToRecentExchanges(cleanedTexts);
|
|
1831
|
-
|
|
1832
|
-
// Build exchange text from recent window only
|
|
1833
|
-
const exchangeParts: string[] = [];
|
|
1834
|
-
for (const t of recentTexts) {
|
|
1835
|
-
const { cleanedText } = parsePalaiaHints(t.text);
|
|
1836
|
-
exchangeParts.push(`[${t.role}]: ${cleanedText}`);
|
|
1837
|
-
}
|
|
1838
|
-
const exchangeText = exchangeParts.join("\n");
|
|
1839
|
-
|
|
1840
|
-
if (!shouldAttemptCapture(exchangeText)) {
|
|
1841
|
-
return;
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
const knownProjects = await loadProjects(hookOpts);
|
|
1845
|
-
|
|
1846
|
-
// Helper: build CLI args with metadata
|
|
1847
|
-
const buildWriteArgs = (
|
|
1848
|
-
content: string,
|
|
1849
|
-
type: string,
|
|
1850
|
-
tags: string[],
|
|
1851
|
-
itemProject?: string | null,
|
|
1852
|
-
itemScope?: string | null,
|
|
1853
|
-
): string[] => {
|
|
1854
|
-
const args: string[] = [
|
|
1855
|
-
"write",
|
|
1856
|
-
content,
|
|
1857
|
-
"--type", type,
|
|
1858
|
-
"--tags", tags.join(",") || "auto-capture",
|
|
1859
|
-
];
|
|
1860
|
-
|
|
1861
|
-
// Scope guardrail: config.captureScope overrides everything; otherwise max team (no public)
|
|
1862
|
-
const scope = config.captureScope
|
|
1863
|
-
? sanitizeScope(config.captureScope, "team", true)
|
|
1864
|
-
: sanitizeScope(itemScope, "team", false);
|
|
1865
|
-
args.push("--scope", scope);
|
|
1866
|
-
|
|
1867
|
-
const project = config.captureProject || itemProject;
|
|
1868
|
-
if (project) {
|
|
1869
|
-
args.push("--project", project);
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
if (agentName) {
|
|
1873
|
-
args.push("--agent", agentName);
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
return args;
|
|
1877
|
-
};
|
|
1878
|
-
|
|
1879
|
-
// Helper: store LLM extraction results
|
|
1880
|
-
const storeLLMResults = async (results: ExtractionResult[]) => {
|
|
1881
|
-
for (const r of results) {
|
|
1882
|
-
if (r.significance >= config.captureMinSignificance) {
|
|
1883
|
-
const hintForProject = collectedHints.find((h) => h.project);
|
|
1884
|
-
const hintForScope = collectedHints.find((h) => h.scope);
|
|
1885
|
-
|
|
1886
|
-
const effectiveProject = hintForProject?.project || r.project;
|
|
1887
|
-
const effectiveScope = hintForScope?.scope || r.scope;
|
|
1888
|
-
|
|
1889
|
-
// Project validation: reject unknown projects
|
|
1890
|
-
let validatedProject = effectiveProject;
|
|
1891
|
-
if (validatedProject && knownProjects.length > 0) {
|
|
1892
|
-
const isKnown = knownProjects.some(
|
|
1893
|
-
(p) => p.name.toLowerCase() === validatedProject!.toLowerCase(),
|
|
1894
|
-
);
|
|
1895
|
-
if (!isKnown) {
|
|
1896
|
-
logger.info(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
|
|
1897
|
-
validatedProject = null;
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
// Always include auto-capture tag for GC identification
|
|
1902
|
-
const tags = [...r.tags];
|
|
1903
|
-
if (!tags.includes("auto-capture")) tags.push("auto-capture");
|
|
1904
|
-
|
|
1905
|
-
const args = buildWriteArgs(
|
|
1906
|
-
r.content,
|
|
1907
|
-
r.type,
|
|
1908
|
-
tags,
|
|
1909
|
-
validatedProject,
|
|
1910
|
-
effectiveScope,
|
|
1911
|
-
);
|
|
1912
|
-
await run(args, { ...hookOpts, timeoutMs: 10_000 });
|
|
1913
|
-
logger.info(
|
|
1914
|
-
`[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
|
|
1915
|
-
);
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
};
|
|
1919
|
-
|
|
1920
|
-
// LLM-based extraction (primary)
|
|
1921
|
-
let llmHandled = false;
|
|
1922
|
-
try {
|
|
1923
|
-
const results = await extractWithLLM(event.messages, api.config, {
|
|
1924
|
-
captureModel: config.captureModel,
|
|
1925
|
-
}, knownProjects);
|
|
1926
|
-
|
|
1927
|
-
await storeLLMResults(results);
|
|
1928
|
-
llmHandled = true;
|
|
1929
|
-
} catch (llmError) {
|
|
1930
|
-
// Check if this is a model-availability error (not a generic import failure)
|
|
1931
|
-
const errStr = String(llmError);
|
|
1932
|
-
const isModelError = /FailoverError|Unknown model|unknown model|401|403|model.*not found|not_found|model_not_found/i.test(errStr);
|
|
1933
|
-
|
|
1934
|
-
if (isModelError && config.captureModel) {
|
|
1935
|
-
// captureModel is broken — try primary model as fallback
|
|
1936
|
-
if (!_captureModelFailoverWarned) {
|
|
1937
|
-
_captureModelFailoverWarned = true;
|
|
1938
|
-
logger.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
|
|
1939
|
-
}
|
|
1940
|
-
try {
|
|
1941
|
-
// Retry without captureModel → resolveCaptureModel will use primary model
|
|
1942
|
-
const fallbackResults = await extractWithLLM(event.messages, api.config, {
|
|
1943
|
-
captureModel: undefined,
|
|
1944
|
-
}, knownProjects);
|
|
1945
|
-
await storeLLMResults(fallbackResults);
|
|
1946
|
-
llmHandled = true;
|
|
1947
|
-
} catch (fallbackError) {
|
|
1948
|
-
if (!_llmImportFailureLogged) {
|
|
1949
|
-
logger.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
|
|
1950
|
-
_llmImportFailureLogged = true;
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
} else {
|
|
1954
|
-
if (!_llmImportFailureLogged) {
|
|
1955
|
-
logger.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
|
|
1956
|
-
_llmImportFailureLogged = true;
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
// Rule-based fallback (max 1 per turn)
|
|
1962
|
-
if (!llmHandled) {
|
|
1963
|
-
let captureData: { tags: string[]; type: string; summary: string } | null = null;
|
|
1964
|
-
|
|
1965
|
-
if (config.captureFrequency === "significant") {
|
|
1966
|
-
const significance = extractSignificance(exchangeText);
|
|
1967
|
-
if (!significance) {
|
|
1968
|
-
return;
|
|
1969
|
-
}
|
|
1970
|
-
captureData = significance;
|
|
1971
|
-
} else {
|
|
1972
|
-
const summary = exchangeParts
|
|
1973
|
-
.slice(-4)
|
|
1974
|
-
.map((p) => p.slice(0, 200))
|
|
1975
|
-
.join(" | ")
|
|
1976
|
-
.slice(0, 500);
|
|
1977
|
-
captureData = { tags: ["auto-capture"], type: "memory", summary };
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
// Always include auto-capture tag for GC identification
|
|
1981
|
-
if (!captureData.tags.includes("auto-capture")) {
|
|
1982
|
-
captureData.tags.push("auto-capture");
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
const hintForProject = collectedHints.find((h) => h.project);
|
|
1986
|
-
const hintForScope = collectedHints.find((h) => h.scope);
|
|
1987
|
-
|
|
1988
|
-
const args = buildWriteArgs(
|
|
1989
|
-
captureData.summary,
|
|
1990
|
-
captureData.type,
|
|
1991
|
-
captureData.tags,
|
|
1992
|
-
hintForProject?.project,
|
|
1993
|
-
hintForScope?.scope,
|
|
1994
|
-
);
|
|
1995
|
-
|
|
1996
|
-
await run(args, { ...hookOpts, timeoutMs: 10_000 });
|
|
1997
|
-
logger.info(
|
|
1998
|
-
`[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
|
|
1999
|
-
);
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
// Mark that capture occurred in this turn
|
|
2003
|
-
if (sessionKey) {
|
|
2004
|
-
const turnState = getOrCreateTurnState(sessionKey);
|
|
2005
|
-
turnState.capturedInThisTurn = true;
|
|
2006
|
-
} else {
|
|
2007
|
-
}
|
|
2008
|
-
} catch (error) {
|
|
2009
|
-
logger.warn(`[palaia] Auto-capture failed: ${error}`);
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
// ── Emoji Reactions (Issue #87) ──────────────────────────
|
|
2013
|
-
// Send reactions AFTER capture completes, using turn state.
|
|
2014
|
-
if (sessionKey) {
|
|
2015
|
-
try {
|
|
2016
|
-
const turnState = turnStateBySession.get(sessionKey);
|
|
2017
|
-
if (turnState) {
|
|
2018
|
-
const provider = turnState.channelProvider
|
|
2019
|
-
|| extractChannelFromSessionKey(sessionKey)
|
|
2020
|
-
|| (ctx?.channelId as string | undefined);
|
|
2021
|
-
const channelId = turnState.lastInboundChannelId
|
|
2022
|
-
|| extractChannelIdFromEvent(event, ctx)
|
|
2023
|
-
|| extractSlackChannelIdFromSessionKey(sessionKey);
|
|
2024
|
-
const messageId = turnState.lastInboundMessageId;
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
|
|
2028
|
-
// Capture confirmation: 💾
|
|
2029
|
-
if (turnState.capturedInThisTurn && config.showCaptureConfirm) {
|
|
2030
|
-
await sendReaction(channelId, messageId, "floppy_disk", provider);
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
// Recall indicator: 🧠
|
|
2034
|
-
if (turnState.recallOccurred && config.showMemorySources) {
|
|
2035
|
-
await sendReaction(channelId, messageId, "brain", provider);
|
|
2036
|
-
}
|
|
2037
|
-
} else {
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
} catch (reactionError) {
|
|
2041
|
-
logger.warn(`[palaia] Reaction sending failed: ${reactionError}`);
|
|
2042
|
-
} finally {
|
|
2043
|
-
// Always clean up turn state
|
|
2044
|
-
deleteTurnState(sessionKey);
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
});
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
// ── agent_end: Recall-only reactions (when autoCapture is off) ─
|
|
2051
|
-
if (!config.autoCapture && config.showMemorySources) {
|
|
2052
|
-
api.on("agent_end", async (_event: any, ctx: any) => {
|
|
2053
|
-
const sessionKey = resolveSessionKeyFromCtx(ctx);
|
|
2054
|
-
if (!sessionKey) return;
|
|
2055
|
-
|
|
2056
|
-
try {
|
|
2057
|
-
const turnState = turnStateBySession.get(sessionKey);
|
|
2058
|
-
if (turnState?.recallOccurred) {
|
|
2059
|
-
const provider = turnState.channelProvider
|
|
2060
|
-
|| extractChannelFromSessionKey(sessionKey);
|
|
2061
|
-
const channelId = turnState.lastInboundChannelId
|
|
2062
|
-
|| extractChannelIdFromEvent(_event, ctx)
|
|
2063
|
-
|| extractSlackChannelIdFromSessionKey(sessionKey);
|
|
2064
|
-
const messageId = turnState.lastInboundMessageId;
|
|
2065
|
-
|
|
2066
|
-
if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
|
|
2067
|
-
await sendReaction(channelId, messageId, "brain", provider);
|
|
2068
|
-
}
|
|
2069
|
-
}
|
|
2070
|
-
} catch (err) {
|
|
2071
|
-
logger.warn(`[palaia] Recall reaction failed: ${err}`);
|
|
2072
|
-
} finally {
|
|
2073
|
-
deleteTurnState(sessionKey);
|
|
2074
|
-
}
|
|
2075
|
-
});
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
// ── Startup Recovery Service ───────────────────────────────────
|
|
2079
|
-
api.registerService({
|
|
2080
|
-
id: "palaia-recovery",
|
|
2081
|
-
start: async () => {
|
|
2082
|
-
const result = await recover(opts);
|
|
2083
|
-
if (result.replayed > 0) {
|
|
2084
|
-
logger.info(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
|
|
2085
|
-
}
|
|
2086
|
-
if (result.errors > 0) {
|
|
2087
|
-
logger.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
|
|
2088
|
-
}
|
|
2089
|
-
},
|
|
2090
|
-
});
|
|
2091
|
-
}
|