@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.
@@ -0,0 +1,823 @@
1
+ /**
2
+ * Lifecycle hooks for the Palaia OpenClaw plugin.
3
+ *
4
+ * - before_prompt_build: Query-based contextual recall (Issue #65).
5
+ * Returns appendSystemContext with brain 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
+ * Phase 1.5: Decomposed from monolithic hooks.ts into focused modules.
14
+ * This file is the orchestrator — it imports from state, recall, capture,
15
+ * and reactions modules, then registers the hooks with the API.
16
+ */
17
+
18
+ import { run, runJson, recover, type RunnerOpts, getEmbedServerManager } from "../runner.js";
19
+ import type { PalaiaPluginConfig } from "../config.js";
20
+ import type { OpenClawPluginApi } from "../types.js";
21
+
22
+ // ── Re-export everything for backward compatibility ──────────────────────
23
+
24
+ // State exports
25
+ export {
26
+ type PluginState,
27
+ type TurnState,
28
+ turnStateBySession,
29
+ lastInboundMessageByChannel,
30
+ REACTION_SUPPORTED_PROVIDERS,
31
+ pruneStaleEntries,
32
+ getOrCreateTurnState,
33
+ deleteTurnState,
34
+ resetTurnState,
35
+ extractTargetFromSessionKey,
36
+ extractChannelFromSessionKey,
37
+ extractSlackChannelIdFromSessionKey,
38
+ extractChannelIdFromEvent,
39
+ resolveSessionKeyFromCtx,
40
+ isValidScope,
41
+ sanitizeScope,
42
+ resolvePerAgentContext,
43
+ formatShortDate,
44
+ formatStatusResponse,
45
+ loadPluginState,
46
+ savePluginState,
47
+ } from "./state.js";
48
+
49
+ // Recall exports
50
+ export {
51
+ type QueryResult,
52
+ type Message,
53
+ type RankedEntry,
54
+ isEntryRelevant,
55
+ buildFootnote,
56
+ checkNudges,
57
+ extractMessageTexts,
58
+ getLastUserMessage,
59
+ stripChannelEnvelope,
60
+ stripSystemPrefix,
61
+ buildRecallQuery,
62
+ rerankByTypeWeight,
63
+ } from "./recall.js";
64
+
65
+ // Capture exports
66
+ export {
67
+ type PalaiaHint,
68
+ type ExtractionResult,
69
+ type CachedProject,
70
+ parsePalaiaHints,
71
+ resetProjectCache,
72
+ loadProjects,
73
+ getEmbeddedPiAgent,
74
+ resetEmbeddedPiAgentLoader,
75
+ setEmbeddedPiAgentLoader,
76
+ buildExtractionPrompt,
77
+ resetCaptureModelFallbackWarning,
78
+ resolveCaptureModel,
79
+ trimToRecentExchanges,
80
+ extractWithLLM,
81
+ isNoiseContent,
82
+ shouldAttemptCapture,
83
+ extractSignificance,
84
+ stripPalaiaInjectedContext,
85
+ } from "./capture.js";
86
+
87
+ // Reaction exports
88
+ export {
89
+ sendReaction,
90
+ resetSlackTokenCache,
91
+ } from "./reactions.js";
92
+
93
+ // ── Internal imports for hook registration ───────────────────────────────
94
+
95
+ import {
96
+ pruneStaleEntries,
97
+ getOrCreateTurnState,
98
+ deleteTurnState,
99
+ resetTurnState,
100
+ extractTargetFromSessionKey,
101
+ extractChannelFromSessionKey,
102
+ extractSlackChannelIdFromSessionKey,
103
+ extractChannelIdFromEvent,
104
+ resolveSessionKeyFromCtx,
105
+ isValidScope,
106
+ sanitizeScope,
107
+ resolvePerAgentContext,
108
+ formatStatusResponse,
109
+ loadPluginState,
110
+ savePluginState,
111
+ turnStateBySession,
112
+ lastInboundMessageByChannel,
113
+ REACTION_SUPPORTED_PROVIDERS,
114
+ } from "./state.js";
115
+
116
+ import {
117
+ type QueryResult,
118
+ checkNudges,
119
+ extractMessageTexts,
120
+ buildRecallQuery,
121
+ rerankByTypeWeight,
122
+ } from "./recall.js";
123
+
124
+ import {
125
+ parsePalaiaHints,
126
+ loadProjects,
127
+ extractWithLLM,
128
+ resolveCaptureModel,
129
+ shouldAttemptCapture,
130
+ extractSignificance,
131
+ stripPalaiaInjectedContext,
132
+ trimToRecentExchanges,
133
+ setLogger as setCaptureLogger,
134
+ getLlmImportFailureLogged,
135
+ setLlmImportFailureLogged,
136
+ getCaptureModelFailoverWarned,
137
+ setCaptureModelFailoverWarned,
138
+ type ExtractionResult,
139
+ type PalaiaHint,
140
+ } from "./capture.js";
141
+
142
+ import {
143
+ sendReaction,
144
+ resetSlackTokenCache,
145
+ setLogger as setReactionsLogger,
146
+ } from "./reactions.js";
147
+
148
+ import {
149
+ loadPriorities,
150
+ resolvePriorities,
151
+ filterBlocked,
152
+ } from "../priorities.js";
153
+
154
+ // ============================================================================
155
+ // Logger (Issue: api.logger integration)
156
+ // ============================================================================
157
+
158
+ /** Module-level logger — defaults to console, replaced by api.logger in registerHooks. */
159
+ let logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void } = {
160
+ info: (...args: any[]) => console.log(...args),
161
+ warn: (...args: any[]) => console.warn(...args),
162
+ };
163
+
164
+ // ============================================================================
165
+ // Helper
166
+ // ============================================================================
167
+
168
+ function buildRunnerOpts(config: PalaiaPluginConfig, overrides?: { workspace?: string }): RunnerOpts {
169
+ return {
170
+ binaryPath: config.binaryPath,
171
+ workspace: overrides?.workspace || config.workspace,
172
+ timeoutMs: config.timeoutMs,
173
+ };
174
+ }
175
+
176
+ // ============================================================================
177
+ // Hook registration
178
+ // ============================================================================
179
+
180
+ /**
181
+ * Register lifecycle hooks on the plugin API.
182
+ */
183
+ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig): void {
184
+ // Store api.logger for module-wide use (integrates into OpenClaw log system)
185
+ if (api.logger && typeof api.logger.info === "function") {
186
+ logger = api.logger;
187
+ setCaptureLogger(api.logger);
188
+ setReactionsLogger(api.logger);
189
+ }
190
+
191
+ const opts = buildRunnerOpts(config);
192
+
193
+ // ── Startup checks (H-2, H-3, captureModel validation) ────────
194
+ (async () => {
195
+ // H-2: Warn if no agent is configured
196
+ if (!process.env.PALAIA_AGENT) {
197
+ try {
198
+ const statusOut = await run(["config", "get", "agent"], { ...opts, timeoutMs: 3000 });
199
+ if (!statusOut.trim()) {
200
+ logger.warn(
201
+ "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
202
+ "Auto-captured entries will have no agent attribution."
203
+ );
204
+ }
205
+ } catch {
206
+ logger.warn(
207
+ "[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
208
+ "Auto-captured entries will have no agent attribution."
209
+ );
210
+ }
211
+ }
212
+
213
+ // H-3: Warn if no embedding provider beyond BM25
214
+ try {
215
+ const statusJson = await run(["status", "--json"], { ...opts, timeoutMs: 5000 });
216
+ if (statusJson && statusJson.trim()) {
217
+ const status = JSON.parse(statusJson);
218
+ // embedding_chain can be at top level OR nested under config
219
+ const chain = status.embedding_chain
220
+ || status.embeddingChain
221
+ || status.config?.embedding_chain
222
+ || status.config?.embeddingChain
223
+ || [];
224
+ const hasSemanticProvider = Array.isArray(chain)
225
+ ? chain.some((p: string) => p !== "bm25")
226
+ : false;
227
+ // Also check embedding_provider as a fallback signal
228
+ const hasProviderConfig = !!(
229
+ status.embedding_provider
230
+ || status.config?.embedding_provider
231
+ );
232
+ if (!hasSemanticProvider && !hasProviderConfig) {
233
+ logger.warn(
234
+ "[palaia] No embedding provider configured. Semantic search is inactive (BM25 keyword-only). " +
235
+ "Run 'pip install palaia[fastembed]' and 'palaia doctor --fix' for better recall quality."
236
+ );
237
+ }
238
+ }
239
+ // If statusJson is empty/null, skip warning (CLI may not be available)
240
+ } catch {
241
+ // Non-fatal — status check failed, skip warning (avoid false positive)
242
+ }
243
+
244
+ // Validate captureModel auth at plugin startup via modelAuth API
245
+ if (config.captureModel && api.runtime?.modelAuth) {
246
+ try {
247
+ const resolved = resolveCaptureModel(api.config, config.captureModel);
248
+ if (resolved?.provider) {
249
+ const key = await api.runtime.modelAuth.resolveApiKeyForProvider({ provider: resolved.provider, cfg: api.config });
250
+ if (!key) {
251
+ logger.warn(`[palaia] captureModel provider "${resolved.provider}" has no API key — auto-capture LLM extraction will fail`);
252
+ }
253
+ }
254
+ } catch { /* non-fatal */ }
255
+ }
256
+ })();
257
+
258
+ // ── /palaia status command ─────────────────────────────────────
259
+ api.registerCommand({
260
+ name: "palaia-status",
261
+ description: "Show Palaia memory status",
262
+ async handler(_args: string) {
263
+ try {
264
+ const state = await loadPluginState(config.workspace);
265
+
266
+ let stats: Record<string, unknown> = {};
267
+ try {
268
+ const statsOutput = await run(["status", "--json"], opts);
269
+ stats = JSON.parse(statsOutput || "{}");
270
+ } catch {
271
+ // Non-fatal
272
+ }
273
+
274
+ return { text: formatStatusResponse(state, stats, config) };
275
+ } catch (error) {
276
+ return { text: `Palaia status error: ${error}` };
277
+ }
278
+ },
279
+ });
280
+
281
+ // ── message_received (capture inbound message ID for reactions) ─
282
+ api.on("message_received", (event: any, ctx: any) => {
283
+ try {
284
+ const messageId = event?.metadata?.messageId;
285
+ const provider = event?.metadata?.provider;
286
+
287
+ // ctx.channelId returns the provider name ("slack"), NOT the actual channel ID.
288
+ // ctx.sessionKey is null during message_received.
289
+ // Extract the real channel ID from event.metadata.to / ctx.conversationId.
290
+ const channelId = extractChannelIdFromEvent(event, ctx)
291
+ ?? (resolveSessionKeyFromCtx(ctx) ? extractSlackChannelIdFromSessionKey(resolveSessionKeyFromCtx(ctx)!) : undefined);
292
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
293
+
294
+
295
+ if (messageId && channelId && provider && REACTION_SUPPORTED_PROVIDERS.has(provider)) {
296
+ // Normalize channelId to UPPERCASE for consistent lookups
297
+ // (extractSlackChannelIdFromSessionKey returns uppercase)
298
+ const normalizedChannelId = String(channelId).toUpperCase();
299
+ lastInboundMessageByChannel.set(normalizedChannelId, {
300
+ messageId: String(messageId),
301
+ provider,
302
+ timestamp: Date.now(),
303
+ });
304
+
305
+ // Also populate turnState if sessionKey is available
306
+ if (sessionKey) {
307
+ const turnState = getOrCreateTurnState(sessionKey);
308
+ turnState.lastInboundMessageId = String(messageId);
309
+ turnState.lastInboundChannelId = normalizedChannelId;
310
+ turnState.channelProvider = provider;
311
+ }
312
+ }
313
+ } catch {
314
+ // Non-fatal — never block message flow
315
+ }
316
+ });
317
+
318
+ // ── before_prompt_build (Issue #65: Query-based Recall) ────────
319
+ if (config.memoryInject) {
320
+ api.on("before_prompt_build", async (event: any, ctx: any) => {
321
+ // Prune stale entries to prevent memory leaks from crashed sessions (C-2)
322
+ pruneStaleEntries();
323
+
324
+ // Per-agent workspace resolution (Issue #111)
325
+ const resolved = resolvePerAgentContext(ctx, config);
326
+ const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
327
+
328
+ try {
329
+ // Load and resolve priorities (Issue #121)
330
+ const prio = await loadPriorities(resolved.workspace);
331
+ const project = config.captureProject || undefined;
332
+ const resolvedPrio = resolvePriorities(prio, {
333
+ recallTypeWeight: config.recallTypeWeight,
334
+ recallMinScore: config.recallMinScore,
335
+ maxInjectedChars: config.maxInjectedChars,
336
+ tier: config.tier,
337
+ }, resolved.agentId, project);
338
+
339
+ const maxChars = resolvedPrio.maxInjectedChars || 4000;
340
+ const limit = Math.min(config.maxResults || 10, 20);
341
+ let entries: QueryResult["results"] = [];
342
+
343
+ if (config.recallMode === "query") {
344
+ const userMessage = event.messages
345
+ ? buildRecallQuery(event.messages)
346
+ : (event.prompt || null);
347
+
348
+ if (userMessage && userMessage.length >= 5) {
349
+ // Try embed server first (fast path: ~0.5s), then CLI fallback (~3-14s)
350
+ let serverQueried = false;
351
+ if (config.embeddingServer) {
352
+ try {
353
+ const mgr = getEmbedServerManager(hookOpts);
354
+ // If embed server workspace differs from resolved workspace, skip server and use CLI
355
+ const serverWorkspace = hookOpts.workspace;
356
+ const embedOpts = buildRunnerOpts(config);
357
+ if (serverWorkspace !== embedOpts.workspace) {
358
+ logger.info(`[palaia] Embed server workspace mismatch (agent=${resolved.workspace}), falling back to CLI`);
359
+ } else {
360
+ const resp = await mgr.query({
361
+ text: userMessage,
362
+ top_k: limit,
363
+ include_cold: resolvedPrio.tier === "all",
364
+ }, config.timeoutMs || 3000);
365
+ if (resp?.result?.results && Array.isArray(resp.result.results)) {
366
+ entries = resp.result.results;
367
+ serverQueried = true;
368
+ }
369
+ }
370
+ } catch (serverError) {
371
+ logger.warn(`[palaia] Embed server query failed, falling back to CLI: ${serverError}`);
372
+ }
373
+ }
374
+
375
+ // CLI fallback
376
+ if (!serverQueried) {
377
+ try {
378
+ const queryArgs: string[] = ["query", userMessage, "--limit", String(limit)];
379
+ if (resolvedPrio.tier === "all") {
380
+ queryArgs.push("--all");
381
+ }
382
+ const result = await runJson<QueryResult>(queryArgs, { ...hookOpts, timeoutMs: 15000 });
383
+ if (result && Array.isArray(result.results)) {
384
+ entries = result.results;
385
+ }
386
+ } catch (queryError) {
387
+ logger.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ // Fallback: list mode (no emoji — list-based recall is not query-relevant)
394
+ let isListFallback = false;
395
+ if (entries.length === 0) {
396
+ isListFallback = true;
397
+ try {
398
+ const listArgs: string[] = ["list"];
399
+ if (resolvedPrio.tier === "all") {
400
+ listArgs.push("--all");
401
+ } else {
402
+ listArgs.push("--tier", resolvedPrio.tier || "hot");
403
+ }
404
+ const result = await runJson<QueryResult>(listArgs, hookOpts);
405
+ if (result && Array.isArray(result.results)) {
406
+ entries = result.results;
407
+ }
408
+ } catch {
409
+ return;
410
+ }
411
+ }
412
+
413
+ if (entries.length === 0) return;
414
+
415
+ // Apply type-weighted reranking and blocked filtering (Issue #121)
416
+ const rankedRaw = rerankByTypeWeight(entries, resolvedPrio.recallTypeWeight);
417
+ const ranked = filterBlocked(rankedRaw, resolvedPrio.blocked);
418
+
419
+ // Build context string with char budget (compact format for token efficiency)
420
+ const SCOPE_SHORT: Record<string, string> = { team: "t", private: "p", public: "pub" };
421
+ const TYPE_SHORT: Record<string, string> = { memory: "m", process: "pr", task: "tk" };
422
+
423
+ let text = "## Active Memory (Palaia)\n\n";
424
+ let chars = text.length;
425
+
426
+ for (const entry of ranked) {
427
+ const scopeKey = SCOPE_SHORT[entry.scope] || entry.scope;
428
+ const typeKey = TYPE_SHORT[entry.type] || entry.type;
429
+ const prefix = `[${scopeKey}/${typeKey}]`;
430
+
431
+ // If body starts with title (common), skip title to save tokens
432
+ let line: string;
433
+ if (entry.body.toLowerCase().startsWith(entry.title.toLowerCase())) {
434
+ line = `${prefix} ${entry.body}\n\n`;
435
+ } else {
436
+ line = `${prefix} ${entry.title}\n${entry.body}\n\n`;
437
+ }
438
+
439
+ if (chars + line.length > maxChars) break;
440
+ text += line;
441
+ chars += line.length;
442
+ }
443
+
444
+ // Build nudge text and check remaining budget before appending
445
+ 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.";
446
+ let nudgeContext = "";
447
+ try {
448
+ const pluginState = await loadPluginState(resolved.workspace);
449
+ pluginState.successfulRecalls++;
450
+ if (!pluginState.firstRecallTimestamp) {
451
+ pluginState.firstRecallTimestamp = new Date().toISOString();
452
+ }
453
+ const { nudges } = checkNudges(pluginState);
454
+ if (nudges.length > 0) {
455
+ nudgeContext = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
456
+ }
457
+ await savePluginState(pluginState, resolved.workspace);
458
+ } catch {
459
+ // Non-fatal
460
+ }
461
+
462
+ // Before adding nudges, check remaining budget
463
+ const nudgeText = USAGE_NUDGE + "\n\n" + nudgeContext;
464
+ if (chars + nudgeText.length <= maxChars) {
465
+ text += nudgeText;
466
+ }
467
+ // If nudges don't fit, skip them — the recall content is more important
468
+
469
+ // Track recall in session-isolated turn state for emoji reactions
470
+ // Only flag recall as meaningful if at least one result scores above threshold
471
+ // List-fallback never triggers brain emoji (not query-relevant)
472
+ const hasRelevantRecall = !isListFallback && entries.some(
473
+ (e) => typeof e.score === "number" && e.score >= resolvedPrio.recallMinScore,
474
+ );
475
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
476
+ if (sessionKey && hasRelevantRecall) {
477
+ const turnState = getOrCreateTurnState(sessionKey);
478
+ turnState.recallOccurred = true;
479
+
480
+ // Populate channel info — prefer event metadata, fall back to sessionKey
481
+ const provider = extractChannelFromSessionKey(sessionKey);
482
+ if (provider) turnState.channelProvider = provider;
483
+ const slackChannel = extractChannelIdFromEvent(event, ctx)
484
+ ?? extractSlackChannelIdFromSessionKey(sessionKey);
485
+ if (slackChannel) turnState.lastInboundChannelId = slackChannel;
486
+
487
+ // Try to get the inbound message ID from the message_received store
488
+ if (slackChannel) {
489
+ const inbound = lastInboundMessageByChannel.get(slackChannel);
490
+ if (inbound && (Date.now() - inbound.timestamp) < 30_000) {
491
+ turnState.lastInboundMessageId = inbound.messageId;
492
+ }
493
+ }
494
+ }
495
+
496
+ // Return prependContext + appendSystemContext for recall emoji
497
+ return {
498
+ prependContext: text + nudgeContext,
499
+ appendSystemContext: config.showMemorySources
500
+ ? "You used Palaia memory in this turn. Add \u{1f9e0} at the very end of your response (after everything else, on its own line)."
501
+ : undefined,
502
+ };
503
+ } catch (error) {
504
+ logger.warn(`[palaia] Memory injection failed: ${error}`);
505
+ }
506
+ });
507
+ }
508
+
509
+ // ── message_sending (Issue #81: Hint stripping) ──────────────────
510
+ api.on("message_sending", (_event: any, _ctx: any) => {
511
+ const content = _event?.content;
512
+ if (typeof content !== "string") return;
513
+
514
+ const { hints, cleanedText } = parsePalaiaHints(content);
515
+ if (hints.length > 0) {
516
+ return { content: cleanedText };
517
+ }
518
+ });
519
+
520
+ // ── agent_end (Issue #64 + #81: Auto-Capture with Metadata + Reactions) ───
521
+ if (config.autoCapture) {
522
+ api.on("agent_end", async (event: any, ctx: any) => {
523
+ // Resolve session key for turn state
524
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
525
+
526
+ // Per-agent workspace resolution (Issue #111)
527
+ const resolved = resolvePerAgentContext(ctx, config);
528
+ const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
529
+
530
+ if (!event.success || !event.messages || event.messages.length === 0) {
531
+ return;
532
+ }
533
+
534
+ try {
535
+ const agentName = resolved.agentId;
536
+
537
+ const allTexts = extractMessageTexts(event.messages);
538
+
539
+ const userTurns = allTexts.filter((t) => t.role === "user").length;
540
+ if (userTurns < config.captureMinTurns) {
541
+ return;
542
+ }
543
+
544
+ // Parse capture hints from all messages (Issue #81)
545
+ const collectedHints: PalaiaHint[] = [];
546
+ for (const t of allTexts) {
547
+ const { hints } = parsePalaiaHints(t.text);
548
+ collectedHints.push(...hints);
549
+ }
550
+
551
+ // Strip Palaia-injected recall context from user messages to prevent feedback loop.
552
+ // The recall block is prepended to user messages by before_prompt_build.
553
+ // Without stripping, auto-capture would re-capture previously recalled memories.
554
+ const cleanedTexts = allTexts.map(t =>
555
+ t.role === "user"
556
+ ? { ...t, text: stripPalaiaInjectedContext(t.text) }
557
+ : t
558
+ );
559
+
560
+ // Only extract from recent exchanges — full history causes LLM timeouts
561
+ // and dilutes extraction quality
562
+ const recentTexts = trimToRecentExchanges(cleanedTexts);
563
+
564
+ // Build exchange text from recent window only
565
+ const exchangeParts: string[] = [];
566
+ for (const t of recentTexts) {
567
+ const { cleanedText } = parsePalaiaHints(t.text);
568
+ exchangeParts.push(`[${t.role}]: ${cleanedText}`);
569
+ }
570
+ const exchangeText = exchangeParts.join("\n");
571
+
572
+ if (!shouldAttemptCapture(exchangeText)) {
573
+ return;
574
+ }
575
+
576
+ const knownProjects = await loadProjects(hookOpts);
577
+
578
+ // Helper: build CLI args with metadata
579
+ const buildWriteArgs = (
580
+ content: string,
581
+ type: string,
582
+ tags: string[],
583
+ itemProject?: string | null,
584
+ itemScope?: string | null,
585
+ ): string[] => {
586
+ const args: string[] = [
587
+ "write",
588
+ content,
589
+ "--type", type,
590
+ "--tags", tags.join(",") || "auto-capture",
591
+ ];
592
+
593
+ // Scope guardrail: config.captureScope overrides everything; otherwise max team (no public)
594
+ const scope = config.captureScope
595
+ ? sanitizeScope(config.captureScope, "team", true)
596
+ : sanitizeScope(itemScope, "team", false);
597
+ args.push("--scope", scope);
598
+
599
+ const project = config.captureProject || itemProject;
600
+ if (project) {
601
+ args.push("--project", project);
602
+ }
603
+
604
+ if (agentName) {
605
+ args.push("--agent", agentName);
606
+ }
607
+
608
+ return args;
609
+ };
610
+
611
+ // Helper: store LLM extraction results
612
+ const storeLLMResults = async (results: ExtractionResult[]) => {
613
+ for (const r of results) {
614
+ if (r.significance >= config.captureMinSignificance) {
615
+ const hintForProject = collectedHints.find((h) => h.project);
616
+ const hintForScope = collectedHints.find((h) => h.scope);
617
+
618
+ const effectiveProject = hintForProject?.project || r.project;
619
+ const effectiveScope = hintForScope?.scope || r.scope;
620
+
621
+ // Project validation: reject unknown projects
622
+ let validatedProject = effectiveProject;
623
+ if (validatedProject && knownProjects.length > 0) {
624
+ const isKnown = knownProjects.some(
625
+ (p) => p.name.toLowerCase() === validatedProject!.toLowerCase(),
626
+ );
627
+ if (!isKnown) {
628
+ logger.info(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
629
+ validatedProject = null;
630
+ }
631
+ }
632
+
633
+ // Always include auto-capture tag for GC identification
634
+ const tags = [...r.tags];
635
+ if (!tags.includes("auto-capture")) tags.push("auto-capture");
636
+
637
+ const args = buildWriteArgs(
638
+ r.content,
639
+ r.type,
640
+ tags,
641
+ validatedProject,
642
+ effectiveScope,
643
+ );
644
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
645
+ logger.info(
646
+ `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
647
+ );
648
+ }
649
+ }
650
+ };
651
+
652
+ // LLM-based extraction (primary)
653
+ let llmHandled = false;
654
+ try {
655
+ const results = await extractWithLLM(event.messages, api.config, {
656
+ captureModel: config.captureModel,
657
+ }, knownProjects);
658
+
659
+ await storeLLMResults(results);
660
+ llmHandled = true;
661
+ } catch (llmError) {
662
+ // Check if this is a model-availability error (not a generic import failure)
663
+ const errStr = String(llmError);
664
+ const isModelError = /FailoverError|Unknown model|unknown model|401|403|model.*not found|not_found|model_not_found/i.test(errStr);
665
+
666
+ if (isModelError && config.captureModel) {
667
+ // captureModel is broken — try primary model as fallback
668
+ if (!getCaptureModelFailoverWarned()) {
669
+ setCaptureModelFailoverWarned(true);
670
+ logger.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
671
+ }
672
+ try {
673
+ // Retry without captureModel -> resolveCaptureModel will use primary model
674
+ const fallbackResults = await extractWithLLM(event.messages, api.config, {
675
+ captureModel: undefined,
676
+ }, knownProjects);
677
+ await storeLLMResults(fallbackResults);
678
+ llmHandled = true;
679
+ } catch (fallbackError) {
680
+ if (!getLlmImportFailureLogged()) {
681
+ logger.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
682
+ setLlmImportFailureLogged(true);
683
+ }
684
+ }
685
+ } else {
686
+ if (!getLlmImportFailureLogged()) {
687
+ logger.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
688
+ setLlmImportFailureLogged(true);
689
+ }
690
+ }
691
+ }
692
+
693
+ // Rule-based fallback (max 1 per turn)
694
+ if (!llmHandled) {
695
+ let captureData: { tags: string[]; type: string; summary: string } | null = null;
696
+
697
+ if (config.captureFrequency === "significant") {
698
+ const significance = extractSignificance(exchangeText);
699
+ if (!significance) {
700
+ return;
701
+ }
702
+ captureData = significance;
703
+ } else {
704
+ const summary = exchangeParts
705
+ .slice(-4)
706
+ .map((p) => p.slice(0, 200))
707
+ .join(" | ")
708
+ .slice(0, 500);
709
+ captureData = { tags: ["auto-capture"], type: "memory", summary };
710
+ }
711
+
712
+ // Always include auto-capture tag for GC identification
713
+ if (!captureData.tags.includes("auto-capture")) {
714
+ captureData.tags.push("auto-capture");
715
+ }
716
+
717
+ const hintForProject = collectedHints.find((h) => h.project);
718
+ const hintForScope = collectedHints.find((h) => h.scope);
719
+
720
+ const args = buildWriteArgs(
721
+ captureData.summary,
722
+ captureData.type,
723
+ captureData.tags,
724
+ hintForProject?.project,
725
+ hintForScope?.scope,
726
+ );
727
+
728
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
729
+ logger.info(
730
+ `[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
731
+ );
732
+ }
733
+
734
+ // Mark that capture occurred in this turn
735
+ if (sessionKey) {
736
+ const turnState = getOrCreateTurnState(sessionKey);
737
+ turnState.capturedInThisTurn = true;
738
+ } else {
739
+ }
740
+ } catch (error) {
741
+ logger.warn(`[palaia] Auto-capture failed: ${error}`);
742
+ }
743
+
744
+ // ── Emoji Reactions (Issue #87) ──────────────────────────
745
+ // Send reactions AFTER capture completes, using turn state.
746
+ if (sessionKey) {
747
+ try {
748
+ const turnState = turnStateBySession.get(sessionKey);
749
+ if (turnState) {
750
+ const provider = turnState.channelProvider
751
+ || extractChannelFromSessionKey(sessionKey)
752
+ || (ctx?.channelId as string | undefined);
753
+ const channelId = turnState.lastInboundChannelId
754
+ || extractChannelIdFromEvent(event, ctx)
755
+ || extractSlackChannelIdFromSessionKey(sessionKey);
756
+ const messageId = turnState.lastInboundMessageId;
757
+
758
+
759
+ if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
760
+ // Capture confirmation: floppy_disk
761
+ if (turnState.capturedInThisTurn && config.showCaptureConfirm) {
762
+ await sendReaction(channelId, messageId, "floppy_disk", provider);
763
+ }
764
+
765
+ // Recall indicator: brain
766
+ if (turnState.recallOccurred && config.showMemorySources) {
767
+ await sendReaction(channelId, messageId, "brain", provider);
768
+ }
769
+ } else {
770
+ }
771
+ }
772
+ } catch (reactionError) {
773
+ logger.warn(`[palaia] Reaction sending failed: ${reactionError}`);
774
+ } finally {
775
+ // Always clean up turn state
776
+ deleteTurnState(sessionKey);
777
+ }
778
+ }
779
+ });
780
+ }
781
+
782
+ // ── agent_end: Recall-only reactions (when autoCapture is off) ─
783
+ if (!config.autoCapture && config.showMemorySources) {
784
+ api.on("agent_end", async (_event: any, ctx: any) => {
785
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
786
+ if (!sessionKey) return;
787
+
788
+ try {
789
+ const turnState = turnStateBySession.get(sessionKey);
790
+ if (turnState?.recallOccurred) {
791
+ const provider = turnState.channelProvider
792
+ || extractChannelFromSessionKey(sessionKey);
793
+ const channelId = turnState.lastInboundChannelId
794
+ || extractChannelIdFromEvent(_event, ctx)
795
+ || extractSlackChannelIdFromSessionKey(sessionKey);
796
+ const messageId = turnState.lastInboundMessageId;
797
+
798
+ if (provider && REACTION_SUPPORTED_PROVIDERS.has(provider) && channelId && messageId) {
799
+ await sendReaction(channelId, messageId, "brain", provider);
800
+ }
801
+ }
802
+ } catch (err) {
803
+ logger.warn(`[palaia] Recall reaction failed: ${err}`);
804
+ } finally {
805
+ deleteTurnState(sessionKey);
806
+ }
807
+ });
808
+ }
809
+
810
+ // ── Startup Recovery Service ───────────────────────────────────
811
+ api.registerService({
812
+ id: "palaia-recovery",
813
+ start: async () => {
814
+ const result = await recover(opts);
815
+ if (result.replayed > 0) {
816
+ logger.info(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
817
+ }
818
+ if (result.errors > 0) {
819
+ logger.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
820
+ }
821
+ },
822
+ });
823
+ }