@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.
@@ -1,21 +1,31 @@
1
1
  /**
2
- * ContextEngine adapter for deeper OpenClaw integration.
2
+ * ContextEngine adapter for OpenClaw integration.
3
3
  *
4
- * Maps the 7 ContextEngine lifecycle hooks to palaia functionality,
5
- * using the decomposed hooks modules as the implementation layer.
4
+ * Implements the OpenClaw v2026.3.24 ContextEngine interface,
5
+ * mapping lifecycle hooks to palaia functionality via the
6
+ * decomposed hooks modules.
6
7
  *
7
- * Phase 1.5: Created as a thin adapter over existing recall/capture logic.
8
+ * Supports both the modern ContextEngine interface (v2026.3.24+)
9
+ * and the legacy interface for backward compatibility.
8
10
  */
9
11
 
10
- import type { OpenClawPluginApi } from "./types.js";
12
+ import type {
13
+ OpenClawPluginApi,
14
+ ContextEngine,
15
+ AgentMessage,
16
+ } from "./types.js";
11
17
  import type { PalaiaPluginConfig } from "./config.js";
12
18
  import { run, recover, type RunnerOpts, getEmbedServerManager } from "./runner.js";
19
+ import { getOrCreateSessionState } from "./hooks/state.js";
20
+ import { formatBriefing } from "./hooks/session.js";
13
21
 
14
22
  import {
15
23
  extractMessageTexts,
16
24
  buildRecallQuery,
17
25
  rerankByTypeWeight,
18
26
  checkNudges,
27
+ formatEntryLine,
28
+ shouldUseCompactMode,
19
29
  type QueryResult,
20
30
  } from "./hooks/recall.js";
21
31
 
@@ -24,19 +34,16 @@ import {
24
34
  shouldAttemptCapture,
25
35
  extractSignificance,
26
36
  stripPalaiaInjectedContext,
37
+ stripPrivateBlocks,
27
38
  trimToRecentExchanges,
28
39
  parsePalaiaHints,
29
40
  loadProjects,
30
- resolveCaptureModel,
31
- type ExtractionResult,
32
41
  } from "./hooks/capture.js";
33
42
 
34
43
  import {
35
44
  loadPluginState,
36
45
  savePluginState,
37
- resolvePerAgentContext,
38
46
  sanitizeScope,
39
- isValidScope,
40
47
  } from "./hooks/state.js";
41
48
 
42
49
  import {
@@ -46,17 +53,24 @@ import {
46
53
  } from "./priorities.js";
47
54
 
48
55
  /**
49
- * ContextEngine adapter for deeper OpenClaw integration.
50
- * Maps the 7 ContextEngine lifecycle hooks to palaia functionality.
56
+ * Resolve the effective capture scope from priorities (per-agent override)
57
+ * falling back to plugin config, then to "team".
51
58
  */
52
- export interface PalaiaContextEngine {
53
- bootstrap(): Promise<void>;
54
- ingest(messages: unknown[]): Promise<void>;
55
- assemble(budget: { maxTokens: number }): Promise<{ content: string; tokenEstimate: number }>;
56
- compact(): Promise<void>;
57
- afterTurn(turn: unknown): Promise<void>;
58
- prepareSubagentSpawn(parentContext: unknown): Promise<unknown>;
59
- onSubagentEnded(result: unknown): Promise<void>;
59
+ async function getEffectiveCaptureScope(config: PalaiaPluginConfig): Promise<string> {
60
+ try {
61
+ const prio = await loadPriorities(config.workspace || "");
62
+ const agentId = process.env.PALAIA_AGENT || undefined;
63
+ const resolved = resolvePriorities(prio, {
64
+ recallTypeWeight: config.recallTypeWeight,
65
+ recallMinScore: config.recallMinScore,
66
+ maxInjectedChars: config.maxInjectedChars,
67
+ tier: config.tier,
68
+ }, agentId);
69
+ if (resolved.captureScope) return resolved.captureScope;
70
+ } catch {
71
+ // Fall through to config default
72
+ }
73
+ return config.captureScope || "team";
60
74
  }
61
75
 
62
76
  function buildRunnerOpts(config: PalaiaPluginConfig, overrides?: { workspace?: string }): RunnerOpts {
@@ -72,28 +86,307 @@ function estimateTokens(text: string): number {
72
86
  return Math.ceil(text.length / 4);
73
87
  }
74
88
 
89
+ /**
90
+ * Build the memory context string from recall results.
91
+ * Used by the ContextEngine assemble() method.
92
+ */
93
+ async function buildMemoryContext(
94
+ config: PalaiaPluginConfig,
95
+ opts: RunnerOpts,
96
+ messages: unknown[],
97
+ maxChars: number,
98
+ ): Promise<{ text: string; recallOccurred: boolean; recallMinScore: number }> {
99
+ // Load and resolve priorities (Issue #121)
100
+ const prio = await loadPriorities(config.workspace || "");
101
+ const agentId = process.env.PALAIA_AGENT || undefined;
102
+ const project = config.captureProject || undefined;
103
+ const resolvedPrio = resolvePriorities(prio, {
104
+ recallTypeWeight: config.recallTypeWeight,
105
+ recallMinScore: config.recallMinScore,
106
+ maxInjectedChars: config.maxInjectedChars,
107
+ tier: config.tier,
108
+ }, agentId, project);
109
+
110
+ const effectiveMaxChars = Math.min(resolvedPrio.maxInjectedChars || 4000, maxChars);
111
+ const limit = Math.min(config.maxResults || 10, 20);
112
+ let entries: QueryResult["results"] = [];
113
+
114
+ if (config.recallMode === "query" && messages.length > 0) {
115
+ const userMessage = buildRecallQuery(messages);
116
+ if (userMessage && userMessage.length >= 5) {
117
+ // Try embed server first
118
+ let serverQueried = false;
119
+ if (config.embeddingServer) {
120
+ try {
121
+ const mgr = getEmbedServerManager(opts);
122
+ const resp = await mgr.query({
123
+ text: userMessage,
124
+ top_k: limit,
125
+ include_cold: resolvedPrio.tier === "all",
126
+ ...(resolvedPrio.scopeVisibility ? { scope_visibility: resolvedPrio.scopeVisibility } : {}),
127
+ }, config.timeoutMs || 3000);
128
+ if (resp?.result?.results && Array.isArray(resp.result.results)) {
129
+ entries = resp.result.results;
130
+ serverQueried = true;
131
+ }
132
+ } catch {
133
+ // Fall through to CLI
134
+ }
135
+ }
136
+
137
+ if (!serverQueried) {
138
+ try {
139
+ const { runJson } = await import("./runner.js");
140
+ const queryArgs: string[] = ["query", userMessage, "--limit", String(limit)];
141
+ if (resolvedPrio.tier === "all") queryArgs.push("--all");
142
+ const result = await runJson<QueryResult>(queryArgs, { ...opts, timeoutMs: 15000 });
143
+ if (result && Array.isArray(result.results)) {
144
+ entries = result.results;
145
+ }
146
+ } catch {
147
+ // Fall through to list
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // List fallback
154
+ if (entries.length === 0) {
155
+ try {
156
+ const { runJson } = await import("./runner.js");
157
+ const listArgs: string[] = ["list"];
158
+ if (resolvedPrio.tier === "all") {
159
+ listArgs.push("--all");
160
+ } else {
161
+ listArgs.push("--tier", resolvedPrio.tier || "hot");
162
+ }
163
+ const result = await runJson<QueryResult>(listArgs, opts);
164
+ if (result && Array.isArray(result.results)) {
165
+ entries = result.results;
166
+ }
167
+ } catch {
168
+ return { text: "", recallOccurred: false, recallMinScore: resolvedPrio.recallMinScore };
169
+ }
170
+ }
171
+
172
+ if (entries.length === 0) {
173
+ return { text: "", recallOccurred: false, recallMinScore: resolvedPrio.recallMinScore };
174
+ }
175
+
176
+ // Apply type-weighted reranking and blocked filtering (Issue #121)
177
+ const rankedRaw = rerankByTypeWeight(entries, resolvedPrio.recallTypeWeight, config.recallRecencyBoost);
178
+ const ranked = filterBlocked(rankedRaw, resolvedPrio.blocked);
179
+
180
+ // Build context string — progressive disclosure for large stores
181
+ const compact = shouldUseCompactMode(ranked.length);
182
+ let text = "## Active Memory (Palaia)\n\n";
183
+ if (compact) {
184
+ text += "_Compact mode — use `memory_get <id>` for full details._\n\n";
185
+ }
186
+ let chars = text.length;
187
+
188
+ for (const entry of ranked) {
189
+ const line = formatEntryLine(entry, compact);
190
+ if (chars + line.length > effectiveMaxChars) break;
191
+ text += line;
192
+ chars += line.length;
193
+ }
194
+
195
+ // Build nudge text and check remaining budget before appending
196
+ 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.";
197
+ let agentNudges = "";
198
+ try {
199
+ const pluginState = await loadPluginState(config.workspace);
200
+ pluginState.successfulRecalls++;
201
+ if (!pluginState.firstRecallTimestamp) {
202
+ pluginState.firstRecallTimestamp = new Date().toISOString();
203
+ }
204
+ const { nudges } = checkNudges(pluginState);
205
+ if (nudges.length > 0) {
206
+ agentNudges = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
207
+ }
208
+ await savePluginState(pluginState, config.workspace);
209
+ } catch {
210
+ // Non-fatal
211
+ }
212
+
213
+ // --- Isolation workflow nudges (#148) ---
214
+ let isolationNudges = "";
215
+
216
+ // 1. missing_agent_identity: scopeVisibility configured but no PALAIA_AGENT
217
+ if (resolvedPrio.scopeVisibility && !process.env.PALAIA_AGENT) {
218
+ isolationNudges += "\n\n⚠️ scopeVisibility is configured but PALAIA_AGENT is not set. " +
219
+ "Scope filtering cannot work without agent identity. Set PALAIA_AGENT env var.";
220
+ }
221
+
222
+ // 2. recall_noise_ratio: >80% auto-capture entries for isolated agents
223
+ if (resolvedPrio.scopeVisibility && ranked.length >= 5) {
224
+ const autoCaptureCount = ranked.filter(e =>
225
+ (e.tags || []).includes("auto-capture")
226
+ ).length;
227
+ if (autoCaptureCount / ranked.length > 0.8) {
228
+ isolationNudges += "\n\n[palaia] Most recall results are auto-captured session data. " +
229
+ "Ask the orchestrator to run: palaia prune --agent " +
230
+ (process.env.PALAIA_AGENT || "<agent>") +
231
+ " --tags auto-capture --protect-type process";
232
+ }
233
+ }
234
+
235
+ const nudgeText = USAGE_NUDGE + "\n\n" + agentNudges + isolationNudges;
236
+ if (chars + nudgeText.length <= effectiveMaxChars) {
237
+ text += nudgeText;
238
+ }
239
+
240
+ const recallOccurred = entries.some(
241
+ (e) => typeof e.score === "number" && e.score >= resolvedPrio.recallMinScore,
242
+ );
243
+
244
+ return { text, recallOccurred, recallMinScore: resolvedPrio.recallMinScore };
245
+ }
246
+
247
+ /**
248
+ * Run auto-capture logic on a set of messages.
249
+ * Used by the ContextEngine afterTurn() method.
250
+ */
251
+ async function runAutoCapture(
252
+ messages: unknown[],
253
+ api: OpenClawPluginApi,
254
+ config: PalaiaPluginConfig,
255
+ logger: { info(...a: unknown[]): void; warn(...a: unknown[]): void },
256
+ ): Promise<boolean> {
257
+ if (!config.autoCapture) return false;
258
+ if (!messages || messages.length === 0) return false;
259
+
260
+ const allTexts = extractMessageTexts(messages);
261
+ const userTurns = allTexts.filter((t) => t.role === "user").length;
262
+ if (userTurns < config.captureMinTurns) return false;
263
+
264
+ // Parse capture hints
265
+ const collectedHints: { project?: string; scope?: string }[] = [];
266
+ for (const t of allTexts) {
267
+ const { hints } = parsePalaiaHints(t.text);
268
+ collectedHints.push(...hints);
269
+ }
270
+
271
+ // Strip injected context and private blocks, then trim to recent
272
+ const cleanedTexts = allTexts.map(t => ({
273
+ ...t,
274
+ text: stripPrivateBlocks(
275
+ t.role === "user" ? stripPalaiaInjectedContext(t.text) : t.text
276
+ ),
277
+ }));
278
+ const recentTexts = trimToRecentExchanges(cleanedTexts);
279
+ const exchangeParts: string[] = [];
280
+ for (const t of recentTexts) {
281
+ const { cleanedText } = parsePalaiaHints(t.text);
282
+ exchangeParts.push(`[${t.role}]: ${cleanedText}`);
283
+ }
284
+ const exchangeText = exchangeParts.join("\n");
285
+
286
+ if (!shouldAttemptCapture(exchangeText)) return false;
287
+
288
+ const hookOpts = buildRunnerOpts(config);
289
+ const knownProjects = await loadProjects(hookOpts);
290
+ const agentName = process.env.PALAIA_AGENT || undefined;
291
+ const effectiveCaptureScope = await getEffectiveCaptureScope(config);
292
+
293
+ // LLM-based extraction (primary)
294
+ let llmHandled = false;
295
+ try {
296
+ const results = await extractWithLLM(messages, api.config, {
297
+ captureModel: config.captureModel,
298
+ }, knownProjects);
299
+
300
+ for (const r of results) {
301
+ if (r.significance >= config.captureMinSignificance) {
302
+ const hintProject = collectedHints.find((h) => h.project)?.project;
303
+ const hintScope = collectedHints.find((h) => h.scope)?.scope;
304
+ const effectiveProject = hintProject || r.project;
305
+ const scope = effectiveCaptureScope !== "team"
306
+ ? sanitizeScope(effectiveCaptureScope, "team", true)
307
+ : sanitizeScope(hintScope || r.scope, "team", false);
308
+ const tags = [...r.tags];
309
+ if (!tags.includes("auto-capture")) tags.push("auto-capture");
310
+
311
+ const args: string[] = [
312
+ "write", r.content,
313
+ "--type", r.type,
314
+ "--tags", tags.join(",") || "auto-capture",
315
+ "--scope", scope,
316
+ ];
317
+ const project = config.captureProject || effectiveProject;
318
+ if (project) args.push("--project", project);
319
+ if (agentName) args.push("--agent", agentName);
320
+
321
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
322
+ }
323
+ }
324
+ llmHandled = true;
325
+ } catch {
326
+ // Fall through to rule-based
327
+ }
328
+
329
+ // Rule-based fallback
330
+ if (!llmHandled) {
331
+ if (config.captureFrequency === "significant") {
332
+ const significance = extractSignificance(exchangeText);
333
+ if (!significance) return false;
334
+ const tags = [...significance.tags];
335
+ if (!tags.includes("auto-capture")) tags.push("auto-capture");
336
+ const scope = effectiveCaptureScope !== "team"
337
+ ? sanitizeScope(effectiveCaptureScope, "team", true)
338
+ : "team";
339
+ const args: string[] = [
340
+ "write", significance.summary,
341
+ "--type", significance.type,
342
+ "--tags", tags.join(","),
343
+ "--scope", scope,
344
+ ];
345
+ if (agentName) args.push("--agent", agentName);
346
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
347
+ } else {
348
+ const summary = exchangeParts.slice(-4).map(p => p.slice(0, 200)).join(" | ").slice(0, 500);
349
+ const args: string[] = [
350
+ "write", summary,
351
+ "--type", "memory",
352
+ "--tags", "auto-capture",
353
+ "--scope", effectiveCaptureScope,
354
+ ];
355
+ if (agentName) args.push("--agent", agentName);
356
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
357
+ }
358
+ }
359
+
360
+ return true;
361
+ }
362
+
363
+ /**
364
+ * Create a palaia ContextEngine implementing OpenClaw v2026.3.24 interface.
365
+ */
75
366
  export function createPalaiaContextEngine(
76
367
  api: OpenClawPluginApi,
77
368
  config: PalaiaPluginConfig,
78
- ): PalaiaContextEngine {
369
+ ): ContextEngine {
79
370
  const logger = api.logger;
80
371
  const opts = buildRunnerOpts(config);
81
372
 
82
373
  /** Last messages seen via ingest(), used by assemble() for query building. */
83
- let _lastMessages: unknown[] = [];
374
+ let _lastMessages: AgentMessage[] = [];
84
375
 
85
- /** State from the last assemble() call, used by afterTurn(). */
86
- let _lastAssembleState: {
87
- recallOccurred: boolean;
88
- capturedInThisTurn: boolean;
89
- } = { recallOccurred: false, capturedInThisTurn: false };
376
+ /** Whether the last assemble() call found relevant memories. */
377
+ let _lastRecallOccurred = false;
378
+
379
+ const engine: ContextEngine = {
380
+ info: {
381
+ id: "palaia",
382
+ name: "Palaia Memory",
383
+ version: "2.3",
384
+ },
90
385
 
91
- return {
92
386
  /**
93
387
  * Bootstrap: WAL recovery + embed-server start.
94
- * Maps to the palaia-recovery service from runner.ts.
95
388
  */
96
- async bootstrap(): Promise<void> {
389
+ async bootstrap(_params) {
97
390
  try {
98
391
  const result = await recover(opts);
99
392
  if (result.replayed > 0) {
@@ -116,326 +409,176 @@ export function createPalaiaContextEngine(
116
409
  logger.info(`[palaia] Embed server pre-start skipped: ${err}`);
117
410
  }
118
411
  }
412
+
413
+ return { bootstrapped: true };
119
414
  },
120
415
 
121
416
  /**
122
- * Ingest: auto-capture logic from hooks/capture.ts.
123
- * Called with the turn's messages after the agent responds.
417
+ * Ingest: process a single message for auto-capture.
418
+ * Called per-message by the ContextEngine lifecycle.
419
+ * Accumulates messages; capture runs in afterTurn().
124
420
  */
125
- async ingest(messages: unknown[]): Promise<void> {
126
- _lastMessages = messages;
421
+ async ingest(params) {
422
+ if (params.message) {
423
+ _lastMessages.push(params.message);
424
+ }
425
+ return { ingested: true };
426
+ },
127
427
 
128
- if (!config.autoCapture) return;
129
- if (!messages || messages.length === 0) return;
428
+ /**
429
+ * AfterTurn: run auto-capture on accumulated messages.
430
+ */
431
+ async afterTurn(params) {
432
+ // Use params.messages if available (richer), else fall back to accumulated
433
+ const messages = params?.messages?.length ? params.messages : _lastMessages;
130
434
 
131
435
  try {
132
- const allTexts = extractMessageTexts(messages);
133
- const userTurns = allTexts.filter((t) => t.role === "user").length;
134
- if (userTurns < config.captureMinTurns) return;
135
-
136
- // Parse capture hints
137
- const collectedHints: { project?: string; scope?: string }[] = [];
138
- for (const t of allTexts) {
139
- const { hints } = parsePalaiaHints(t.text);
140
- collectedHints.push(...hints);
141
- }
142
-
143
- // Strip injected context and trim to recent
144
- const cleanedTexts = allTexts.map(t =>
145
- t.role === "user"
146
- ? { ...t, text: stripPalaiaInjectedContext(t.text) }
147
- : t
148
- );
149
- const recentTexts = trimToRecentExchanges(cleanedTexts);
150
- const exchangeParts: string[] = [];
151
- for (const t of recentTexts) {
152
- const { cleanedText } = parsePalaiaHints(t.text);
153
- exchangeParts.push(`[${t.role}]: ${cleanedText}`);
154
- }
155
- const exchangeText = exchangeParts.join("\n");
156
-
157
- if (!shouldAttemptCapture(exchangeText)) return;
158
-
159
- const hookOpts = buildRunnerOpts(config);
160
- const knownProjects = await loadProjects(hookOpts);
161
- const agentName = process.env.PALAIA_AGENT || undefined;
162
-
163
- // LLM-based extraction (primary)
164
- let llmHandled = false;
165
- try {
166
- const results = await extractWithLLM(messages, api.config, {
167
- captureModel: config.captureModel,
168
- }, knownProjects);
169
-
170
- for (const r of results) {
171
- if (r.significance >= config.captureMinSignificance) {
172
- const hintProject = collectedHints.find((h) => h.project)?.project;
173
- const hintScope = collectedHints.find((h) => h.scope)?.scope;
174
- const effectiveProject = hintProject || r.project;
175
- const scope = config.captureScope
176
- ? sanitizeScope(config.captureScope, "team", true)
177
- : sanitizeScope(hintScope || r.scope, "team", false);
178
- const tags = [...r.tags];
179
- if (!tags.includes("auto-capture")) tags.push("auto-capture");
180
-
181
- const args: string[] = [
182
- "write", r.content,
183
- "--type", r.type,
184
- "--tags", tags.join(",") || "auto-capture",
185
- "--scope", scope,
186
- ];
187
- const project = config.captureProject || effectiveProject;
188
- if (project) args.push("--project", project);
189
- if (agentName) args.push("--agent", agentName);
190
-
191
- await run(args, { ...hookOpts, timeoutMs: 10_000 });
192
- }
193
- }
194
- llmHandled = true;
195
- } catch {
196
- // Fall through to rule-based
197
- }
198
-
199
- // Rule-based fallback
200
- if (!llmHandled) {
201
- if (config.captureFrequency === "significant") {
202
- const significance = extractSignificance(exchangeText);
203
- if (!significance) return;
204
- const tags = [...significance.tags];
205
- if (!tags.includes("auto-capture")) tags.push("auto-capture");
206
- const scope = config.captureScope
207
- ? sanitizeScope(config.captureScope, "team", true)
208
- : "team";
209
- const args: string[] = [
210
- "write", significance.summary,
211
- "--type", significance.type,
212
- "--tags", tags.join(","),
213
- "--scope", scope,
214
- ];
215
- if (agentName) args.push("--agent", agentName);
216
- await run(args, { ...hookOpts, timeoutMs: 10_000 });
217
- } else {
218
- const summary = exchangeParts.slice(-4).map(p => p.slice(0, 200)).join(" | ").slice(0, 500);
219
- const args: string[] = [
220
- "write", summary,
221
- "--type", "memory",
222
- "--tags", "auto-capture",
223
- "--scope", config.captureScope || "team",
224
- ];
225
- if (agentName) args.push("--agent", agentName);
226
- await run(args, { ...hookOpts, timeoutMs: 10_000 });
227
- }
228
- }
229
-
230
- _lastAssembleState.capturedInThisTurn = true;
436
+ await runAutoCapture(messages, api, config, logger);
231
437
  } catch (error) {
232
- logger.warn(`[palaia] ContextEngine ingest failed: ${error}`);
438
+ logger.warn(`[palaia] ContextEngine afterTurn capture failed: ${error}`);
233
439
  }
440
+
441
+ // Reset for next turn
442
+ _lastRecallOccurred = false;
443
+ _lastMessages = [];
234
444
  },
235
445
 
236
446
  /**
237
- * Assemble: recall logic with token budget awareness from hooks/recall.ts.
238
- * Returns memory context string and token estimate, respecting the budget.
447
+ * Assemble: recall logic with token budget awareness.
448
+ * Returns memory context via systemPromptAddition.
239
449
  */
240
- async assemble(budget: { maxTokens: number }): Promise<{ content: string; tokenEstimate: number }> {
241
- _lastAssembleState = { recallOccurred: false, capturedInThisTurn: _lastAssembleState.capturedInThisTurn };
450
+ async assemble(params) {
451
+ _lastRecallOccurred = false;
242
452
 
243
453
  if (!config.memoryInject) {
244
- return { content: "", tokenEstimate: 0 };
454
+ return {
455
+ messages: params.messages || [],
456
+ estimatedTokens: 0,
457
+ };
245
458
  }
246
459
 
247
460
  try {
248
- // Load and resolve priorities (Issue #121)
249
- const prio = await loadPriorities(config.workspace || "");
250
- const agentId = process.env.PALAIA_AGENT || undefined;
251
- const project = config.captureProject || undefined;
252
- const resolvedPrio = resolvePriorities(prio, {
253
- recallTypeWeight: config.recallTypeWeight,
254
- recallMinScore: config.recallMinScore,
255
- maxInjectedChars: config.maxInjectedChars,
256
- tier: config.tier,
257
- }, agentId, project);
258
-
259
- // Convert token budget to char budget (~4 chars per token)
260
- const maxChars = Math.min(resolvedPrio.maxInjectedChars || 4000, budget.maxTokens * 4);
261
- const limit = Math.min(config.maxResults || 10, 20);
262
- let entries: QueryResult["results"] = [];
263
-
264
- if (config.recallMode === "query" && _lastMessages.length > 0) {
265
- const userMessage = buildRecallQuery(_lastMessages);
266
- if (userMessage && userMessage.length >= 5) {
267
- // Try embed server first
268
- let serverQueried = false;
269
- if (config.embeddingServer) {
270
- try {
271
- const mgr = getEmbedServerManager(opts);
272
- const resp = await mgr.query({
273
- text: userMessage,
274
- top_k: limit,
275
- include_cold: resolvedPrio.tier === "all",
276
- }, config.timeoutMs || 3000);
277
- if (resp?.result?.results && Array.isArray(resp.result.results)) {
278
- entries = resp.result.results;
279
- serverQueried = true;
280
- }
281
- } catch {
282
- // Fall through to CLI
283
- }
284
- }
461
+ const tokenBudget = params.tokenBudget || 4000;
462
+ const maxChars = tokenBudget * 4;
285
463
 
286
- if (!serverQueried) {
287
- try {
288
- const { runJson } = await import("./runner.js");
289
- const queryArgs: string[] = ["query", userMessage, "--limit", String(limit)];
290
- if (resolvedPrio.tier === "all") queryArgs.push("--all");
291
- const result = await runJson<QueryResult>(queryArgs, { ...opts, timeoutMs: 15000 });
292
- if (result && Array.isArray(result.results)) {
293
- entries = result.results;
294
- }
295
- } catch {
296
- // Fall through to list
297
- }
298
- }
299
- }
300
- }
464
+ // Use params.messages for query building (richer than accumulated)
465
+ const queryMessages = params.messages?.length ? params.messages : _lastMessages;
466
+
467
+ const { text, recallOccurred } = await buildMemoryContext(
468
+ config, opts, queryMessages, maxChars,
469
+ );
301
470
 
302
- // List fallback
303
- if (entries.length === 0) {
304
- try {
305
- const { runJson } = await import("./runner.js");
306
- const listArgs: string[] = ["list"];
307
- if (resolvedPrio.tier === "all") {
308
- listArgs.push("--all");
309
- } else {
310
- listArgs.push("--tier", resolvedPrio.tier || "hot");
471
+ _lastRecallOccurred = recallOccurred;
472
+
473
+ // Inject session briefing if pending (session_start or model switch)
474
+ let briefingText = "";
475
+ if (config.sessionBriefing && params.sessionKey) {
476
+ const sessionState = getOrCreateSessionState(params.sessionKey);
477
+ if (sessionState.pendingBriefing && !sessionState.briefingDelivered) {
478
+ // Wait for briefing to be ready (with timeout)
479
+ if (sessionState.briefingReady) {
480
+ await Promise.race([
481
+ sessionState.briefingReady,
482
+ new Promise<void>(r => setTimeout(r, 3000)),
483
+ ]);
311
484
  }
312
- const result = await runJson<QueryResult>(listArgs, opts);
313
- if (result && Array.isArray(result.results)) {
314
- entries = result.results;
485
+ if (sessionState.pendingBriefing) {
486
+ const remainingChars = maxChars - (text?.length || 0);
487
+ briefingText = formatBriefing(
488
+ sessionState.pendingBriefing,
489
+ Math.min(config.sessionBriefingMaxChars || 1500, remainingChars),
490
+ );
491
+ if (briefingText) {
492
+ sessionState.briefingDelivered = true;
493
+ }
315
494
  }
316
- } catch {
317
- return { content: "", tokenEstimate: 0 };
318
495
  }
319
496
  }
320
497
 
321
- if (entries.length === 0) {
322
- return { content: "", tokenEstimate: 0 };
323
- }
324
-
325
- // Apply type-weighted reranking and blocked filtering (Issue #121)
326
- const rankedRaw = rerankByTypeWeight(entries, resolvedPrio.recallTypeWeight);
327
- const ranked = filterBlocked(rankedRaw, resolvedPrio.blocked);
498
+ const combined = [briefingText, text].filter(Boolean).join("\n\n");
328
499
 
329
- // Build context string
330
- const SCOPE_SHORT: Record<string, string> = { team: "t", private: "p", public: "pub" };
331
- const TYPE_SHORT: Record<string, string> = { memory: "m", process: "pr", task: "tk" };
332
-
333
- let text = "## Active Memory (Palaia)\n\n";
334
- let chars = text.length;
335
-
336
- for (const entry of ranked) {
337
- const scopeKey = SCOPE_SHORT[entry.scope] || entry.scope;
338
- const typeKey = TYPE_SHORT[entry.type] || entry.type;
339
- const prefix = `[${scopeKey}/${typeKey}]`;
340
-
341
- let line: string;
342
- if (entry.body.toLowerCase().startsWith(entry.title.toLowerCase())) {
343
- line = `${prefix} ${entry.body}\n\n`;
344
- } else {
345
- line = `${prefix} ${entry.title}\n${entry.body}\n\n`;
346
- }
347
-
348
- if (chars + line.length > maxChars) break;
349
- text += line;
350
- chars += line.length;
500
+ if (!combined) {
501
+ return {
502
+ messages: params.messages || [],
503
+ estimatedTokens: 0,
504
+ };
351
505
  }
352
506
 
353
- // Build nudge text and check remaining budget before appending
354
- 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.";
355
- let agentNudges = "";
356
- try {
357
- const pluginState = await loadPluginState(config.workspace);
358
- pluginState.successfulRecalls++;
359
- if (!pluginState.firstRecallTimestamp) {
360
- pluginState.firstRecallTimestamp = new Date().toISOString();
361
- }
362
- const { nudges } = checkNudges(pluginState);
363
- if (nudges.length > 0) {
364
- agentNudges = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
365
- }
366
- await savePluginState(pluginState, config.workspace);
367
- } catch {
368
- // Non-fatal
369
- }
370
-
371
- // Before adding nudges, check remaining budget
372
- const nudgeText = USAGE_NUDGE + "\n\n" + agentNudges;
373
- if (chars + nudgeText.length <= maxChars) {
374
- text += nudgeText;
375
- }
376
- // If nudges don't fit, skip them — the recall content is more important
377
-
378
- _lastAssembleState.recallOccurred = entries.some(
379
- (e) => typeof e.score === "number" && e.score >= resolvedPrio.recallMinScore,
380
- );
381
-
382
507
  return {
383
- content: text,
384
- tokenEstimate: estimateTokens(text),
508
+ messages: params.messages || [],
509
+ estimatedTokens: estimateTokens(combined),
510
+ systemPromptAddition: combined,
385
511
  };
386
512
  } catch (error) {
387
513
  logger.warn(`[palaia] ContextEngine assemble failed: ${error}`);
388
- return { content: "", tokenEstimate: 0 };
514
+ return {
515
+ messages: params.messages || [],
516
+ estimatedTokens: 0,
517
+ };
389
518
  }
390
519
  },
391
520
 
392
521
  /**
393
522
  * Compact: trigger `palaia gc` via runner.
394
523
  */
395
- async compact(): Promise<void> {
524
+ async compact(_params) {
396
525
  try {
397
526
  await run(["gc"], { ...opts, timeoutMs: 30_000 });
398
527
  logger.info("[palaia] GC compaction completed");
528
+ return { ok: true, compacted: true };
399
529
  } catch (error) {
400
530
  logger.warn(`[palaia] GC compaction failed: ${error}`);
531
+ return { ok: false, compacted: false, reason: String(error) };
401
532
  }
402
533
  },
403
534
 
404
535
  /**
405
- * AfterTurn: state save + emoji reactions.
406
- * Called after each agent turn completes.
407
- */
408
- async afterTurn(turn: unknown): Promise<void> {
409
- // State save is handled implicitly by assemble() nudge logic.
410
- // Reset for next turn.
411
- _lastAssembleState = { recallOccurred: false, capturedInThisTurn: false };
412
- _lastMessages = [];
413
- },
414
-
415
- /**
416
- * PrepareSubagentSpawn: pass workspace + agent identity.
417
- * Returns context for the sub-agent to inherit.
536
+ * PrepareSubagentSpawn: load session summary for sub-agent context.
418
537
  */
419
- async prepareSubagentSpawn(parentContext: unknown): Promise<unknown> {
420
- const workspace = typeof api.workspace === "string"
421
- ? api.workspace
422
- : api.workspace?.dir;
423
- const agentId = process.env.PALAIA_AGENT || undefined;
424
-
425
- return {
426
- palaiaWorkspace: workspace || config.workspace,
427
- palaiaAgentId: agentId,
428
- parentContext,
429
- };
538
+ async prepareSubagentSpawn(_params) {
539
+ // Sub-agents share the same palaia store via workspace,
540
+ // so no explicit context passing is needed.
541
+ // Future: use OpenClaw's extraSystemPrompt to pass session summary.
542
+ return undefined;
430
543
  },
431
544
 
432
545
  /**
433
- * OnSubagentEnded: placeholder for future sub-agent memory merge.
434
- * Will eventually merge sub-agent captures into parent context.
546
+ * OnSubagentEnded: capture sub-agent result as memory.
435
547
  */
436
- async onSubagentEnded(_result: unknown): Promise<void> {
437
- // Future: merge sub-agent memory captures into parent agent's context.
438
- // For now, sub-agents write to the same palaia store, so no merge needed.
548
+ async onSubagentEnded(params) {
549
+ if (!config.autoCapture) return;
550
+ // Only capture results for successfully completed sub-agents
551
+ if (params?.reason !== "completed") return;
552
+ try {
553
+ // Try to get sub-agent's last messages for summary
554
+ if (api.runtime?.subagent?.getSessionMessages && params?.childSessionKey) {
555
+ const result = await api.runtime.subagent.getSessionMessages({
556
+ sessionKey: params.childSessionKey,
557
+ limit: 10,
558
+ });
559
+ if (result?.messages?.length) {
560
+ const texts = extractMessageTexts(result.messages);
561
+ const summaryParts = texts.slice(-4).map(
562
+ (t: { role: string; text: string }) => `[${t.role}]: ${t.text.slice(0, 200)}`
563
+ );
564
+ if (summaryParts.length > 0) {
565
+ const summaryText = `Sub-agent result:\n${summaryParts.join("\n")}`.slice(0, 500);
566
+ const subagentScope = await getEffectiveCaptureScope(config);
567
+ await run([
568
+ "write", summaryText,
569
+ "--type", "memory",
570
+ "--tags", "subagent-result,auto-capture",
571
+ "--scope", subagentScope,
572
+ ], { ...opts, timeoutMs: 10_000 });
573
+ logger.info(`[palaia] Sub-agent result captured (${summaryText.length} chars)`);
574
+ }
575
+ }
576
+ }
577
+ } catch (error) {
578
+ logger.warn(`[palaia] Sub-agent result capture failed: ${error}`);
579
+ }
439
580
  },
440
581
  };
582
+
583
+ return engine;
441
584
  }