@byte5ai/palaia 2.3.5 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -144,7 +144,10 @@ function resolveExtensionAPIPath(): string | null {
144
144
 
145
145
  // Strategy 3: Sibling in global node_modules (plugin installed alongside openclaw)
146
146
  try {
147
- const thisFile = typeof __dirname !== "undefined" ? __dirname : path.dirname(new URL(import.meta.url).pathname);
147
+ // __dirname is always available in CJS (our tsconfig module); the ESM
148
+ // fallback via import.meta.url is kept for jiti/ESM loaders at runtime.
149
+ // @ts-expect-error import.meta is valid at runtime under jiti/ESM but TS module=commonjs rejects it
150
+ const thisFile: string = typeof __dirname !== "undefined" ? __dirname : path.dirname(new URL(import.meta.url).pathname);
148
151
  // Walk up from plugin src/dist to node_modules, then into openclaw
149
152
  let dir = thisFile;
150
153
  for (let i = 0; i < 6; i++) {
@@ -469,11 +472,11 @@ export async function extractWithLLM(
469
472
  }
470
473
 
471
474
  const allTexts = extractMessageTexts(messages);
472
- // Strip Palaia-injected recall context from user messages to prevent feedback loop
475
+ // Strip Palaia-injected recall context and private blocks from user messages
473
476
  const cleanedTexts = allTexts.map(t =>
474
477
  t.role === "user"
475
- ? { ...t, text: stripPalaiaInjectedContext(t.text) }
476
- : t
478
+ ? { ...t, text: stripPrivateBlocks(stripPalaiaInjectedContext(t.text)) }
479
+ : { ...t, text: stripPrivateBlocks(t.text) }
477
480
  );
478
481
  // Only extract from recent exchanges — full history causes LLM timeouts
479
482
  // and dilutes extraction quality
@@ -736,5 +739,19 @@ export function stripPalaiaInjectedContext(text: string): string {
736
739
  // Pattern: "## Active Memory (Palaia)" ... "[palaia] auto-capture=on..." + optional trailing newlines
737
740
  // The nudge line is always present and marks the end of the injected block
738
741
  const PALAIA_BLOCK_RE = /## Active Memory \(Palaia\)[\s\S]*?\[palaia\][^\n]*\n*/;
739
- return text.replace(PALAIA_BLOCK_RE, '').trim();
742
+ // Also strip Session Briefing blocks
743
+ const BRIEFING_BLOCK_RE = /## Session Briefing \(Palaia\)[\s\S]*?(?=\n##|\n\n\n|$)/;
744
+ return text
745
+ .replace(PALAIA_BLOCK_RE, '')
746
+ .replace(BRIEFING_BLOCK_RE, '')
747
+ .trim();
748
+ }
749
+
750
+ /**
751
+ * Strip <private>...</private> blocks from text.
752
+ * Content inside private tags is excluded from memory capture.
753
+ * Inspired by claude-mem's privacy marker system.
754
+ */
755
+ export function stripPrivateBlocks(text: string): string {
756
+ return text.replace(/<private>[\s\S]*?<\/private>/gi, '').trim();
740
757
  }
@@ -119,6 +119,8 @@ import {
119
119
  extractMessageTexts,
120
120
  buildRecallQuery,
121
121
  rerankByTypeWeight,
122
+ formatEntryLine,
123
+ shouldUseCompactMode,
122
124
  } from "./recall.js";
123
125
 
124
126
  import {
@@ -151,6 +153,9 @@ import {
151
153
  filterBlocked,
152
154
  } from "../priorities.js";
153
155
 
156
+ import { formatBriefing } from "./session.js";
157
+ import { getOrCreateSessionState } from "./state.js";
158
+
154
159
  // ============================================================================
155
160
  // Logger (Issue: api.logger integration)
156
161
  // ============================================================================
@@ -190,6 +195,10 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
190
195
 
191
196
  const opts = buildRunnerOpts(config);
192
197
 
198
+ // Note: Session lifecycle hooks (session_start, session_end, before_reset,
199
+ // llm_input, llm_output, after_tool_call) are registered in index.ts entry
200
+ // point BEFORE this function, so they work for both ContextEngine and legacy paths.
201
+
193
202
  // ── Startup checks (H-2, H-3, captureModel validation) ────────
194
203
  (async () => {
195
204
  // H-2: Warn if no agent is configured
@@ -326,7 +335,7 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
326
335
  }
327
336
  });
328
337
 
329
- // ── before_prompt_build (Issue #65: Query-based Recall) ────────
338
+ // ── before_prompt_build (Issue #65: Query-based Recall + v3.0 Session Briefing) ──
330
339
  if (config.memoryInject) {
331
340
  api.on("before_prompt_build", async (event: any, ctx: any) => {
332
341
  // Prune stale entries to prevent memory leaks from crashed sessions (C-2)
@@ -337,6 +346,34 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
337
346
  const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
338
347
 
339
348
  try {
349
+ // ── Session Briefing Injection (v3.0) ─────────────────────
350
+ // If a session briefing is pending (from session_start or model switch),
351
+ // prepend it to the recall context for seamless session continuity.
352
+ let briefingText = "";
353
+ let briefingSummary: string | null = null; // Kept for smart query fallback
354
+ const sessionKey = resolveSessionKeyFromCtx(ctx);
355
+ if (sessionKey) {
356
+ const sessState = getOrCreateSessionState(sessionKey);
357
+ // Wait for session_start briefing load (max 3s to avoid blocking)
358
+ if (sessState.briefingReady) {
359
+ await Promise.race([
360
+ sessState.briefingReady,
361
+ new Promise<void>(r => setTimeout(r, 3000)),
362
+ ]);
363
+ }
364
+ // Capture summary BEFORE clearing, for smart query fallback below
365
+ briefingSummary = sessState.pendingBriefing?.summary ?? null;
366
+ if (sessState.pendingBriefing && !sessState.briefingDelivered) {
367
+ briefingText = formatBriefing(sessState.pendingBriefing, config.sessionBriefingMaxChars);
368
+ sessState.briefingDelivered = true;
369
+ // Clear pending briefing after delivery (unless model switch re-triggers)
370
+ if (!sessState.modelSwitchDetected) {
371
+ sessState.pendingBriefing = null;
372
+ }
373
+ sessState.modelSwitchDetected = false;
374
+ }
375
+ }
376
+
340
377
  // Load and resolve priorities (Issue #121)
341
378
  const prio = await loadPriorities(resolved.workspace);
342
379
  const project = config.captureProject || undefined;
@@ -347,15 +384,25 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
347
384
  tier: config.tier,
348
385
  }, resolved.agentId, project);
349
386
 
350
- const maxChars = resolvedPrio.maxInjectedChars || 4000;
387
+ // Reduce recall budget by briefing size
388
+ const maxChars = Math.max((resolvedPrio.maxInjectedChars || 4000) - briefingText.length, 500);
351
389
  const limit = Math.min(config.maxResults || 10, 20);
352
390
  let entries: QueryResult["results"] = [];
353
391
 
354
392
  if (config.recallMode === "query") {
355
- const userMessage = event.messages
393
+ let userMessage = event.messages
356
394
  ? buildRecallQuery(event.messages)
357
395
  : (event.prompt || null);
358
396
 
397
+ // ── Smart Query Fallback (v3.0) ───────────────────────
398
+ // If query is too short or matches a continuation pattern,
399
+ // use the session summary as query for better recall results.
400
+ const CONTINUATION_PATTERN = /^(ja|ok|weiter|mach|genau|do it|yes|continue|go|proceed|sure|klar|passt|yep|yup|exactly|right)\b/i;
401
+ if (briefingSummary && userMessage && (userMessage.length < 10 || CONTINUATION_PATTERN.test(userMessage.trim()))) {
402
+ userMessage = briefingSummary.slice(0, 500);
403
+ logger.info("[palaia] Smart query fallback: using session summary as recall query");
404
+ }
405
+
359
406
  if (userMessage && userMessage.length >= 5) {
360
407
  // Try embed server first (fast path: ~0.5s), then CLI fallback (~3-14s)
361
408
  let serverQueried = false;
@@ -372,6 +419,7 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
372
419
  text: userMessage,
373
420
  top_k: limit,
374
421
  include_cold: resolvedPrio.tier === "all",
422
+ ...(resolvedPrio.scopeVisibility ? { scope_visibility: resolvedPrio.scopeVisibility } : {}),
375
423
  }, config.timeoutMs || 3000);
376
424
  if (resp?.result?.results && Array.isArray(resp.result.results)) {
377
425
  entries = resp.result.results;
@@ -417,36 +465,37 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
417
465
  entries = result.results;
418
466
  }
419
467
  } catch {
468
+ // Still deliver briefing even if recall fails completely
469
+ if (briefingText) {
470
+ return { prependContext: briefingText };
471
+ }
420
472
  return;
421
473
  }
422
474
  }
423
475
 
424
- if (entries.length === 0) return;
476
+ // If no recall entries but briefing exists, deliver briefing alone
477
+ if (entries.length === 0) {
478
+ if (briefingText) {
479
+ return { prependContext: briefingText };
480
+ }
481
+ return;
482
+ }
425
483
 
426
484
  // Apply type-weighted reranking and blocked filtering (Issue #121)
427
- const rankedRaw = rerankByTypeWeight(entries, resolvedPrio.recallTypeWeight);
485
+ const rankedRaw = rerankByTypeWeight(entries, resolvedPrio.recallTypeWeight, config.recallRecencyBoost);
428
486
  const ranked = filterBlocked(rankedRaw, resolvedPrio.blocked);
429
487
 
430
- // Build context string with char budget (compact format for token efficiency)
431
- const SCOPE_SHORT: Record<string, string> = { team: "t", private: "p", public: "pub" };
432
- const TYPE_SHORT: Record<string, string> = { memory: "m", process: "pr", task: "tk" };
433
-
488
+ // Build context string with char budget
489
+ // Progressive disclosure: compact mode for large stores (title + first line + ID)
490
+ const compact = shouldUseCompactMode(ranked.length);
434
491
  let text = "## Active Memory (Palaia)\n\n";
492
+ if (compact) {
493
+ text += "_Compact mode — use `memory_get <id>` for full details._\n\n";
494
+ }
435
495
  let chars = text.length;
436
496
 
437
497
  for (const entry of ranked) {
438
- const scopeKey = SCOPE_SHORT[entry.scope] || entry.scope;
439
- const typeKey = TYPE_SHORT[entry.type] || entry.type;
440
- const prefix = `[${scopeKey}/${typeKey}]`;
441
-
442
- // If body starts with title (common), skip title to save tokens
443
- let line: string;
444
- if (entry.body.toLowerCase().startsWith(entry.title.toLowerCase())) {
445
- line = `${prefix} ${entry.body}\n\n`;
446
- } else {
447
- line = `${prefix} ${entry.title}\n${entry.body}\n\n`;
448
- }
449
-
498
+ const line = formatEntryLine(entry, compact);
450
499
  if (chars + line.length > maxChars) break;
451
500
  text += line;
452
501
  chars += line.length;
@@ -483,7 +532,6 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
483
532
  const hasRelevantRecall = !isListFallback && entries.some(
484
533
  (e) => typeof e.score === "number" && e.score >= resolvedPrio.recallMinScore,
485
534
  );
486
- const sessionKey = resolveSessionKeyFromCtx(ctx);
487
535
  if (sessionKey && hasRelevantRecall) {
488
536
  const turnState = getOrCreateTurnState(sessionKey);
489
537
  turnState.recallOccurred = true;
@@ -506,7 +554,7 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
506
554
 
507
555
  // Return prependContext + appendSystemContext for recall emoji
508
556
  return {
509
- prependContext: text + nudgeContext,
557
+ prependContext: briefingText + text,
510
558
  appendSystemContext: config.showMemorySources
511
559
  ? "You used Palaia memory in this turn. Add \u{1f9e0} at the very end of your response (after everything else, on its own line)."
512
560
  : undefined,
@@ -559,14 +607,17 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
559
607
  collectedHints.push(...hints);
560
608
  }
561
609
 
562
- // Strip Palaia-injected recall context from user messages to prevent feedback loop.
610
+ // Strip Palaia-injected recall context and private blocks from messages.
563
611
  // The recall block is prepended to user messages by before_prompt_build.
564
612
  // Without stripping, auto-capture would re-capture previously recalled memories.
565
- const cleanedTexts = allTexts.map(t =>
566
- t.role === "user"
567
- ? { ...t, text: stripPalaiaInjectedContext(t.text) }
568
- : t
569
- );
613
+ // Private blocks (<private>...</private>) must be excluded from capture.
614
+ const { stripPrivateBlocks } = await import("./capture.js");
615
+ const cleanedTexts = allTexts.map(t => ({
616
+ ...t,
617
+ text: stripPrivateBlocks(
618
+ t.role === "user" ? stripPalaiaInjectedContext(t.text) : t.text
619
+ ),
620
+ }));
570
621
 
571
622
  // Only extract from recent exchanges — full history causes LLM timeouts
572
623
  // and dilutes extraction quality
@@ -586,6 +637,23 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
586
637
 
587
638
  const knownProjects = await loadProjects(hookOpts);
588
639
 
640
+ // Resolve effective capture scope from priorities (per-agent override, #147)
641
+ let effectiveCaptureScope = config.captureScope || "";
642
+ try {
643
+ const prio = await loadPriorities(resolved.workspace);
644
+ const resolvedCapturePrio = resolvePriorities(prio, {
645
+ recallTypeWeight: config.recallTypeWeight,
646
+ recallMinScore: config.recallMinScore,
647
+ maxInjectedChars: config.maxInjectedChars,
648
+ tier: config.tier,
649
+ }, agentName);
650
+ if (resolvedCapturePrio.captureScope) {
651
+ effectiveCaptureScope = resolvedCapturePrio.captureScope;
652
+ }
653
+ } catch {
654
+ // Fall through to config default
655
+ }
656
+
589
657
  // Helper: build CLI args with metadata
590
658
  const buildWriteArgs = (
591
659
  content: string,
@@ -601,9 +669,9 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
601
669
  "--tags", tags.join(",") || "auto-capture",
602
670
  ];
603
671
 
604
- // Scope guardrail: config.captureScope overrides everything; otherwise max team (no public)
605
- const scope = config.captureScope
606
- ? sanitizeScope(config.captureScope, "team", true)
672
+ // Scope guardrail: priorities captureScope > config.captureScope > hint/LLM scope
673
+ const scope = effectiveCaptureScope
674
+ ? sanitizeScope(effectiveCaptureScope, "team", true)
607
675
  : sanitizeScope(itemScope, "team", false);
608
676
  args.push("--scope", scope);
609
677
 
@@ -821,7 +889,7 @@ export function registerHooks(api: OpenClawPluginApi, config: PalaiaPluginConfig
821
889
  // ── Startup Recovery Service ───────────────────────────────────
822
890
  api.registerService({
823
891
  id: "palaia-recovery",
824
- start: async () => {
892
+ start: async (_ctx) => {
825
893
  const result = await recover(opts);
826
894
  if (result.replayed > 0) {
827
895
  logger.info(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
@@ -26,6 +26,7 @@ export interface QueryResult {
26
26
  title?: string;
27
27
  type?: string;
28
28
  tags?: string[];
29
+ created?: string;
29
30
  }>;
30
31
  }
31
32
 
@@ -342,16 +343,36 @@ export interface RankedEntry {
342
343
  bm25Score?: number;
343
344
  embedScore?: number;
344
345
  weightedScore: number;
346
+ created?: string;
347
+ tags?: string[];
348
+ }
349
+
350
+ /**
351
+ * Calculate recency boost factor.
352
+ * Returns a multiplier: 1.0 (no boost) to 1.0 + boostFactor (max boost for very recent).
353
+ * Formula: 1 + boostFactor * exp(-hoursAgo / 24)
354
+ */
355
+ function calcRecencyBoost(created: string | undefined, boostFactor: number): number {
356
+ if (!boostFactor || !created) return 1.0;
357
+ try {
358
+ const hoursAgo = (Date.now() - new Date(created).getTime()) / (1000 * 60 * 60);
359
+ if (hoursAgo < 0 || isNaN(hoursAgo)) return 1.0;
360
+ return 1.0 + boostFactor * Math.exp(-hoursAgo / 24);
361
+ } catch {
362
+ return 1.0;
363
+ }
345
364
  }
346
365
 
347
366
  export function rerankByTypeWeight(
348
367
  results: QueryResult["results"],
349
- weights: RecallTypeWeights,
368
+ weights: Record<string, number>,
369
+ recencyBoost = 0,
350
370
  ): RankedEntry[] {
351
371
  return results
352
372
  .map((r) => {
353
373
  const type = r.type || "memory";
354
374
  const weight = weights[type] ?? 1.0;
375
+ const recency = calcRecencyBoost(r.created, recencyBoost);
355
376
  return {
356
377
  id: r.id,
357
378
  body: r.content || r.body || "",
@@ -362,8 +383,50 @@ export function rerankByTypeWeight(
362
383
  score: r.score,
363
384
  bm25Score: r.bm25_score,
364
385
  embedScore: r.embed_score,
365
- weightedScore: r.score * weight,
386
+ weightedScore: r.score * weight * recency,
387
+ created: r.created,
388
+ tags: r.tags,
366
389
  };
367
390
  })
368
391
  .sort((a, b) => b.weightedScore - a.weightedScore);
369
392
  }
393
+
394
+ // ── Context Formatting ──────────────────────────────────────────────────
395
+
396
+ const SCOPE_SHORT: Record<string, string> = { team: "t", private: "p", public: "pub" };
397
+ const TYPE_SHORT: Record<string, string> = { memory: "m", process: "pr", task: "tk" };
398
+
399
+ /**
400
+ * Format a ranked entry as an injectable context line.
401
+ *
402
+ * In compact mode (progressive disclosure), only title + first line + ID are shown.
403
+ * The agent can use `memory_get <id>` for the full entry.
404
+ */
405
+ export function formatEntryLine(entry: RankedEntry, compact: boolean): string {
406
+ const scopeKey = SCOPE_SHORT[entry.scope] || entry.scope;
407
+ const typeKey = TYPE_SHORT[entry.type] || entry.type;
408
+ const prefix = `[${scopeKey}/${typeKey}]`;
409
+
410
+ if (compact) {
411
+ // Compact: title + first line of body + ID reference
412
+ const firstLine = entry.body.split("\n")[0]?.slice(0, 120) || "";
413
+ const titlePart = entry.body.toLowerCase().startsWith(entry.title.toLowerCase())
414
+ ? firstLine
415
+ : `${entry.title} — ${firstLine}`;
416
+ return `${prefix} ${titlePart} [id:${entry.id}]\n`;
417
+ }
418
+
419
+ // Full: title + complete body
420
+ if (entry.body.toLowerCase().startsWith(entry.title.toLowerCase())) {
421
+ return `${prefix} ${entry.body}\n\n`;
422
+ }
423
+ return `${prefix} ${entry.title}\n${entry.body}\n\n`;
424
+ }
425
+
426
+ /**
427
+ * Determine if compact mode should be used based on result count.
428
+ * Above threshold, use compact mode to fit more entries in budget.
429
+ */
430
+ export function shouldUseCompactMode(totalResults: number, threshold = 100): boolean {
431
+ return totalResults > threshold;
432
+ }