@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.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Session lifecycle hooks for palaia v3.
3
+ *
4
+ * Handles session_start, session_end, before_reset, llm_input, llm_output,
5
+ * and after_tool_call hooks to provide:
6
+ * - Session briefing on session start (context restoration)
7
+ * - Automatic session summaries on session end/reset
8
+ * - LLM model switch detection
9
+ * - Tool observation tracking
10
+ * - Token usage tracking
11
+ */
12
+
13
+ import type { OpenClawPluginApi } from "../types.js";
14
+ import type { PalaiaPluginConfig } from "../config.js";
15
+ import { run, type RunnerOpts } from "../runner.js";
16
+ import {
17
+ getOrCreateSessionState,
18
+ deleteSessionState,
19
+ type PendingBriefing,
20
+ type ToolObservation,
21
+ } from "./state.js";
22
+ import {
23
+ stripPalaiaInjectedContext,
24
+ stripPrivateBlocks,
25
+ trimToRecentExchanges,
26
+ extractWithLLM,
27
+ } from "./capture.js";
28
+ import { extractMessageTexts } from "./recall.js";
29
+ import { loadPriorities, resolvePriorities } from "../priorities.js";
30
+
31
+ /**
32
+ * Resolve effective capture scope from priorities (per-agent override)
33
+ * falling back to plugin config, then to "team".
34
+ */
35
+ async function getEffectiveCaptureScope(config: PalaiaPluginConfig): Promise<string> {
36
+ try {
37
+ const prio = await loadPriorities(config.workspace || "");
38
+ const agentId = process.env.PALAIA_AGENT || undefined;
39
+ const resolved = resolvePriorities(prio, {
40
+ recallTypeWeight: config.recallTypeWeight,
41
+ recallMinScore: config.recallMinScore,
42
+ maxInjectedChars: config.maxInjectedChars,
43
+ tier: config.tier,
44
+ }, agentId);
45
+ if (resolved.captureScope) return resolved.captureScope;
46
+ } catch {
47
+ // Fall through to config default
48
+ }
49
+ return config.captureScope || "team";
50
+ }
51
+
52
+ function buildRunnerOpts(config: PalaiaPluginConfig): RunnerOpts {
53
+ return {
54
+ binaryPath: config.binaryPath,
55
+ workspace: config.workspace,
56
+ timeoutMs: config.timeoutMs,
57
+ };
58
+ }
59
+
60
+ // ── Session Briefing ────────────────────────────────────────���───────────
61
+
62
+ /**
63
+ * Load session briefing data (last session summary + open tasks).
64
+ * Called during session_start to prepare context for the first turn.
65
+ */
66
+ export async function loadSessionBriefing(
67
+ config: PalaiaPluginConfig,
68
+ logger: { info(...a: unknown[]): void; warn(...a: unknown[]): void },
69
+ ): Promise<PendingBriefing> {
70
+ const opts = buildRunnerOpts(config);
71
+ let summary: string | null = null;
72
+ let summaryCreated: number = Date.now();
73
+ const openTasks: string[] = [];
74
+
75
+ // Load last session summary and open tasks in parallel
76
+ const { runJson } = await import("../runner.js");
77
+ const [summaryResult, tasksResult] = await Promise.allSettled([
78
+ runJson<{ results: Array<{ title: string; body: string; created?: string }> }>(
79
+ ["query", "session-summary", "--limit", "1", "--tags", "session-summary"],
80
+ { ...opts, timeoutMs: 5000 },
81
+ ),
82
+ runJson<{ results: Array<{ title: string; body: string; priority?: string }> }>(
83
+ ["list", "--type", "task", "--status", "open", "--limit", "5"],
84
+ { ...opts, timeoutMs: 5000 },
85
+ ),
86
+ ]);
87
+
88
+ if (summaryResult.status === "fulfilled" && summaryResult.value?.results?.[0]) {
89
+ summary = summaryResult.value.results[0].body;
90
+ const created = summaryResult.value.results[0].created;
91
+ if (created) {
92
+ const parsed = new Date(created).getTime();
93
+ if (!isNaN(parsed)) summaryCreated = parsed;
94
+ }
95
+ }
96
+
97
+ if (tasksResult.status === "fulfilled" && tasksResult.value?.results) {
98
+ for (const task of tasksResult.value.results) {
99
+ const prio = task.priority ? ` (${task.priority})` : "";
100
+ openTasks.push(`${task.title}${prio}`);
101
+ }
102
+ }
103
+
104
+ return { summary, openTasks, timestamp: summaryCreated };
105
+ }
106
+
107
+ /**
108
+ * Format a pending briefing into injectable text.
109
+ */
110
+ export function formatBriefing(briefing: PendingBriefing, maxChars: number): string {
111
+ if (maxChars <= 0) return "";
112
+ if (!briefing.summary && briefing.openTasks.length === 0) return "";
113
+
114
+ const parts: string[] = ["## Session Briefing (Palaia)\n"];
115
+
116
+ if (briefing.summary) {
117
+ const agoMs = Date.now() - briefing.timestamp;
118
+ const agoMin = Math.round(agoMs / 60_000);
119
+ const agoStr = agoMin < 1 ? "just now" :
120
+ agoMin < 60 ? `${agoMin}m ago` :
121
+ `${Math.round(agoMin / 60)}h ago`;
122
+ parts.push(`Last session (${agoStr}):`);
123
+ parts.push(briefing.summary);
124
+ parts.push("");
125
+ }
126
+
127
+ if (briefing.openTasks.length > 0) {
128
+ parts.push("Open tasks:");
129
+ for (const task of briefing.openTasks) {
130
+ parts.push(`- ${task}`);
131
+ }
132
+ parts.push("");
133
+ }
134
+
135
+ const text = parts.join("\n");
136
+ return text.length <= maxChars ? text : text.slice(0, maxChars - 3) + "...";
137
+ }
138
+
139
+ // ── Session Summary Capture ─────────────────────────────────────────────
140
+
141
+ /**
142
+ * Extract and save a session summary from conversation messages.
143
+ * Called during before_reset (with messages) or session_end (without).
144
+ */
145
+ export async function captureSessionSummary(
146
+ messages: unknown[] | undefined,
147
+ sessionKey: string,
148
+ api: OpenClawPluginApi,
149
+ config: PalaiaPluginConfig,
150
+ logger: { info(...a: unknown[]): void; warn(...a: unknown[]): void },
151
+ ): Promise<void> {
152
+ const opts = buildRunnerOpts(config);
153
+ const state = getOrCreateSessionState(sessionKey);
154
+
155
+ // Guard: prevent double-save (before_reset + session_end race)
156
+ if (state.summarySaved) return;
157
+ let summaryText: string | null = null;
158
+
159
+ if (messages && messages.length > 0) {
160
+ // Try LLM-based summary extraction
161
+ try {
162
+ const results = await extractWithLLM(messages, api.config, {
163
+ captureModel: config.captureModel,
164
+ }, []);
165
+
166
+ if (results.length > 0) {
167
+ summaryText = results[0].content;
168
+ }
169
+ } catch {
170
+ // Fall through to rule-based
171
+ }
172
+
173
+ // Rule-based fallback: last 3 user+assistant pairs
174
+ if (!summaryText) {
175
+ const allTexts = extractMessageTexts(messages);
176
+ const cleaned = allTexts.map((t: { role: string; text: string }) => ({
177
+ ...t,
178
+ text: stripPrivateBlocks(
179
+ t.role === "user" ? stripPalaiaInjectedContext(t.text) : t.text
180
+ ),
181
+ }));
182
+ const recent = trimToRecentExchanges(cleaned, 3);
183
+ if (recent.length > 0) {
184
+ summaryText = recent
185
+ .map(t => `[${t.role}]: ${t.text.slice(0, 200)}`)
186
+ .join("\n")
187
+ .slice(0, 500);
188
+ }
189
+ }
190
+ } else {
191
+ // No messages available (session_end without before_reset).
192
+ // Use accumulated tool observations as summary.
193
+ if (state.toolObservations.length > 0) {
194
+ summaryText = state.toolObservations
195
+ .map(o => `${o.toolName}: ${o.resultSummary}`)
196
+ .join("\n")
197
+ .slice(0, 500);
198
+ }
199
+ }
200
+
201
+ if (!summaryText) return;
202
+
203
+ // Save session summary
204
+ try {
205
+ const scope = await getEffectiveCaptureScope(config);
206
+ const args: string[] = [
207
+ "write", summaryText,
208
+ "--type", "memory",
209
+ "--tags", "session-summary,auto-capture",
210
+ "--scope", scope,
211
+ ];
212
+ if (state.autoSessionId) args.push("--instance", state.autoSessionId);
213
+ const agentName = process.env.PALAIA_AGENT || undefined;
214
+ if (agentName) args.push("--agent", agentName);
215
+
216
+ await run(args, { ...opts, timeoutMs: 10_000 });
217
+ state.summarySaved = true; // Only mark after successful write
218
+ logger.info(`[palaia] Session summary saved (${summaryText.length} chars)`);
219
+ } catch (error) {
220
+ // Don't set summarySaved — allow retry from session_end if before_reset failed
221
+ logger.warn(`[palaia] Failed to save session summary: ${error}`);
222
+ }
223
+ }
224
+
225
+ // ── Tool Observations ─────────────────────────────────────────────────���─
226
+
227
+ /** Tools worth tracking for observations. */
228
+ const TRACKED_TOOLS = new Set([
229
+ // File operations
230
+ "memory_search", "memory_get", "memory_write",
231
+ "read_file", "write_file", "edit_file",
232
+ "search_files", "list_files",
233
+ // Shell
234
+ "bash", "shell", "terminal",
235
+ // Web
236
+ "web_search", "fetch_url",
237
+ ]);
238
+
239
+ /**
240
+ * Process an after_tool_call event into a tool observation.
241
+ * Returns null if the tool isn't worth tracking.
242
+ */
243
+ export function processToolCall(
244
+ toolName: string,
245
+ params: Record<string, unknown>,
246
+ result: unknown,
247
+ durationMs: number,
248
+ ): ToolObservation | null {
249
+ // Only track relevant tools (skip internal/framework tools)
250
+ if (!TRACKED_TOOLS.has(toolName) && !toolName.startsWith("palaia_")) {
251
+ return null;
252
+ }
253
+
254
+ // Skip errored tool calls (only null/undefined, not falsy 0/"")
255
+ if (result === undefined || result === null) return null;
256
+
257
+ // Summarize params (keep it short)
258
+ const paramKeys = Object.keys(params).slice(0, 3);
259
+ const paramsSummary = paramKeys
260
+ .map(k => {
261
+ const v = params[k];
262
+ try {
263
+ const s = typeof v === "string" ? v.slice(0, 80) : JSON.stringify(v)?.slice(0, 80) ?? "";
264
+ return `${k}=${s}`;
265
+ } catch {
266
+ return `${k}=[object]`;
267
+ }
268
+ })
269
+ .join(", ");
270
+
271
+ // Summarize result
272
+ let resultSummary: string;
273
+ if (typeof result === "string") {
274
+ resultSummary = result.slice(0, 200);
275
+ } else if (typeof result === "object" && result !== null) {
276
+ resultSummary = JSON.stringify(result).slice(0, 200);
277
+ } else {
278
+ resultSummary = String(result).slice(0, 200);
279
+ }
280
+
281
+ return {
282
+ toolName,
283
+ paramsSummary,
284
+ resultSummary,
285
+ durationMs,
286
+ timestamp: Date.now(),
287
+ };
288
+ }
289
+
290
+ // ── Hook Registration ───────────────────────────────────────────────────
291
+
292
+ /**
293
+ * Register all session lifecycle hooks.
294
+ * Called from hooks/index.ts during plugin registration.
295
+ */
296
+ export function registerSessionHooks(
297
+ api: OpenClawPluginApi,
298
+ config: PalaiaPluginConfig,
299
+ ): void {
300
+ const logger = api.logger;
301
+
302
+ // ── session_start: Load briefing for context restoration ──────────
303
+ if (config.sessionBriefing) {
304
+ api.on("session_start", async (event: any, ctx: any) => {
305
+ const sessionKey = ctx?.sessionKey || ctx?.sessionId;
306
+ if (!sessionKey) return;
307
+ const state = getOrCreateSessionState(sessionKey);
308
+
309
+ // Create a promise that before_prompt_build can await
310
+ let resolve: () => void;
311
+ state.briefingReady = new Promise<void>(r => { resolve = r; });
312
+ state.briefingReadyResolve = resolve!;
313
+
314
+ try {
315
+ state.pendingBriefing = await loadSessionBriefing(config, logger);
316
+ if (state.pendingBriefing.summary || state.pendingBriefing.openTasks.length > 0) {
317
+ logger.info(`[palaia] Session briefing loaded for ${sessionKey}`);
318
+ }
319
+ } catch (error) {
320
+ logger.warn(`[palaia] Failed to load session briefing: ${error}`);
321
+ } finally {
322
+ state.briefingReadyResolve?.();
323
+ }
324
+ });
325
+ }
326
+
327
+ // ── session_end: Save session summary ─────────────────────────────
328
+ if (config.sessionSummary) {
329
+ api.on("session_end", async (event: any, ctx: any) => {
330
+ const sessionKey = ctx?.sessionKey || ctx?.sessionId;
331
+ if (!sessionKey) return;
332
+
333
+ try {
334
+ // Only save summary if session had meaningful interaction.
335
+ // Guard: if OpenClaw omits messageCount, fall back to tool observations
336
+ // as evidence of interaction rather than silently skipping.
337
+ const messageCount = (event as any)?.messageCount;
338
+ const state = getOrCreateSessionState(sessionKey);
339
+ const hasInteraction = (typeof messageCount === "number" && messageCount >= 4)
340
+ || (messageCount === undefined && state.toolObservations.length > 0);
341
+ if (hasInteraction) {
342
+ await captureSessionSummary(undefined, sessionKey, api, config, logger);
343
+ }
344
+ } catch (error) {
345
+ logger.warn(`[palaia] session_end summary failed: ${error}`);
346
+ } finally {
347
+ deleteSessionState(sessionKey);
348
+ }
349
+ });
350
+ }
351
+
352
+ // ── before_reset: Save session summary with messages (priority) ───
353
+ if (config.sessionSummary) {
354
+ api.on("before_reset", async (event: any, ctx: any) => {
355
+ const sessionKey = ctx?.sessionKey || ctx?.sessionId;
356
+ if (!sessionKey) return;
357
+
358
+ try {
359
+ const messages = (event as any)?.messages;
360
+ if (messages && Array.isArray(messages) && messages.length >= 4) {
361
+ await captureSessionSummary(messages, sessionKey, api, config, logger);
362
+ }
363
+ } catch (error) {
364
+ logger.warn(`[palaia] before_reset summary failed: ${error}`);
365
+ }
366
+ });
367
+ }
368
+
369
+ // ── llm_input: Model switch detection ─────────────────────────────
370
+ api.on("llm_input", (event: any, ctx: any) => {
371
+ const sessionKey = ctx?.sessionKey || ctx?.sessionId;
372
+ if (!sessionKey) return;
373
+ const state = getOrCreateSessionState(sessionKey);
374
+
375
+ const model = (event as any)?.model;
376
+ if (!model) return;
377
+
378
+ if (state.lastModel && model !== state.lastModel) {
379
+ state.modelSwitchDetected = true;
380
+ logger.info(`[palaia] Model switch detected: ${state.lastModel} → ${model}`);
381
+
382
+ // Re-load briefing for next assemble
383
+ if (config.sessionBriefing && !state.pendingBriefing) {
384
+ loadSessionBriefing(config, logger).then(briefing => {
385
+ state.pendingBriefing = briefing;
386
+ state.briefingDelivered = false;
387
+ }).catch(() => {});
388
+ }
389
+ }
390
+ state.lastModel = model;
391
+ });
392
+
393
+ // ── after_tool_call: Tool observation tracking ────────────────��───
394
+ if (config.captureToolObservations) {
395
+ api.on("after_tool_call", (event: any, ctx: any) => {
396
+ const sessionKey = ctx?.sessionKey || ctx?.sessionId;
397
+ if (!sessionKey) return;
398
+ const state = getOrCreateSessionState(sessionKey);
399
+
400
+ const obs = processToolCall(
401
+ (event as any)?.toolName || "",
402
+ (event as any)?.params || {},
403
+ (event as any)?.result,
404
+ (event as any)?.durationMs || 0,
405
+ );
406
+
407
+ if (obs) {
408
+ state.toolObservations.push(obs);
409
+ // Keep max 50 observations per session to prevent memory bloat
410
+ if (state.toolObservations.length > 50) {
411
+ state.toolObservations.shift();
412
+ }
413
+ }
414
+ });
415
+ }
416
+
417
+ // ── subagent_spawning: Log sub-agent spawn for context tracking ─
418
+ api.on("subagent_spawning", async (event: any, _ctx: any) => {
419
+ try {
420
+ const childKey = (event as any)?.childSessionKey;
421
+ if (!childKey) return;
422
+ logger.info(`[palaia] Sub-agent spawning: ${childKey}`);
423
+ } catch {
424
+ // Non-fatal
425
+ }
426
+ });
427
+
428
+ // ── subagent_ended: Capture sub-agent results ─────────────────
429
+ if (config.autoCapture) {
430
+ api.on("subagent_ended", async (event: any, _ctx: any) => {
431
+ try {
432
+ const outcome = (event as any)?.outcome;
433
+ if (outcome !== "ok") return;
434
+
435
+ const childKey = (event as any)?.targetSessionKey;
436
+ if (!childKey || !api.runtime?.subagent?.getSessionMessages) return;
437
+
438
+ const result = await api.runtime.subagent.getSessionMessages({
439
+ sessionKey: childKey,
440
+ limit: 10,
441
+ });
442
+ if (!result?.messages?.length) return;
443
+
444
+ const texts = extractMessageTexts(result.messages);
445
+ const summaryParts = texts.slice(-4).map(
446
+ (t: { role: string; text: string }) => `[${t.role}]: ${t.text.slice(0, 200)}`
447
+ );
448
+ if (summaryParts.length === 0) return;
449
+
450
+ const summaryText = `Sub-agent result:\n${summaryParts.join("\n")}`.slice(0, 500);
451
+ const subagentScope = await getEffectiveCaptureScope(config);
452
+ await run([
453
+ "write", summaryText,
454
+ "--type", "memory",
455
+ "--tags", "subagent-result,auto-capture",
456
+ "--scope", subagentScope,
457
+ ], { ...buildRunnerOpts(config), timeoutMs: 10_000 });
458
+ logger.info(`[palaia] Sub-agent result captured (${summaryText.length} chars)`);
459
+ } catch (error) {
460
+ logger.warn(`[palaia] Sub-agent result capture failed: ${error}`);
461
+ }
462
+ });
463
+ }
464
+ }
@@ -127,6 +127,8 @@ export function pruneStaleEntries(): void {
127
127
  lastInboundMessageByChannel.delete(key);
128
128
  }
129
129
  }
130
+ // Also prune stale session state (orphaned sessions)
131
+ pruneStaleSessionState();
130
132
  }
131
133
 
132
134
  /**
@@ -154,6 +156,106 @@ export function resetTurnState(): void {
154
156
  lastInboundMessageByChannel.clear();
155
157
  }
156
158
 
159
+ // ============================================================================
160
+ // Session Continuity State (v3.0)
161
+ // ============================================================================
162
+
163
+ /** Accumulated tool observation from after_tool_call hooks. */
164
+ export interface ToolObservation {
165
+ toolName: string;
166
+ paramsSummary: string;
167
+ resultSummary: string;
168
+ durationMs: number;
169
+ timestamp: number;
170
+ }
171
+
172
+ /** Pending session briefing, cached between session_start and first before_prompt_build. */
173
+ export interface PendingBriefing {
174
+ summary: string | null;
175
+ openTasks: string[];
176
+ timestamp: number;
177
+ }
178
+
179
+ /** Session-level state that persists across turns within a session. */
180
+ export interface SessionState {
181
+ /** Auto-generated session ID for instance tracking. */
182
+ autoSessionId: string;
183
+ /** Last known LLM model (for switch detection). */
184
+ lastModel: string | null;
185
+ /** True when model switch detected, cleared after briefing re-injection. */
186
+ modelSwitchDetected: boolean;
187
+ /** Pending briefing to inject on next before_prompt_build. */
188
+ pendingBriefing: PendingBriefing | null;
189
+ /** Accumulated tool observations for the session (used in session summary). */
190
+ toolObservations: ToolObservation[];
191
+ /** Timestamp of session start. */
192
+ startedAt: number;
193
+ /** Whether the first turn briefing has been delivered. */
194
+ briefingDelivered: boolean;
195
+ /** Whether a session summary has already been saved (prevents double-save). */
196
+ summarySaved: boolean;
197
+ /** Promise that resolves when session_start briefing load is complete. */
198
+ briefingReady: Promise<void> | null;
199
+ /** Resolver for briefingReady promise. */
200
+ briefingReadyResolve: (() => void) | null;
201
+ }
202
+
203
+ /** Session state map. Keyed by sessionKey. */
204
+ export const sessionStateByKey = new Map<string, SessionState>();
205
+
206
+ /** Maximum age for session state entries before pruning (1 hour). */
207
+ const SESSION_STATE_TTL_MS = 60 * 60 * 1000;
208
+
209
+ /**
210
+ * Remove stale entries from sessionStateByKey.
211
+ * Called alongside pruneStaleEntries() to prevent memory leaks from
212
+ * sessions that ended without firing session_end (crash/kill).
213
+ */
214
+ export function pruneStaleSessionState(): void {
215
+ const now = Date.now();
216
+ for (const [key, state] of sessionStateByKey) {
217
+ if (now - state.startedAt > SESSION_STATE_TTL_MS) {
218
+ sessionStateByKey.delete(key);
219
+ }
220
+ }
221
+ }
222
+
223
+ /** Generate an auto session ID. */
224
+ export function generateAutoSessionId(): string {
225
+ const now = new Date();
226
+ const pad = (n: number) => String(n).padStart(2, "0");
227
+ const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
228
+ const time = `${pad(now.getHours())}${pad(now.getMinutes())}`;
229
+ const hex = Math.floor(Math.random() * 0xffff).toString(16).padStart(4, "0");
230
+ return `auto-${date}-${time}-${hex}`;
231
+ }
232
+
233
+ /** Get or create session state for a session key. */
234
+ export function getOrCreateSessionState(sessionKey: string): SessionState {
235
+ let state = sessionStateByKey.get(sessionKey);
236
+ if (!state) {
237
+ state = {
238
+ autoSessionId: generateAutoSessionId(),
239
+ lastModel: null,
240
+ modelSwitchDetected: false,
241
+ pendingBriefing: null,
242
+ toolObservations: [],
243
+ startedAt: Date.now(),
244
+ briefingDelivered: false,
245
+ summarySaved: false,
246
+ briefingReady: null,
247
+ briefingReadyResolve: null,
248
+ };
249
+ sessionStateByKey.set(sessionKey, state);
250
+ }
251
+ return state;
252
+ }
253
+
254
+ /** Delete session state (cleanup). */
255
+ export function deleteSessionState(sessionKey: string): void {
256
+ sessionStateByKey.delete(sessionKey);
257
+ }
258
+
157
259
  // ============================================================================
158
260
  // Session Key Helpers
159
261
  // ============================================================================
package/src/priorities.ts CHANGED
@@ -32,6 +32,8 @@ export interface AgentPriorityOverride {
32
32
  recallMinScore?: number;
33
33
  maxInjectedChars?: number;
34
34
  tier?: string;
35
+ scopeVisibility?: string[]; // Issue #145: agent isolation
36
+ captureScope?: string; // Issue #147: per-agent write scope
35
37
  }
36
38
 
37
39
  export interface ProjectPriorityOverride {
@@ -45,6 +47,8 @@ export interface ResolvedPriorities {
45
47
  recallMinScore: number;
46
48
  maxInjectedChars: number;
47
49
  tier: string;
50
+ scopeVisibility: string[] | null; // Issue #145: agent isolation
51
+ captureScope: string | null; // Issue #147: per-agent write scope
48
52
  }
49
53
 
50
54
  // ============================================================================
@@ -136,6 +140,8 @@ export function resolvePriorities(
136
140
  recallMinScore: defaults.recallMinScore ?? DEFAULT_MIN_SCORE,
137
141
  maxInjectedChars: defaults.maxInjectedChars ?? DEFAULT_MAX_CHARS,
138
142
  tier: defaults.tier ?? DEFAULT_TIER,
143
+ scopeVisibility: null,
144
+ captureScope: null,
139
145
  };
140
146
 
141
147
  if (!prio) return resolved;
@@ -176,6 +182,12 @@ export function resolvePriorities(
176
182
  if (agentCfg.tier !== undefined) {
177
183
  resolved.tier = agentCfg.tier;
178
184
  }
185
+ if (agentCfg.scopeVisibility) {
186
+ resolved.scopeVisibility = [...agentCfg.scopeVisibility];
187
+ }
188
+ if (agentCfg.captureScope) {
189
+ resolved.captureScope = agentCfg.captureScope;
190
+ }
179
191
  }
180
192
  }
181
193
 
package/src/tools.ts CHANGED
@@ -10,6 +10,7 @@ import { Type } from "@sinclair/typebox";
10
10
  import { run, runJson, type RunnerOpts } from "./runner.js";
11
11
  import type { PalaiaPluginConfig } from "./config.js";
12
12
  import { sanitizeScope, isValidScope } from "./hooks/index.js";
13
+ import { loadPriorities, resolvePriorities } from "./priorities.js";
13
14
  import type { OpenClawPluginApi } from "./types.js";
14
15
 
15
16
  /** Shape returned by `palaia query --json` */
@@ -81,11 +82,6 @@ export function registerTools(api: OpenClawPluginApi, config: PalaiaPluginConfig
81
82
  description: "hot|warm|all (default: hot+warm)",
82
83
  })
83
84
  ),
84
- scope: Type.Optional(
85
- Type.String({
86
- description: "Filter by scope: private|team|shared:X|public",
87
- })
88
- ),
89
85
  type: Type.Optional(
90
86
  Type.String({
91
87
  description: "Filter by entry type: memory|process|task",
@@ -98,10 +94,25 @@ export function registerTools(api: OpenClawPluginApi, config: PalaiaPluginConfig
98
94
  query: string;
99
95
  maxResults?: number;
100
96
  tier?: string;
101
- scope?: string;
102
97
  type?: string;
103
98
  }
104
99
  ) {
100
+ // Load scope visibility from priorities (Issue #145: agent isolation)
101
+ let scopeVisibility: string[] | null = null;
102
+ try {
103
+ const prio = await loadPriorities(config.workspace || "");
104
+ const agentId = process.env.PALAIA_AGENT || undefined;
105
+ const resolvedPrio = resolvePriorities(prio, {
106
+ recallTypeWeight: config.recallTypeWeight,
107
+ recallMinScore: config.recallMinScore,
108
+ maxInjectedChars: config.maxInjectedChars,
109
+ tier: config.tier,
110
+ }, agentId);
111
+ scopeVisibility = resolvedPrio.scopeVisibility;
112
+ } catch {
113
+ // Non-fatal: proceed without scope filtering
114
+ }
115
+
105
116
  const limit = params.maxResults || config.maxResults || 5;
106
117
  const args: string[] = ["query", params.query, "--limit", String(limit)];
107
118
 
@@ -117,8 +128,21 @@ export function registerTools(api: OpenClawPluginApi, config: PalaiaPluginConfig
117
128
 
118
129
  const result = await runJson<QueryResult>(args, opts);
119
130
 
131
+ // Apply scope visibility filter (Issue #145: agent isolation)
132
+ let filteredResults = result.results || [];
133
+ if (scopeVisibility) {
134
+ filteredResults = filteredResults.filter((r) => {
135
+ const scope = r.scope || "team";
136
+ return scopeVisibility!.some((allowed) => {
137
+ if (scope === allowed) return true;
138
+ if (allowed === "shared" && scope.startsWith("shared:")) return true;
139
+ return false;
140
+ });
141
+ });
142
+ }
143
+
120
144
  // Format as memory_search compatible output
121
- const snippets = (result.results || []).map((r) => {
145
+ const snippets = filteredResults.map((r) => {
122
146
  const body = r.content || r.body || "";
123
147
  const path = r.path || `${r.tier}/${r.id}.md`;
124
148
  return {