@byte5ai/palaia 1.8.1 → 2.0.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/src/hooks.ts CHANGED
@@ -1,14 +1,569 @@
1
1
  /**
2
2
  * Lifecycle hooks for the Palaia OpenClaw plugin.
3
3
  *
4
- * - before_prompt_build: Injects HOT memory into agent context (opt-in).
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.
5
10
  * - palaia-recovery service: Replays WAL on startup.
11
+ * - /palaia command: Show memory status.
6
12
  */
7
13
 
8
- import { runJson, recover, type RunnerOpts } from "./runner.js";
9
- import type { PalaiaPluginConfig } from "./config.js";
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 } from "./runner.js";
18
+ import type { PalaiaPluginConfig, RecallTypeWeights } from "./config.js";
10
19
 
11
- /** Shape returned by `palaia query --json` */
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
+ // Scope Validation (Issue #90)
142
+ // ============================================================================
143
+
144
+ const VALID_SCOPES = ["private", "team", "public"];
145
+
146
+ /**
147
+ * Check if a scope string is valid for palaia write.
148
+ * Valid: "private", "team", "public", or any "shared:*" prefix.
149
+ */
150
+ export function isValidScope(s: string): boolean {
151
+ return VALID_SCOPES.includes(s) || s.startsWith("shared:");
152
+ }
153
+
154
+ /**
155
+ * Sanitize a scope value — returns the value if valid, otherwise fallback.
156
+ */
157
+ export function sanitizeScope(rawScope: string | null | undefined, fallback = "team"): string {
158
+ if (rawScope && isValidScope(rawScope)) return rawScope;
159
+ return fallback;
160
+ }
161
+
162
+ // ============================================================================
163
+ // Session Key Helpers
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Extract channel target from a session key.
168
+ * e.g. "agent:main:slack:channel:c0ake2g15hv" → "channel:C0AKE2G15HV"
169
+ */
170
+ export function extractTargetFromSessionKey(sessionKey: string): string | undefined {
171
+ const parts = sessionKey.split(":");
172
+ for (let i = 0; i < parts.length - 1; i++) {
173
+ if (parts[i] === "channel" || parts[i] === "dm" || parts[i] === "group") {
174
+ return `${parts[i]}:${parts[i + 1].toUpperCase()}`;
175
+ }
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ /**
181
+ * Extract channel provider from a session key.
182
+ * e.g. "agent:main:slack:channel:c0ake2g15hv" → "slack"
183
+ */
184
+ export function extractChannelFromSessionKey(sessionKey: string): string | undefined {
185
+ const parts = sessionKey.split(":");
186
+ if (parts.length >= 5 && parts[0] === "agent") {
187
+ return parts[2];
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ // ============================================================================
193
+ // Emoji Reaction Helpers (Issue #87: Reactions)
194
+ // ============================================================================
195
+
196
+ /**
197
+ * Extract the Slack channel ID from a session key.
198
+ * e.g. "agent:main:slack:channel:c0ake2g15hv" → "C0AKE2G15HV"
199
+ */
200
+ export function extractSlackChannelIdFromSessionKey(sessionKey: string): string | undefined {
201
+ const parts = sessionKey.split(":");
202
+ for (let i = 0; i < parts.length - 1; i++) {
203
+ if (parts[i] === "channel" || parts[i] === "dm") {
204
+ return parts[i + 1].toUpperCase();
205
+ }
206
+ }
207
+ return undefined;
208
+ }
209
+
210
+ /**
211
+ * Resolve the session key for the current turn from available ctx.
212
+ * Tries ctx.sessionKey first, then falls back to sessionId.
213
+ */
214
+ function resolveSessionKeyFromCtx(ctx: any): string | undefined {
215
+ const sk = ctx?.sessionKey?.trim?.();
216
+ if (sk) return sk;
217
+ const sid = ctx?.sessionId?.trim?.();
218
+ return sid || undefined;
219
+ }
220
+
221
+ /**
222
+ * Get or create turn state for a session.
223
+ */
224
+ export function getOrCreateTurnState(sessionKey: string): TurnState {
225
+ let state = turnStateBySession.get(sessionKey);
226
+ if (!state) {
227
+ state = createDefaultTurnState();
228
+ turnStateBySession.set(sessionKey, state);
229
+ }
230
+ return state;
231
+ }
232
+
233
+ /**
234
+ * Delete turn state for a session (cleanup after agent_end).
235
+ */
236
+ export function deleteTurnState(sessionKey: string): void {
237
+ turnStateBySession.delete(sessionKey);
238
+ }
239
+
240
+ /**
241
+ * Send an emoji reaction to a message via the Slack Web API (or Discord API).
242
+ * Only fires for supported channels (slack, discord). Silently no-ops for others.
243
+ *
244
+ * For Slack, calls reactions.add via the @slack/web-api client.
245
+ * Requires SLACK_BOT_TOKEN in the environment.
246
+ */
247
+ export async function sendReaction(
248
+ channelId: string,
249
+ messageId: string,
250
+ emoji: string,
251
+ provider: string,
252
+ ): Promise<void> {
253
+ if (!channelId || !messageId || !emoji) return;
254
+ if (!REACTION_SUPPORTED_PROVIDERS.has(provider)) return;
255
+
256
+ if (provider === "slack") {
257
+ await sendSlackReaction(channelId, messageId, emoji);
258
+ }
259
+ // Discord: future implementation
260
+ }
261
+
262
+ /** Cached Slack bot token resolved from env or OpenClaw config. */
263
+ let _cachedSlackToken: string | null | undefined;
264
+
265
+ /**
266
+ * Resolve the Slack bot token from environment or OpenClaw config file.
267
+ * Caches the result for the lifetime of the process.
268
+ *
269
+ * Resolution order:
270
+ * 1. SLACK_BOT_TOKEN env var (explicit override)
271
+ * 2. OpenClaw config: channels.slack.botToken (standard single-account)
272
+ * 3. OpenClaw config: channels.slack.accounts.default.botToken (multi-account)
273
+ *
274
+ * Config path: OPENCLAW_CONFIG env var → ~/.openclaw/openclaw.json
275
+ */
276
+ async function resolveSlackBotToken(): Promise<string | null> {
277
+ if (_cachedSlackToken !== undefined) return _cachedSlackToken;
278
+
279
+ // 1) Environment variable
280
+ const envToken = process.env.SLACK_BOT_TOKEN?.trim();
281
+ if (envToken) {
282
+ _cachedSlackToken = envToken;
283
+ return envToken;
284
+ }
285
+
286
+ // 2) OpenClaw config file — OPENCLAW_CONFIG takes precedence over default path
287
+ const configPaths = [
288
+ process.env.OPENCLAW_CONFIG || "",
289
+ path.join(os.homedir(), ".openclaw", "openclaw.json"),
290
+ ].filter(Boolean);
291
+
292
+ for (const configPath of configPaths) {
293
+ try {
294
+ const raw = await fs.readFile(configPath, "utf-8");
295
+ const config = JSON.parse(raw);
296
+
297
+ // 2a) Standard path: channels.slack.botToken
298
+ const directToken = config?.channels?.slack?.botToken?.trim();
299
+ if (directToken) {
300
+ _cachedSlackToken = directToken;
301
+ return directToken;
302
+ }
303
+
304
+ // 2b) Multi-account path: channels.slack.accounts.default.botToken
305
+ const accountToken = config?.channels?.slack?.accounts?.default?.botToken?.trim();
306
+ if (accountToken) {
307
+ _cachedSlackToken = accountToken;
308
+ return accountToken;
309
+ }
310
+ } catch {
311
+ // Try next path
312
+ }
313
+ }
314
+
315
+ _cachedSlackToken = null;
316
+ return null;
317
+ }
318
+
319
+ /** Reset cached token (for testing). */
320
+ export function resetSlackTokenCache(): void {
321
+ _cachedSlackToken = undefined;
322
+ }
323
+
324
+ async function sendSlackReaction(
325
+ channelId: string,
326
+ messageId: string,
327
+ emoji: string,
328
+ ): Promise<void> {
329
+ const token = await resolveSlackBotToken();
330
+ if (!token) {
331
+ console.warn("[palaia] Cannot send Slack reaction: no bot token found");
332
+ return;
333
+ }
334
+
335
+ const normalizedEmoji = emoji.replace(/^:/, "").replace(/:$/, "");
336
+
337
+ const controller = new AbortController();
338
+ const timeout = setTimeout(() => controller.abort(), 5000);
339
+
340
+ try {
341
+ const response = await fetch("https://slack.com/api/reactions.add", {
342
+ method: "POST",
343
+ headers: {
344
+ "Content-Type": "application/json; charset=utf-8",
345
+ Authorization: `Bearer ${token}`,
346
+ },
347
+ body: JSON.stringify({
348
+ channel: channelId,
349
+ timestamp: messageId,
350
+ name: normalizedEmoji,
351
+ }),
352
+ signal: controller.signal,
353
+ });
354
+ const data = await response.json() as { ok: boolean; error?: string };
355
+ if (!data.ok && data.error !== "already_reacted") {
356
+ console.warn(`[palaia] Slack reaction failed: ${data.error} (${normalizedEmoji} on ${channelId})`);
357
+ }
358
+ } catch (err) {
359
+ if ((err as Error).name !== "AbortError") {
360
+ console.warn(`[palaia] Slack reaction error (${normalizedEmoji}): ${err}`);
361
+ }
362
+ } finally {
363
+ clearTimeout(timeout);
364
+ }
365
+ }
366
+
367
+ // ============================================================================
368
+ // Footnote Helpers (Issue #87)
369
+ // ============================================================================
370
+
371
+ /**
372
+ * Format an ISO date string as a short date: "Mar 16", "Feb 10".
373
+ */
374
+ export function formatShortDate(isoDate: string): string {
375
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
376
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
377
+ try {
378
+ const d = new Date(isoDate);
379
+ if (isNaN(d.getTime())) return "";
380
+ return `${months[d.getMonth()]} ${d.getDate()}`;
381
+ } catch {
382
+ return "";
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Check if an injected entry is relevant to the response text.
388
+ * Simple keyword overlap: split title into words, check if >=2 words appear
389
+ * in the response (case-insensitive). Words shorter than 3 chars are skipped.
390
+ */
391
+ export function isEntryRelevant(title: string, responseText: string): boolean {
392
+ const responseLower = responseText.toLowerCase();
393
+ const titleWords = title
394
+ .toLowerCase()
395
+ .split(/[\s\-_/]+/)
396
+ .filter((w) => w.length >= 3);
397
+ if (titleWords.length === 0) return false;
398
+ const threshold = titleWords.length === 1 ? 1 : 2;
399
+ let matches = 0;
400
+ for (const word of titleWords) {
401
+ if (responseLower.includes(word)) {
402
+ matches++;
403
+ if (matches >= threshold) return true;
404
+ }
405
+ }
406
+ return false;
407
+ }
408
+
409
+ /**
410
+ * Build a memory source footnote string from injected entries and response text.
411
+ * Returns null if no relevant entries found.
412
+ */
413
+ export function buildFootnote(
414
+ entries: Array<{ title: string; date: string }>,
415
+ _responseText: string,
416
+ maxEntries = 3,
417
+ ): string | null {
418
+ if (entries.length === 0) return null;
419
+
420
+ const display = entries.slice(0, maxEntries);
421
+ const parts = display.map((e) => {
422
+ const dateStr = formatShortDate(e.date);
423
+ return dateStr ? `"${e.title}" (${dateStr})` : `"${e.title}"`;
424
+ });
425
+ return `\n\n📎 Palaia: ${parts.join(", ")}`;
426
+ }
427
+
428
+ // ============================================================================
429
+ // Satisfaction / Transparency Nudge Helpers (Issue #87)
430
+ // ============================================================================
431
+
432
+ const SATISFACTION_THRESHOLD = 10;
433
+ const TRANSPARENCY_RECALL_THRESHOLD = 50;
434
+ const TRANSPARENCY_DAYS_THRESHOLD = 7;
435
+
436
+ const SATISFACTION_NUDGE_TEXT =
437
+ "Your user has been using Palaia for a while now. " +
438
+ "Ask them casually if they're happy with the memory system. " +
439
+ "If there are issues, suggest `palaia doctor`.";
440
+
441
+ const TRANSPARENCY_NUDGE_TEXT =
442
+ "Your user has been seeing memory Footnotes and capture confirmations for several days. " +
443
+ "Ask them once: 'Would you like to keep seeing memory source references and capture " +
444
+ "confirmations, or should I hide them? You can change this anytime.' " +
445
+ "Based on their answer: `palaia config set showMemorySources true/false` and " +
446
+ "`palaia config set showCaptureConfirm true/false`";
447
+
448
+ /**
449
+ * Check which nudges (if any) should fire based on plugin state.
450
+ * Returns nudge texts to prepend, and updates state accordingly.
451
+ */
452
+ export function checkNudges(state: PluginState): { nudges: string[]; updated: boolean } {
453
+ const nudges: string[] = [];
454
+ let updated = false;
455
+
456
+ if (!state.satisfactionNudged && state.successfulRecalls >= SATISFACTION_THRESHOLD) {
457
+ nudges.push(SATISFACTION_NUDGE_TEXT);
458
+ state.satisfactionNudged = true;
459
+ updated = true;
460
+ }
461
+
462
+ if (!state.transparencyNudged && state.firstRecallTimestamp) {
463
+ const daysSinceFirst = (Date.now() - new Date(state.firstRecallTimestamp).getTime()) / (1000 * 60 * 60 * 24);
464
+ if (state.successfulRecalls >= TRANSPARENCY_RECALL_THRESHOLD || daysSinceFirst >= TRANSPARENCY_DAYS_THRESHOLD) {
465
+ nudges.push(TRANSPARENCY_NUDGE_TEXT);
466
+ state.transparencyNudged = true;
467
+ updated = true;
468
+ }
469
+ }
470
+
471
+ return { nudges, updated };
472
+ }
473
+
474
+ // ============================================================================
475
+ // Capture Hints (Issue #81)
476
+ // ============================================================================
477
+
478
+ /** Parsed palaia-hint tag attributes */
479
+ export interface PalaiaHint {
480
+ project?: string;
481
+ scope?: string;
482
+ type?: string;
483
+ tags?: string[];
484
+ }
485
+
486
+ /**
487
+ * Parse `<palaia-hint ... />` tags from text.
488
+ * Returns extracted hints and cleaned text with hints removed.
489
+ */
490
+ export function parsePalaiaHints(text: string): { hints: PalaiaHint[]; cleanedText: string } {
491
+ const hints: PalaiaHint[] = [];
492
+ const regex = /<palaia-hint\s+([^/]*)\s*\/>/gi;
493
+
494
+ let match: RegExpExecArray | null;
495
+ while ((match = regex.exec(text)) !== null) {
496
+ const attrs = match[1];
497
+ const hint: PalaiaHint = {};
498
+
499
+ const projectMatch = attrs.match(/project\s*=\s*"([^"]*)"/i);
500
+ if (projectMatch) hint.project = projectMatch[1];
501
+
502
+ const scopeMatch = attrs.match(/scope\s*=\s*"([^"]*)"/i);
503
+ if (scopeMatch) hint.scope = scopeMatch[1];
504
+
505
+ const typeMatch = attrs.match(/type\s*=\s*"([^"]*)"/i);
506
+ if (typeMatch) hint.type = typeMatch[1];
507
+
508
+ const tagsMatch = attrs.match(/tags\s*=\s*"([^"]*)"/i);
509
+ if (tagsMatch) hint.tags = tagsMatch[1].split(",").map((t) => t.trim()).filter(Boolean);
510
+
511
+ hints.push(hint);
512
+ }
513
+
514
+ const cleanedText = text.replace(/<palaia-hint\s+[^/]*\s*\/>/gi, "").trim();
515
+ return { hints, cleanedText };
516
+ }
517
+
518
+ // ============================================================================
519
+ // Project Cache (Issue #81)
520
+ // ============================================================================
521
+
522
+ interface CachedProject {
523
+ name: string;
524
+ description?: string;
525
+ }
526
+
527
+ let _cachedProjects: CachedProject[] | null = null;
528
+ let _projectCacheTime = 0;
529
+ const PROJECT_CACHE_TTL_MS = 60_000;
530
+
531
+ /** Reset project cache (for testing). */
532
+ export function resetProjectCache(): void {
533
+ _cachedProjects = null;
534
+ _projectCacheTime = 0;
535
+ }
536
+
537
+ /**
538
+ * Load known projects from CLI, with caching.
539
+ */
540
+ async function loadProjects(opts: import("./runner.js").RunnerOpts): Promise<CachedProject[]> {
541
+ const now = Date.now();
542
+ if (_cachedProjects && (now - _projectCacheTime) < PROJECT_CACHE_TTL_MS) {
543
+ return _cachedProjects;
544
+ }
545
+
546
+ try {
547
+ const result = await runJson<{ projects: Array<{ name: string; description?: string }> }>(
548
+ ["project", "list"],
549
+ opts,
550
+ );
551
+ _cachedProjects = (result.projects || []).map((p) => ({
552
+ name: p.name,
553
+ description: p.description,
554
+ }));
555
+ _projectCacheTime = now;
556
+ return _cachedProjects;
557
+ } catch {
558
+ return _cachedProjects || [];
559
+ }
560
+ }
561
+
562
+ // ============================================================================
563
+ // Types
564
+ // ============================================================================
565
+
566
+ /** Shape returned by `palaia query --json` or `palaia list --json` */
12
567
  interface QueryResult {
13
568
  results: Array<{
14
569
  id: string;
@@ -18,12 +573,504 @@ interface QueryResult {
18
573
  tier: string;
19
574
  scope: string;
20
575
  title?: string;
576
+ type?: string;
577
+ tags?: string[];
21
578
  }>;
22
579
  }
23
580
 
581
+ /** Message shape from OpenClaw event.messages */
582
+ interface Message {
583
+ role?: string;
584
+ content?: string | Array<{ type?: string; text?: string }>;
585
+ }
586
+
587
+ // ============================================================================
588
+ // LLM-based Extraction (Issue #64 upgrade)
589
+ // ============================================================================
590
+
591
+ /** Result from LLM-based knowledge extraction */
592
+ export interface ExtractionResult {
593
+ content: string;
594
+ type: "memory" | "process" | "task";
595
+ tags: string[];
596
+ significance: number;
597
+ project?: string | null;
598
+ scope?: string | null;
599
+ }
600
+
601
+ type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
602
+
603
+ let _embeddedPiAgentLoader: Promise<RunEmbeddedPiAgentFn> | null = null;
604
+ /** Whether the LLM import failure has already been logged (to avoid spam). */
605
+ let _llmImportFailureLogged = false;
606
+
24
607
  /**
25
- * Build RunnerOpts from plugin config.
608
+ * Resolve the path to OpenClaw's extensionAPI module.
609
+ * Uses multiple strategies for portability across installation layouts.
26
610
  */
611
+ function resolveExtensionAPIPath(): string | null {
612
+ // Strategy 1: require.resolve with openclaw package exports
613
+ try {
614
+ return require.resolve("openclaw/dist/extensionAPI.js");
615
+ } catch {
616
+ // Not resolvable via standard module resolution
617
+ }
618
+
619
+ // Strategy 2: Resolve openclaw main entry, then navigate to dist/extensionAPI.js
620
+ try {
621
+ const openclawMain = require.resolve("openclaw");
622
+ const candidate = path.join(path.dirname(openclawMain), "extensionAPI.js");
623
+ if (require("node:fs").existsSync(candidate)) return candidate;
624
+ } catch {
625
+ // openclaw not resolvable at all
626
+ }
627
+
628
+ // Strategy 3: Sibling in global node_modules (plugin installed alongside openclaw)
629
+ try {
630
+ const thisFile = typeof __dirname !== "undefined" ? __dirname : path.dirname(new URL(import.meta.url).pathname);
631
+ // Walk up from plugin src/dist to node_modules, then into openclaw
632
+ let dir = thisFile;
633
+ for (let i = 0; i < 6; i++) {
634
+ const candidate = path.join(dir, "openclaw", "dist", "extensionAPI.js");
635
+ if (require("node:fs").existsSync(candidate)) return candidate;
636
+ const parent = path.dirname(dir);
637
+ if (parent === dir) break;
638
+ dir = parent;
639
+ }
640
+ } catch {
641
+ // Traversal failed
642
+ }
643
+
644
+ // Strategy 4: Well-known global install paths
645
+ const globalCandidates = [
646
+ path.join(os.homedir(), ".openclaw", "node_modules", "openclaw", "dist", "extensionAPI.js"),
647
+ "/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw/dist/extensionAPI.js",
648
+ "/usr/local/lib/node_modules/openclaw/dist/extensionAPI.js",
649
+ "/usr/lib/node_modules/openclaw/dist/extensionAPI.js",
650
+ ];
651
+ for (const candidate of globalCandidates) {
652
+ try {
653
+ if (require("node:fs").existsSync(candidate)) return candidate;
654
+ } catch {
655
+ // skip
656
+ }
657
+ }
658
+
659
+ return null;
660
+ }
661
+
662
+ async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
663
+ const resolved = resolveExtensionAPIPath();
664
+ if (!resolved) {
665
+ throw new Error("Could not locate openclaw/dist/extensionAPI.js — tried module resolution, sibling lookup, and global paths");
666
+ }
667
+
668
+ const mod = (await import(resolved)) as { runEmbeddedPiAgent?: unknown };
669
+ const fn = (mod as any).runEmbeddedPiAgent;
670
+ if (typeof fn !== "function") {
671
+ throw new Error(`runEmbeddedPiAgent not exported from ${resolved}`);
672
+ }
673
+ return fn as RunEmbeddedPiAgentFn;
674
+ }
675
+
676
+ export function getEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
677
+ if (!_embeddedPiAgentLoader) {
678
+ _embeddedPiAgentLoader = loadRunEmbeddedPiAgent();
679
+ }
680
+ return _embeddedPiAgentLoader;
681
+ }
682
+
683
+ /** Reset cached loader (for testing). */
684
+ export function resetEmbeddedPiAgentLoader(): void {
685
+ _embeddedPiAgentLoader = null;
686
+ _llmImportFailureLogged = false;
687
+ }
688
+
689
+ /** Override the cached loader with a custom promise (for testing). */
690
+ export function setEmbeddedPiAgentLoader(loader: Promise<RunEmbeddedPiAgentFn> | null): void {
691
+ _embeddedPiAgentLoader = loader;
692
+ }
693
+
694
+ const EXTRACTION_SYSTEM_PROMPT_BASE = `You are a knowledge extraction engine. Analyze the following conversation exchange and identify information worth remembering long-term.
695
+
696
+ For each piece of knowledge, return a JSON array of objects:
697
+ - "content": concise summary of the knowledge (1-3 sentences)
698
+ - "type": "memory" (facts, decisions, preferences), "process" (workflows, procedures, steps), or "task" (action items, todos, commitments)
699
+ - "tags": array of significance tags from: ["decision", "lesson", "surprise", "commitment", "correction", "preference", "fact"]
700
+ - "significance": 0.0-1.0 how important this is for long-term recall
701
+ - "project": which project this belongs to (from known projects list, or null if unclear)
702
+ - "scope": "private" (personal preference, agent-specific), "team" (shared knowledge), or "public" (documentation)
703
+
704
+ Only extract genuinely significant knowledge. Skip small talk, acknowledgments, routine exchanges.
705
+ Return empty array [] if nothing is worth remembering.
706
+ Return ONLY valid JSON, no markdown fences.`;
707
+
708
+ function buildExtractionPrompt(projects: CachedProject[]): string {
709
+ if (projects.length === 0) return EXTRACTION_SYSTEM_PROMPT_BASE;
710
+ const projectList = projects
711
+ .map((p) => `${p.name}${p.description ? ` (${p.description})` : ""}`)
712
+ .join(", ");
713
+ return `${EXTRACTION_SYSTEM_PROMPT_BASE}\n\nKnown projects: ${projectList}`;
714
+ }
715
+
716
+ const CHEAP_MODELS: Record<string, string> = {
717
+ anthropic: "claude-haiku-4",
718
+ openai: "gpt-4.1-mini",
719
+ google: "gemini-2.0-flash",
720
+ };
721
+
722
+ export function resolveCaptureModel(
723
+ config: any,
724
+ captureModel?: string,
725
+ ): { provider: string; model: string } | undefined {
726
+ if (captureModel && captureModel !== "cheap") {
727
+ const parts = captureModel.split("/");
728
+ if (parts.length >= 2) {
729
+ return { provider: parts[0], model: parts.slice(1).join("/") };
730
+ }
731
+ const defaultsModel = config?.agents?.defaults?.model;
732
+ const primary = typeof defaultsModel === "string"
733
+ ? defaultsModel.trim()
734
+ : (defaultsModel?.primary?.trim() ?? "");
735
+ const defaultProvider = primary.split("/")[0];
736
+ if (defaultProvider) {
737
+ return { provider: defaultProvider, model: captureModel };
738
+ }
739
+ }
740
+
741
+ const defaultsModel = config?.agents?.defaults?.model;
742
+ const primary = typeof defaultsModel === "string"
743
+ ? defaultsModel.trim()
744
+ : (defaultsModel?.primary?.trim() ?? "");
745
+ const defaultProvider = primary.split("/")[0];
746
+ const defaultModel = primary.split("/").slice(1).join("/");
747
+
748
+ if (defaultProvider && CHEAP_MODELS[defaultProvider]) {
749
+ return { provider: defaultProvider, model: CHEAP_MODELS[defaultProvider] };
750
+ }
751
+
752
+ if (defaultProvider && defaultModel) {
753
+ return { provider: defaultProvider, model: defaultModel };
754
+ }
755
+
756
+ return undefined;
757
+ }
758
+
759
+ function stripCodeFences(s: string): string {
760
+ const trimmed = s.trim();
761
+ const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
762
+ if (m) return (m[1] ?? "").trim();
763
+ return trimmed;
764
+ }
765
+
766
+ function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string {
767
+ return (payloads ?? [])
768
+ .filter((p) => !p.isError && typeof p.text === "string")
769
+ .map((p) => p.text ?? "")
770
+ .join("\n")
771
+ .trim();
772
+ }
773
+
774
+ export async function extractWithLLM(
775
+ messages: unknown[],
776
+ config: any,
777
+ pluginConfig?: { captureModel?: string },
778
+ knownProjects?: CachedProject[],
779
+ ): Promise<ExtractionResult[]> {
780
+ const runEmbeddedPiAgent = await getEmbeddedPiAgent();
781
+
782
+ const resolved = resolveCaptureModel(config, pluginConfig?.captureModel);
783
+ if (!resolved) {
784
+ throw new Error("No model available for LLM extraction");
785
+ }
786
+
787
+ const texts = extractMessageTexts(messages);
788
+ const exchangeText = texts
789
+ .filter((t) => t.role === "user" || t.role === "assistant")
790
+ .map((t) => `[${t.role}]: ${t.text}`)
791
+ .join("\n");
792
+
793
+ if (!exchangeText.trim()) {
794
+ return [];
795
+ }
796
+
797
+ const systemPrompt = buildExtractionPrompt(knownProjects || []);
798
+ const prompt = `${systemPrompt}\n\n--- CONVERSATION ---\n${exchangeText}\n--- END ---`;
799
+
800
+ let tmpDir: string | null = null;
801
+ try {
802
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "palaia-extract-"));
803
+ const sessionId = `palaia-extract-${Date.now()}`;
804
+ const sessionFile = path.join(tmpDir, "session.json");
805
+
806
+ const result = await runEmbeddedPiAgent({
807
+ sessionId,
808
+ sessionFile,
809
+ workspaceDir: config?.agents?.defaults?.workspace ?? process.cwd(),
810
+ config,
811
+ prompt,
812
+ timeoutMs: 15_000,
813
+ runId: `palaia-extract-${Date.now()}`,
814
+ provider: resolved.provider,
815
+ model: resolved.model,
816
+ disableTools: true,
817
+ streamParams: { maxTokens: 2048 },
818
+ });
819
+
820
+ const text = collectText((result as any).payloads);
821
+ if (!text) return [];
822
+
823
+ const raw = stripCodeFences(text);
824
+ let parsed: unknown;
825
+ try {
826
+ parsed = JSON.parse(raw);
827
+ } catch {
828
+ throw new Error(`LLM returned invalid JSON: ${raw.slice(0, 200)}`);
829
+ }
830
+
831
+ if (!Array.isArray(parsed)) {
832
+ throw new Error(`LLM returned non-array: ${typeof parsed}`);
833
+ }
834
+
835
+ const results: ExtractionResult[] = [];
836
+ for (const item of parsed) {
837
+ if (!item || typeof item !== "object") continue;
838
+ const content = typeof item.content === "string" ? item.content.trim() : "";
839
+ if (!content) continue;
840
+
841
+ const validTypes = new Set(["memory", "process", "task"]);
842
+ const type = validTypes.has(item.type) ? item.type : "memory";
843
+
844
+ const validTags = new Set([
845
+ "decision", "lesson", "surprise", "commitment",
846
+ "correction", "preference", "fact",
847
+ ]);
848
+ const tags = Array.isArray(item.tags)
849
+ ? item.tags.filter((t: unknown) => typeof t === "string" && validTags.has(t))
850
+ : [];
851
+
852
+ const significance = typeof item.significance === "number"
853
+ ? Math.max(0, Math.min(1, item.significance))
854
+ : 0.5;
855
+
856
+ const project = typeof item.project === "string" && item.project.trim()
857
+ ? item.project.trim()
858
+ : null;
859
+
860
+ const scope = typeof item.scope === "string" && isValidScope(item.scope)
861
+ ? item.scope
862
+ : null;
863
+
864
+ results.push({ content, type, tags, significance, project, scope });
865
+ }
866
+
867
+ return results;
868
+ } finally {
869
+ if (tmpDir) {
870
+ try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
871
+ }
872
+ }
873
+ }
874
+
875
+ // ============================================================================
876
+ // Auto-Capture: Rule-based extraction (Issue #64)
877
+ // ============================================================================
878
+
879
+ const TRIVIAL_RESPONSES = new Set([
880
+ "ok", "ja", "nein", "yes", "no", "sure", "klar", "danke", "thanks",
881
+ "thx", "k", "👍", "👎", "ack", "nope", "yep", "yup", "alright",
882
+ "fine", "gut", "passt", "okay", "hmm", "hm", "ah", "aha",
883
+ ]);
884
+
885
+ const SIGNIFICANCE_RULES: Array<{
886
+ pattern: RegExp;
887
+ tag: string;
888
+ type: "memory" | "process" | "task";
889
+ }> = [
890
+ { pattern: /(?:we decided|entschieden|decision:|beschlossen|let'?s go with|wir nehmen|agreed on)/i, tag: "decision", type: "memory" },
891
+ { pattern: /(?:will use|werden nutzen|going forward|ab jetzt|from now on)/i, tag: "decision", type: "memory" },
892
+ { pattern: /(?:learned|gelernt|lesson:|erkenntnis|takeaway|insight|turns out|it seems)/i, tag: "lesson", type: "memory" },
893
+ { pattern: /(?:mistake was|fehler war|should have|hätten sollen|next time)/i, tag: "lesson", type: "memory" },
894
+ { pattern: /(?:surprising|überraschend|unexpected|unerwartet|didn'?t expect|nicht erwartet|plot twist)/i, tag: "surprise", type: "memory" },
895
+ { pattern: /(?:i will|ich werde|todo:|action item|must do|muss noch|need to|commit to|verspreche)/i, tag: "commitment", type: "task" },
896
+ { pattern: /(?:deadline|frist|due date|bis zum|by end of|spätestens)/i, tag: "commitment", type: "task" },
897
+ { pattern: /(?:the process is|der prozess|steps?:|workflow:|how to|anleitung|recipe:|checklist)/i, tag: "process", type: "process" },
898
+ { pattern: /(?:first,?\s.*then|schritt \d|step \d|1\.\s.*2\.\s)/i, tag: "process", type: "process" },
899
+ ];
900
+
901
+ const NOISE_PATTERNS: RegExp[] = [
902
+ /(?:PASSED|FAILED|ERROR)\s+\[?\d+%\]?/i,
903
+ /(?:test_\w+|tests?\/\w+\.(?:py|ts|js))\s*::/,
904
+ /(?:pytest|vitest|jest|mocha)\s+(?:run|--)/i,
905
+ /\d+ passed,?\s*\d* (?:failed|error|warning)/i,
906
+ /^(?:=+\s*(?:test session|ERRORS|FAILURES|short test summary))/m,
907
+ /(?:Traceback \(most recent call last\)|^\s+File ".*", line \d+)/m,
908
+ /^\s+at\s+\S+\s+\(.*:\d+:\d+\)/m,
909
+ /^(?:\/[\w/.-]+){3,}\s*$/m,
910
+ /(?:npm\s+(?:ERR|WARN)|pip\s+install|cargo\s+build)/i,
911
+ /^(?:warning|error)\[?\w*\]?:\s/m,
912
+ ];
913
+
914
+ export function isNoiseContent(text: string): boolean {
915
+ let matchCount = 0;
916
+ for (const pattern of NOISE_PATTERNS) {
917
+ if (pattern.test(text)) {
918
+ matchCount++;
919
+ if (matchCount >= 2) return true;
920
+ }
921
+ }
922
+
923
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
924
+ if (lines.length > 3) {
925
+ const pathLines = lines.filter((l) => /^\s*(?:\/[\w/.-]+){2,}/.test(l.trim()));
926
+ if (pathLines.length / lines.length > 0.5) return true;
927
+ }
928
+
929
+ return false;
930
+ }
931
+
932
+ export function shouldAttemptCapture(
933
+ exchangeText: string,
934
+ minChars = 100,
935
+ ): boolean {
936
+ const trimmed = exchangeText.trim();
937
+
938
+ if (trimmed.length < minChars) return false;
939
+
940
+ const words = trimmed.toLowerCase().split(/\s+/);
941
+ if (words.length <= 3 && words.every((w) => TRIVIAL_RESPONSES.has(w))) {
942
+ return false;
943
+ }
944
+
945
+ if (trimmed.includes("<relevant-memories>")) return false;
946
+ if (trimmed.startsWith("<") && trimmed.includes("</")) return false;
947
+
948
+ if (isNoiseContent(trimmed)) return false;
949
+
950
+ return true;
951
+ }
952
+
953
+ export function extractSignificance(
954
+ exchangeText: string,
955
+ ): { tags: string[]; type: "memory" | "process" | "task"; summary: string } | null {
956
+ const matched: Array<{ tag: string; type: "memory" | "process" | "task" }> = [];
957
+
958
+ for (const rule of SIGNIFICANCE_RULES) {
959
+ if (rule.pattern.test(exchangeText)) {
960
+ matched.push({ tag: rule.tag, type: rule.type });
961
+ }
962
+ }
963
+
964
+ if (matched.length === 0) return null;
965
+
966
+ const typePriority: Record<string, number> = { task: 3, process: 2, memory: 1 };
967
+ const primaryType = matched.reduce(
968
+ (best, m) => (typePriority[m.type] > typePriority[best] ? m.type : best),
969
+ "memory" as "memory" | "process" | "task",
970
+ );
971
+
972
+ const tags = [...new Set(matched.map((m) => m.tag))];
973
+
974
+ const sentences = exchangeText
975
+ .split(/[.!?\n]+/)
976
+ .map((s) => s.trim())
977
+ .filter((s) => s.length > 20 && s.length < 500);
978
+
979
+ const relevantSentences = sentences.filter((s) =>
980
+ SIGNIFICANCE_RULES.some((r) => r.pattern.test(s)),
981
+ );
982
+
983
+ const summary = (relevantSentences.length > 0 ? relevantSentences : sentences)
984
+ .slice(0, 3)
985
+ .join(". ")
986
+ .slice(0, 500);
987
+
988
+ if (!summary) return null;
989
+
990
+ return { tags, type: primaryType, summary };
991
+ }
992
+
993
+ export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string }> {
994
+ const result: Array<{ role: string; text: string }> = [];
995
+
996
+ for (const msg of messages) {
997
+ if (!msg || typeof msg !== "object") continue;
998
+ const m = msg as Message;
999
+ const role = m.role;
1000
+ if (!role || typeof role !== "string") continue;
1001
+
1002
+ if (typeof m.content === "string" && m.content.trim()) {
1003
+ result.push({ role, text: m.content.trim() });
1004
+ continue;
1005
+ }
1006
+
1007
+ if (Array.isArray(m.content)) {
1008
+ for (const block of m.content) {
1009
+ if (
1010
+ block &&
1011
+ typeof block === "object" &&
1012
+ block.type === "text" &&
1013
+ typeof block.text === "string" &&
1014
+ block.text.trim()
1015
+ ) {
1016
+ result.push({ role, text: block.text.trim() });
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ return result;
1023
+ }
1024
+
1025
+ export function getLastUserMessage(messages: unknown[]): string | null {
1026
+ const texts = extractMessageTexts(messages);
1027
+ for (let i = texts.length - 1; i >= 0; i--) {
1028
+ if (texts[i].role === "user") return texts[i].text;
1029
+ }
1030
+ return null;
1031
+ }
1032
+
1033
+ // ============================================================================
1034
+ // Query-based Recall: Type-weighted reranking (Issue #65)
1035
+ // ============================================================================
1036
+
1037
+ interface RankedEntry {
1038
+ id: string;
1039
+ body: string;
1040
+ title: string;
1041
+ scope: string;
1042
+ tier: string;
1043
+ type: string;
1044
+ score: number;
1045
+ weightedScore: number;
1046
+ }
1047
+
1048
+ export function rerankByTypeWeight(
1049
+ results: QueryResult["results"],
1050
+ weights: RecallTypeWeights,
1051
+ ): RankedEntry[] {
1052
+ return results
1053
+ .map((r) => {
1054
+ const type = r.type || "memory";
1055
+ const weight = weights[type] ?? 1.0;
1056
+ return {
1057
+ id: r.id,
1058
+ body: r.content || r.body || "",
1059
+ title: r.title || "(untitled)",
1060
+ scope: r.scope,
1061
+ tier: r.tier,
1062
+ type,
1063
+ score: r.score,
1064
+ weightedScore: r.score * weight,
1065
+ };
1066
+ })
1067
+ .sort((a, b) => b.weightedScore - a.weightedScore);
1068
+ }
1069
+
1070
+ // ============================================================================
1071
+ // Hook helpers
1072
+ // ============================================================================
1073
+
27
1074
  function buildRunnerOpts(config: PalaiaPluginConfig): RunnerOpts {
28
1075
  return {
29
1076
  binaryPath: config.binaryPath,
@@ -32,57 +1079,492 @@ function buildRunnerOpts(config: PalaiaPluginConfig): RunnerOpts {
32
1079
  };
33
1080
  }
34
1081
 
1082
+ // ============================================================================
1083
+ // /palaia status command — Format helpers
1084
+ // ============================================================================
1085
+
1086
+ function formatStatusResponse(
1087
+ state: PluginState,
1088
+ stats: Record<string, unknown>,
1089
+ config: PalaiaPluginConfig,
1090
+ ): string {
1091
+ const lines: string[] = ["Palaia Memory Status", ""];
1092
+
1093
+ // Recall count
1094
+ const sinceDate = state.firstRecallTimestamp
1095
+ ? formatShortDate(state.firstRecallTimestamp)
1096
+ : "n/a";
1097
+ lines.push(`Recalls: ${state.successfulRecalls} successful (since ${sinceDate})`);
1098
+
1099
+ // Store stats from palaia status --json
1100
+ const totalEntries = stats.total_entries ?? stats.totalEntries ?? "?";
1101
+ const hotEntries = stats.hot ?? stats.hotEntries ?? "?";
1102
+ const warmEntries = stats.warm ?? stats.warmEntries ?? "?";
1103
+ lines.push(`Store: ${totalEntries} entries (${hotEntries} hot, ${warmEntries} warm)`);
1104
+
1105
+ // Recall indicator
1106
+ lines.push(`Recall indicator: ${config.showMemorySources ? "ON" : "OFF"}`);
1107
+
1108
+ // Config summary
1109
+ lines.push(`Config: autoCapture=${config.autoCapture}, captureScope=${config.captureScope || "team"}`);
1110
+
1111
+ return lines.join("\n");
1112
+ }
1113
+
1114
+ // ============================================================================
1115
+ // Legacy exports kept for tests
1116
+ // ============================================================================
1117
+
1118
+ /** Reset all turn state, inbound message store, and cached tokens (for testing and cleanup). */
1119
+ export function resetTurnState(): void {
1120
+ turnStateBySession.clear();
1121
+ lastInboundMessageByChannel.clear();
1122
+ resetSlackTokenCache();
1123
+ }
1124
+
1125
+ // ============================================================================
1126
+ // Hook registration
1127
+ // ============================================================================
1128
+
35
1129
  /**
36
1130
  * Register lifecycle hooks on the plugin API.
37
1131
  */
38
1132
  export function registerHooks(api: any, config: PalaiaPluginConfig): void {
39
1133
  const opts = buildRunnerOpts(config);
40
1134
 
41
- // ── before_prompt_build ────────────────────────────────────────
42
- // Injects top HOT entries into agent system context.
43
- // Only active when config.memoryInject === true (default: false).
1135
+ // ── Startup checks (H-2, H-3) ─────────────────────────────────
1136
+ (async () => {
1137
+ // H-2: Warn if no agent is configured
1138
+ if (!process.env.PALAIA_AGENT) {
1139
+ try {
1140
+ const statusOut = await run(["config", "get", "agent"], { ...opts, timeoutMs: 3000 });
1141
+ if (!statusOut.trim()) {
1142
+ console.warn(
1143
+ "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
1144
+ "Auto-captured entries will have no agent attribution."
1145
+ );
1146
+ }
1147
+ } catch {
1148
+ console.warn(
1149
+ "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
1150
+ "Auto-captured entries will have no agent attribution."
1151
+ );
1152
+ }
1153
+ }
1154
+
1155
+ // H-3: Warn if no embedding provider beyond BM25
1156
+ try {
1157
+ const statusJson = await run(["status", "--json"], { ...opts, timeoutMs: 5000 });
1158
+ if (statusJson && statusJson.trim()) {
1159
+ const status = JSON.parse(statusJson);
1160
+ // embedding_chain can be at top level OR nested under config
1161
+ const chain = status.embedding_chain
1162
+ || status.embeddingChain
1163
+ || status.config?.embedding_chain
1164
+ || status.config?.embeddingChain
1165
+ || [];
1166
+ const hasSemanticProvider = Array.isArray(chain)
1167
+ ? chain.some((p: string) => p !== "bm25")
1168
+ : false;
1169
+ // Also check embedding_provider as a fallback signal
1170
+ const hasProviderConfig = !!(
1171
+ status.embedding_provider
1172
+ || status.config?.embedding_provider
1173
+ );
1174
+ if (!hasSemanticProvider && !hasProviderConfig) {
1175
+ console.warn(
1176
+ "[palaia] No embedding provider configured. Semantic search is inactive (BM25 keyword-only). " +
1177
+ "Run 'pip install palaia[fastembed]' and 'palaia doctor --fix' for better recall quality."
1178
+ );
1179
+ }
1180
+ }
1181
+ // If statusJson is empty/null, skip warning (CLI may not be available)
1182
+ } catch {
1183
+ // Non-fatal — status check failed, skip warning (avoid false positive)
1184
+ }
1185
+ })();
1186
+
1187
+ // ── /palaia status command ─────────────────────────────────────
1188
+ api.registerCommand({
1189
+ name: "palaia-status",
1190
+ description: "Show Palaia memory status",
1191
+ async handler(_args: string) {
1192
+ try {
1193
+ const state = await loadPluginState(config.workspace);
1194
+
1195
+ let stats: Record<string, unknown> = {};
1196
+ try {
1197
+ const statsOutput = await run(["status", "--json"], opts);
1198
+ stats = JSON.parse(statsOutput || "{}");
1199
+ } catch {
1200
+ // Non-fatal
1201
+ }
1202
+
1203
+ return { text: formatStatusResponse(state, stats, config) };
1204
+ } catch (error) {
1205
+ return { text: `Palaia status error: ${error}` };
1206
+ }
1207
+ },
1208
+ });
1209
+
1210
+ // ── message_received (capture inbound message ID for reactions) ─
1211
+ api.on("message_received", (event: any, ctx: any) => {
1212
+ try {
1213
+ const messageId = event?.metadata?.messageId;
1214
+ const provider = event?.metadata?.provider;
1215
+ const channelId = ctx?.channelId;
1216
+
1217
+ if (messageId && channelId && provider && REACTION_SUPPORTED_PROVIDERS.has(provider)) {
1218
+ lastInboundMessageByChannel.set(channelId, {
1219
+ messageId: String(messageId),
1220
+ provider,
1221
+ timestamp: Date.now(),
1222
+ });
1223
+ }
1224
+ } catch {
1225
+ // Non-fatal — never block message flow
1226
+ }
1227
+ });
1228
+
1229
+ // ── before_prompt_build (Issue #65: Query-based Recall) ────────
44
1230
  if (config.memoryInject) {
45
1231
  api.on("before_prompt_build", async (event: any, ctx: any) => {
1232
+ // Prune stale entries to prevent memory leaks from crashed sessions (C-2)
1233
+ pruneStaleEntries();
1234
+
46
1235
  try {
47
1236
  const maxChars = config.maxInjectedChars || 4000;
48
1237
  const limit = Math.min(config.maxResults || 10, 20);
1238
+ let entries: QueryResult["results"] = [];
49
1239
 
50
- const result = await runJson<QueryResult>(
51
- ["list", "--tier", "hot", "--limit", String(limit)],
52
- opts
53
- );
1240
+ if (config.recallMode === "query") {
1241
+ const userMessage = event.prompt
1242
+ || (event.messages ? getLastUserMessage(event.messages) : null);
1243
+
1244
+ if (userMessage && userMessage.length >= 5) {
1245
+ try {
1246
+ const queryArgs: string[] = ["query", userMessage, "--limit", String(limit)];
1247
+ if (config.tier === "all") {
1248
+ queryArgs.push("--all");
1249
+ }
1250
+ const result = await runJson<QueryResult>(queryArgs, opts);
1251
+ if (result && Array.isArray(result.results)) {
1252
+ entries = result.results;
1253
+ }
1254
+ } catch (queryError) {
1255
+ console.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
1256
+ }
1257
+ }
1258
+ }
54
1259
 
55
- if (!result || !Array.isArray(result.results) || result.results.length === 0) {
56
- // Fallback: try query with empty string for recent entries
57
- return;
1260
+ // Fallback: list mode
1261
+ if (entries.length === 0) {
1262
+ try {
1263
+ const listArgs: string[] = ["list"];
1264
+ if (config.tier === "all") {
1265
+ listArgs.push("--all");
1266
+ } else {
1267
+ listArgs.push("--tier", config.tier || "hot");
1268
+ }
1269
+ const result = await runJson<QueryResult>(listArgs, opts);
1270
+ if (result && Array.isArray(result.results)) {
1271
+ entries = result.results;
1272
+ }
1273
+ } catch {
1274
+ return;
1275
+ }
58
1276
  }
59
1277
 
60
- const entries = result.results;
1278
+ if (entries.length === 0) return;
1279
+
1280
+ // Apply type-weighted reranking
1281
+ const ranked = rerankByTypeWeight(entries, config.recallTypeWeight);
1282
+
1283
+ // Build context string with char budget
61
1284
  let text = "## Active Memory (Palaia)\n\n";
62
1285
  let chars = text.length;
63
1286
 
64
- for (const entry of entries) {
65
- const body = entry.content || entry.body || "";
66
- const title = entry.title || "(untitled)";
67
- const line = `**${title}** [${entry.scope}]\n${body}\n\n`;
68
-
1287
+ for (const entry of ranked) {
1288
+ const line = `**${entry.title}** [${entry.scope}/${entry.type}]\n${entry.body}\n\n`;
69
1289
  if (chars + line.length > maxChars) break;
70
1290
  text += line;
71
1291
  chars += line.length;
72
1292
  }
73
1293
 
74
- if (ctx.prependSystemContext) {
75
- ctx.prependSystemContext(text);
1294
+ // Update recall counter for satisfaction/transparency nudges (Issue #87)
1295
+ let nudgeContext = "";
1296
+ try {
1297
+ const pluginState = await loadPluginState(config.workspace);
1298
+ pluginState.successfulRecalls++;
1299
+ if (!pluginState.firstRecallTimestamp) {
1300
+ pluginState.firstRecallTimestamp = new Date().toISOString();
1301
+ }
1302
+ const { nudges } = checkNudges(pluginState);
1303
+ if (nudges.length > 0) {
1304
+ nudgeContext = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
1305
+ }
1306
+ await savePluginState(pluginState, config.workspace);
1307
+ } catch {
1308
+ // Non-fatal
1309
+ }
1310
+
1311
+ // Track recall in session-isolated turn state for emoji reactions
1312
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
1313
+ if (sessionKey) {
1314
+ const turnState = getOrCreateTurnState(sessionKey);
1315
+ turnState.recallOccurred = true;
1316
+
1317
+ // Populate channel info from sessionKey for reaction routing
1318
+ const provider = extractChannelFromSessionKey(sessionKey);
1319
+ if (provider) turnState.channelProvider = provider;
1320
+ const slackChannel = extractSlackChannelIdFromSessionKey(sessionKey);
1321
+ if (slackChannel) turnState.lastInboundChannelId = slackChannel;
1322
+
1323
+ // Try to get the inbound message ID from the message_received store
1324
+ if (slackChannel) {
1325
+ const inbound = lastInboundMessageByChannel.get(slackChannel);
1326
+ if (inbound && (Date.now() - inbound.timestamp) < 30_000) {
1327
+ turnState.lastInboundMessageId = inbound.messageId;
1328
+ }
1329
+ }
76
1330
  }
1331
+
1332
+ // Return prependContext + appendSystemContext for recall emoji
1333
+ return {
1334
+ prependContext: text + nudgeContext,
1335
+ appendSystemContext: config.showMemorySources
1336
+ ? "You used Palaia memory in this turn. Add 🧠 at the very end of your response (after everything else, on its own line)."
1337
+ : undefined,
1338
+ };
77
1339
  } catch (error) {
78
- // Non-fatal: if memory injection fails, agent continues without it
79
1340
  console.warn(`[palaia] Memory injection failed: ${error}`);
80
1341
  }
81
1342
  });
82
1343
  }
83
1344
 
1345
+ // ── message_sending (Issue #81: Hint stripping) ──────────────────
1346
+ api.on("message_sending", (_event: any, _ctx: any) => {
1347
+ const content = _event?.content;
1348
+ if (typeof content !== "string") return;
1349
+
1350
+ const { hints, cleanedText } = parsePalaiaHints(content);
1351
+ if (hints.length > 0) {
1352
+ return { content: cleanedText };
1353
+ }
1354
+ });
1355
+
1356
+ // ── agent_end (Issue #64 + #81: Auto-Capture with Metadata + Reactions) ───
1357
+ if (config.autoCapture) {
1358
+ api.on("agent_end", async (event: any, ctx: any) => {
1359
+ if (!event.success || !event.messages || event.messages.length === 0) {
1360
+ return;
1361
+ }
1362
+
1363
+ // Resolve session key for turn state
1364
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
1365
+
1366
+ try {
1367
+ const agentName = process.env.PALAIA_AGENT || undefined;
1368
+
1369
+ const allTexts = extractMessageTexts(event.messages);
1370
+
1371
+ const userTurns = allTexts.filter((t) => t.role === "user").length;
1372
+ if (userTurns < config.captureMinTurns) return;
1373
+
1374
+ // Parse capture hints from all messages (Issue #81)
1375
+ const collectedHints: PalaiaHint[] = [];
1376
+ for (const t of allTexts) {
1377
+ const { hints } = parsePalaiaHints(t.text);
1378
+ collectedHints.push(...hints);
1379
+ }
1380
+
1381
+ // Build exchange text
1382
+ const exchangeParts: string[] = [];
1383
+ for (const t of allTexts) {
1384
+ if (t.role === "user" || t.role === "assistant") {
1385
+ const { cleanedText } = parsePalaiaHints(t.text);
1386
+ exchangeParts.push(`[${t.role}]: ${cleanedText}`);
1387
+ }
1388
+ }
1389
+ const exchangeText = exchangeParts.join("\n");
1390
+
1391
+ if (!shouldAttemptCapture(exchangeText)) return;
1392
+
1393
+ const knownProjects = await loadProjects(opts);
1394
+
1395
+ // Helper: build CLI args with metadata
1396
+ const buildWriteArgs = (
1397
+ content: string,
1398
+ type: string,
1399
+ tags: string[],
1400
+ itemProject?: string | null,
1401
+ itemScope?: string | null,
1402
+ ): string[] => {
1403
+ const args: string[] = [
1404
+ "write",
1405
+ content,
1406
+ "--type", type,
1407
+ "--tags", tags.join(",") || "auto-capture",
1408
+ ];
1409
+
1410
+ const scope = sanitizeScope(config.captureScope || itemScope, config.captureScope || "team");
1411
+ args.push("--scope", scope);
1412
+
1413
+ const project = config.captureProject || itemProject;
1414
+ if (project) {
1415
+ args.push("--project", project);
1416
+ }
1417
+
1418
+ if (agentName) {
1419
+ args.push("--agent", agentName);
1420
+ }
1421
+
1422
+ return args;
1423
+ };
1424
+
1425
+ // LLM-based extraction (primary)
1426
+ let llmHandled = false;
1427
+ try {
1428
+ const results = await extractWithLLM(event.messages, api.config, {
1429
+ captureModel: config.captureModel,
1430
+ }, knownProjects);
1431
+
1432
+ for (const r of results) {
1433
+ if (r.significance >= config.captureMinSignificance) {
1434
+ const hintForProject = collectedHints.find((h) => h.project);
1435
+ const hintForScope = collectedHints.find((h) => h.scope);
1436
+
1437
+ const effectiveProject = hintForProject?.project || r.project;
1438
+ const effectiveScope = hintForScope?.scope || r.scope;
1439
+
1440
+ const args = buildWriteArgs(
1441
+ r.content,
1442
+ r.type,
1443
+ r.tags,
1444
+ effectiveProject,
1445
+ effectiveScope,
1446
+ );
1447
+ await run(args, { ...opts, timeoutMs: 10_000 });
1448
+ console.log(
1449
+ `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${r.tags.join(",")}, project=${effectiveProject || "none"}, scope=${effectiveScope || "team"}`
1450
+ );
1451
+ }
1452
+ }
1453
+
1454
+ llmHandled = true;
1455
+ } catch (llmError) {
1456
+ if (!_llmImportFailureLogged) {
1457
+ console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
1458
+ _llmImportFailureLogged = true;
1459
+ }
1460
+ }
1461
+
1462
+ // Rule-based fallback
1463
+ if (!llmHandled) {
1464
+ let captureData: { tags: string[]; type: string; summary: string } | null = null;
1465
+
1466
+ if (config.captureFrequency === "significant") {
1467
+ const significance = extractSignificance(exchangeText);
1468
+ if (!significance) return;
1469
+ captureData = significance;
1470
+ } else {
1471
+ const summary = exchangeParts
1472
+ .slice(-4)
1473
+ .map((p) => p.slice(0, 200))
1474
+ .join(" | ")
1475
+ .slice(0, 500);
1476
+ captureData = { tags: ["auto-capture"], type: "memory", summary };
1477
+ }
1478
+
1479
+ const hintForProject = collectedHints.find((h) => h.project);
1480
+ const hintForScope = collectedHints.find((h) => h.scope);
1481
+
1482
+ const args = buildWriteArgs(
1483
+ captureData.summary,
1484
+ captureData.type,
1485
+ captureData.tags,
1486
+ hintForProject?.project,
1487
+ hintForScope?.scope,
1488
+ );
1489
+
1490
+ await run(args, { ...opts, timeoutMs: 10_000 });
1491
+ console.log(
1492
+ `[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
1493
+ );
1494
+ }
1495
+
1496
+ // Mark that capture occurred in this turn
1497
+ if (sessionKey) {
1498
+ const turnState = getOrCreateTurnState(sessionKey);
1499
+ turnState.capturedInThisTurn = true;
1500
+ }
1501
+ } catch (error) {
1502
+ console.warn(`[palaia] Auto-capture failed: ${error}`);
1503
+ }
1504
+
1505
+ // ── Emoji Reactions (Issue #87) ──────────────────────────
1506
+ // Send reactions AFTER capture completes, using turn state.
1507
+ if (sessionKey) {
1508
+ try {
1509
+ const turnState = turnStateBySession.get(sessionKey);
1510
+ if (turnState) {
1511
+ const provider = turnState.channelProvider
1512
+ || extractChannelFromSessionKey(sessionKey)
1513
+ || (ctx?.channelId as string | undefined);
1514
+ const channelId = turnState.lastInboundChannelId
1515
+ || extractSlackChannelIdFromSessionKey(sessionKey);
1516
+ const messageId = turnState.lastInboundMessageId;
1517
+
1518
+ if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
1519
+ // Capture confirmation: 💾
1520
+ if (turnState.capturedInThisTurn && config.showCaptureConfirm) {
1521
+ await sendReaction(channelId, messageId, "floppy_disk", provider);
1522
+ }
1523
+
1524
+ // Recall indicator: 🧠
1525
+ if (turnState.recallOccurred && config.showMemorySources) {
1526
+ await sendReaction(channelId, messageId, "brain", provider);
1527
+ }
1528
+ }
1529
+ }
1530
+ } catch (reactionError) {
1531
+ console.warn(`[palaia] Reaction sending failed: ${reactionError}`);
1532
+ } finally {
1533
+ // Always clean up turn state
1534
+ deleteTurnState(sessionKey);
1535
+ }
1536
+ }
1537
+ });
1538
+ }
1539
+
1540
+ // ── agent_end: Recall-only reactions (when autoCapture is off) ─
1541
+ if (!config.autoCapture && config.showMemorySources) {
1542
+ api.on("agent_end", async (_event: any, ctx: any) => {
1543
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
1544
+ if (!sessionKey) return;
1545
+
1546
+ try {
1547
+ const turnState = turnStateBySession.get(sessionKey);
1548
+ if (turnState?.recallOccurred) {
1549
+ const provider = turnState.channelProvider
1550
+ || extractChannelFromSessionKey(sessionKey);
1551
+ const channelId = turnState.lastInboundChannelId
1552
+ || extractSlackChannelIdFromSessionKey(sessionKey);
1553
+ const messageId = turnState.lastInboundMessageId;
1554
+
1555
+ if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
1556
+ await sendReaction(channelId, messageId, "brain", provider);
1557
+ }
1558
+ }
1559
+ } catch (err) {
1560
+ console.warn(`[palaia] Recall reaction failed: ${err}`);
1561
+ } finally {
1562
+ deleteTurnState(sessionKey);
1563
+ }
1564
+ });
1565
+ }
1566
+
84
1567
  // ── Startup Recovery Service ───────────────────────────────────
85
- // Replays pending WAL entries on plugin startup.
86
1568
  api.registerService({
87
1569
  id: "palaia-recovery",
88
1570
  start: async () => {