@byte5ai/palaia 2.3.6 → 2.7.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.
- package/README.md +5 -5
- package/index.ts +44 -19
- package/openclaw.plugin.json +3 -3
- package/package.json +3 -3
- package/skill/SKILL.md +408 -28
- package/src/config.ts +21 -1
- package/src/context-engine.ts +454 -298
- package/src/hooks/capture.ts +36 -22
- package/src/hooks/index.ts +115 -41
- package/src/hooks/recall.ts +74 -6
- package/src/hooks/session.ts +465 -0
- package/src/hooks/state.ts +106 -3
- package/src/priorities.ts +12 -0
- package/src/runner.ts +4 -4
- package/src/tools.ts +65 -23
- package/src/types.ts +409 -22
package/src/context-engine.ts
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ContextEngine adapter for
|
|
2
|
+
* ContextEngine adapter for OpenClaw integration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
|
@@ -23,20 +33,17 @@ import {
|
|
|
23
33
|
extractWithLLM,
|
|
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
|
-
*
|
|
50
|
-
*
|
|
56
|
+
* Resolve the effective capture scope from priorities (per-agent override)
|
|
57
|
+
* falling back to plugin config, then to "team".
|
|
51
58
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,320 @@ 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, config.manualEntryBoost);
|
|
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 writes rank higher in recall. Use --type process for reusable workflows, --type task for sticky-note reminders (auto-deleted when done), --type memory for important facts.";
|
|
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) {
|
|
259
|
+
logger.info("[palaia] Auto-capture skipped: no messages");
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const allTexts = extractMessageTexts(messages);
|
|
264
|
+
const userTurns = allTexts.filter((t) => t.role === "user").length;
|
|
265
|
+
if (userTurns < config.captureMinTurns) {
|
|
266
|
+
logger.info(`[palaia] Auto-capture skipped: ${userTurns} user turns < captureMinTurns=${config.captureMinTurns}`);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Parse capture hints
|
|
271
|
+
const collectedHints: { project?: string; scope?: string }[] = [];
|
|
272
|
+
for (const t of allTexts) {
|
|
273
|
+
const { hints } = parsePalaiaHints(t.text);
|
|
274
|
+
collectedHints.push(...hints);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Strip injected context and private blocks, then trim to recent
|
|
278
|
+
const cleanedTexts = allTexts.map(t => ({
|
|
279
|
+
...t,
|
|
280
|
+
text: stripPrivateBlocks(
|
|
281
|
+
t.role === "user" ? strippalaiaInjectedContext(t.text) : t.text
|
|
282
|
+
),
|
|
283
|
+
}));
|
|
284
|
+
const recentTexts = trimToRecentExchanges(cleanedTexts);
|
|
285
|
+
const exchangeParts: string[] = [];
|
|
286
|
+
for (const t of recentTexts) {
|
|
287
|
+
const { cleanedText } = parsePalaiaHints(t.text);
|
|
288
|
+
exchangeParts.push(`[${t.role}]: ${cleanedText}`);
|
|
289
|
+
}
|
|
290
|
+
const exchangeText = exchangeParts.join("\n");
|
|
291
|
+
|
|
292
|
+
if (!shouldAttemptCapture(exchangeText)) {
|
|
293
|
+
logger.info(`[palaia] Auto-capture skipped: content did not pass significance filter (${exchangeText.length} chars)`);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const hookOpts = buildRunnerOpts(config);
|
|
298
|
+
const knownProjects = await loadProjects(hookOpts);
|
|
299
|
+
const agentName = process.env.PALAIA_AGENT || undefined;
|
|
300
|
+
const effectiveCaptureScope = await getEffectiveCaptureScope(config);
|
|
301
|
+
|
|
302
|
+
// LLM-based extraction (primary)
|
|
303
|
+
let llmHandled = false;
|
|
304
|
+
try {
|
|
305
|
+
const results = await extractWithLLM(messages, api.config, {
|
|
306
|
+
captureModel: config.captureModel,
|
|
307
|
+
workspace: config.workspace,
|
|
308
|
+
}, knownProjects);
|
|
309
|
+
|
|
310
|
+
for (const r of results) {
|
|
311
|
+
if (r.significance >= config.captureMinSignificance) {
|
|
312
|
+
const hintProject = collectedHints.find((h) => h.project)?.project;
|
|
313
|
+
const hintScope = collectedHints.find((h) => h.scope)?.scope;
|
|
314
|
+
const effectiveProject = hintProject || r.project;
|
|
315
|
+
const scope = effectiveCaptureScope !== "team"
|
|
316
|
+
? sanitizeScope(effectiveCaptureScope, "team", true)
|
|
317
|
+
: sanitizeScope(hintScope || r.scope, "team", false);
|
|
318
|
+
const tags = [...r.tags];
|
|
319
|
+
if (!tags.includes("auto-capture")) tags.push("auto-capture");
|
|
320
|
+
|
|
321
|
+
const args: string[] = [
|
|
322
|
+
"write", r.content,
|
|
323
|
+
"--type", r.type,
|
|
324
|
+
"--tags", tags.join(",") || "auto-capture",
|
|
325
|
+
"--scope", scope,
|
|
326
|
+
];
|
|
327
|
+
const project = config.captureProject || effectiveProject;
|
|
328
|
+
if (project) args.push("--project", project);
|
|
329
|
+
if (agentName) args.push("--agent", agentName);
|
|
330
|
+
|
|
331
|
+
await run(args, { ...hookOpts, timeoutMs: 10_000 });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
llmHandled = true;
|
|
335
|
+
} catch {
|
|
336
|
+
// Fall through to rule-based
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Rule-based fallback
|
|
340
|
+
if (!llmHandled) {
|
|
341
|
+
if (config.captureFrequency === "significant") {
|
|
342
|
+
const significance = extractSignificance(exchangeText);
|
|
343
|
+
if (!significance) {
|
|
344
|
+
logger.info("[palaia] Auto-capture skipped: rule-based extraction found no significance (need ≥2 distinct tags)");
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
const tags = [...significance.tags];
|
|
348
|
+
if (!tags.includes("auto-capture")) tags.push("auto-capture");
|
|
349
|
+
const scope = effectiveCaptureScope !== "team"
|
|
350
|
+
? sanitizeScope(effectiveCaptureScope, "team", true)
|
|
351
|
+
: "team";
|
|
352
|
+
const args: string[] = [
|
|
353
|
+
"write", significance.summary,
|
|
354
|
+
"--type", significance.type,
|
|
355
|
+
"--tags", tags.join(","),
|
|
356
|
+
"--scope", scope,
|
|
357
|
+
];
|
|
358
|
+
if (agentName) args.push("--agent", agentName);
|
|
359
|
+
await run(args, { ...hookOpts, timeoutMs: 10_000 });
|
|
360
|
+
} else {
|
|
361
|
+
const summary = exchangeParts.slice(-4).map(p => p.slice(0, 200)).join(" | ").slice(0, 500);
|
|
362
|
+
const args: string[] = [
|
|
363
|
+
"write", summary,
|
|
364
|
+
"--type", "memory",
|
|
365
|
+
"--tags", "auto-capture",
|
|
366
|
+
"--scope", effectiveCaptureScope,
|
|
367
|
+
];
|
|
368
|
+
if (agentName) args.push("--agent", agentName);
|
|
369
|
+
await run(args, { ...hookOpts, timeoutMs: 10_000 });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Create a palaia ContextEngine implementing OpenClaw v2026.3.24 interface.
|
|
378
|
+
*/
|
|
75
379
|
export function createPalaiaContextEngine(
|
|
76
380
|
api: OpenClawPluginApi,
|
|
77
381
|
config: PalaiaPluginConfig,
|
|
78
|
-
):
|
|
382
|
+
): ContextEngine {
|
|
79
383
|
const logger = api.logger;
|
|
80
384
|
const opts = buildRunnerOpts(config);
|
|
81
385
|
|
|
82
386
|
/** Last messages seen via ingest(), used by assemble() for query building. */
|
|
83
|
-
let _lastMessages:
|
|
387
|
+
let _lastMessages: AgentMessage[] = [];
|
|
84
388
|
|
|
85
|
-
/**
|
|
86
|
-
let
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
389
|
+
/** Whether the last assemble() call found relevant memories. */
|
|
390
|
+
let _lastRecallOccurred = false;
|
|
391
|
+
|
|
392
|
+
const engine: ContextEngine = {
|
|
393
|
+
info: {
|
|
394
|
+
id: "palaia",
|
|
395
|
+
name: "palaia Memory",
|
|
396
|
+
version: "2.3",
|
|
397
|
+
},
|
|
90
398
|
|
|
91
|
-
return {
|
|
92
399
|
/**
|
|
93
400
|
* Bootstrap: WAL recovery + embed-server start.
|
|
94
|
-
* Maps to the palaia-recovery service from runner.ts.
|
|
95
401
|
*/
|
|
96
|
-
async bootstrap()
|
|
402
|
+
async bootstrap(_params) {
|
|
97
403
|
try {
|
|
98
404
|
const result = await recover(opts);
|
|
99
405
|
if (result.replayed > 0) {
|
|
@@ -116,326 +422,176 @@ export function createPalaiaContextEngine(
|
|
|
116
422
|
logger.info(`[palaia] Embed server pre-start skipped: ${err}`);
|
|
117
423
|
}
|
|
118
424
|
}
|
|
425
|
+
|
|
426
|
+
return { bootstrapped: true };
|
|
119
427
|
},
|
|
120
428
|
|
|
121
429
|
/**
|
|
122
|
-
* Ingest:
|
|
123
|
-
* Called
|
|
430
|
+
* Ingest: process a single message for auto-capture.
|
|
431
|
+
* Called per-message by the ContextEngine lifecycle.
|
|
432
|
+
* Accumulates messages; capture runs in afterTurn().
|
|
124
433
|
*/
|
|
125
|
-
async ingest(
|
|
126
|
-
|
|
434
|
+
async ingest(params) {
|
|
435
|
+
if (params.message) {
|
|
436
|
+
_lastMessages.push(params.message);
|
|
437
|
+
}
|
|
438
|
+
return { ingested: true };
|
|
439
|
+
},
|
|
127
440
|
|
|
128
|
-
|
|
129
|
-
|
|
441
|
+
/**
|
|
442
|
+
* AfterTurn: run auto-capture on accumulated messages.
|
|
443
|
+
*/
|
|
444
|
+
async afterTurn(params) {
|
|
445
|
+
// Use params.messages if available (richer), else fall back to accumulated
|
|
446
|
+
const messages = params?.messages?.length ? params.messages : _lastMessages;
|
|
130
447
|
|
|
131
448
|
try {
|
|
132
|
-
|
|
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;
|
|
449
|
+
await runAutoCapture(messages, api, config, logger);
|
|
231
450
|
} catch (error) {
|
|
232
|
-
logger.warn(`[palaia] ContextEngine
|
|
451
|
+
logger.warn(`[palaia] ContextEngine afterTurn capture failed: ${error}`);
|
|
233
452
|
}
|
|
453
|
+
|
|
454
|
+
// Reset for next turn
|
|
455
|
+
_lastRecallOccurred = false;
|
|
456
|
+
_lastMessages = [];
|
|
234
457
|
},
|
|
235
458
|
|
|
236
459
|
/**
|
|
237
|
-
* Assemble: recall logic with token budget awareness
|
|
238
|
-
* Returns memory context
|
|
460
|
+
* Assemble: recall logic with token budget awareness.
|
|
461
|
+
* Returns memory context via systemPromptAddition.
|
|
239
462
|
*/
|
|
240
|
-
async assemble(
|
|
241
|
-
|
|
463
|
+
async assemble(params) {
|
|
464
|
+
_lastRecallOccurred = false;
|
|
242
465
|
|
|
243
466
|
if (!config.memoryInject) {
|
|
244
|
-
return {
|
|
467
|
+
return {
|
|
468
|
+
messages: params.messages || [],
|
|
469
|
+
estimatedTokens: 0,
|
|
470
|
+
};
|
|
245
471
|
}
|
|
246
472
|
|
|
247
473
|
try {
|
|
248
|
-
|
|
249
|
-
const
|
|
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
|
-
}
|
|
474
|
+
const tokenBudget = params.tokenBudget || 4000;
|
|
475
|
+
const maxChars = tokenBudget * 4;
|
|
285
476
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (result && Array.isArray(result.results)) {
|
|
293
|
-
entries = result.results;
|
|
294
|
-
}
|
|
295
|
-
} catch {
|
|
296
|
-
// Fall through to list
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
477
|
+
// Use params.messages for query building (richer than accumulated)
|
|
478
|
+
const queryMessages = params.messages?.length ? params.messages : _lastMessages;
|
|
479
|
+
|
|
480
|
+
const { text, recallOccurred } = await buildMemoryContext(
|
|
481
|
+
config, opts, queryMessages, maxChars,
|
|
482
|
+
);
|
|
301
483
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
484
|
+
_lastRecallOccurred = recallOccurred;
|
|
485
|
+
|
|
486
|
+
// Inject session briefing if pending (session_start or model switch)
|
|
487
|
+
let briefingText = "";
|
|
488
|
+
if (config.sessionBriefing && params.sessionKey) {
|
|
489
|
+
const sessionState = getOrCreateSessionState(params.sessionKey);
|
|
490
|
+
if (sessionState.pendingBriefing && !sessionState.briefingDelivered) {
|
|
491
|
+
// Wait for briefing to be ready (with timeout)
|
|
492
|
+
if (sessionState.briefingReady) {
|
|
493
|
+
await Promise.race([
|
|
494
|
+
sessionState.briefingReady,
|
|
495
|
+
new Promise<void>(r => setTimeout(r, 3000)),
|
|
496
|
+
]);
|
|
311
497
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
498
|
+
if (sessionState.pendingBriefing) {
|
|
499
|
+
const remainingChars = maxChars - (text?.length || 0);
|
|
500
|
+
briefingText = formatBriefing(
|
|
501
|
+
sessionState.pendingBriefing,
|
|
502
|
+
Math.min(config.sessionBriefingMaxChars || 1500, remainingChars),
|
|
503
|
+
);
|
|
504
|
+
if (briefingText) {
|
|
505
|
+
sessionState.briefingDelivered = true;
|
|
506
|
+
}
|
|
315
507
|
}
|
|
316
|
-
} catch {
|
|
317
|
-
return { content: "", tokenEstimate: 0 };
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
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);
|
|
328
|
-
|
|
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
508
|
}
|
|
347
|
-
|
|
348
|
-
if (chars + line.length > maxChars) break;
|
|
349
|
-
text += line;
|
|
350
|
-
chars += line.length;
|
|
351
509
|
}
|
|
352
510
|
|
|
353
|
-
|
|
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
|
-
}
|
|
511
|
+
const combined = [briefingText, text].filter(Boolean).join("\n\n");
|
|
370
512
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
513
|
+
if (!combined) {
|
|
514
|
+
return {
|
|
515
|
+
messages: params.messages || [],
|
|
516
|
+
estimatedTokens: 0,
|
|
517
|
+
};
|
|
375
518
|
}
|
|
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
519
|
|
|
382
520
|
return {
|
|
383
|
-
|
|
384
|
-
|
|
521
|
+
messages: params.messages || [],
|
|
522
|
+
estimatedTokens: estimateTokens(combined),
|
|
523
|
+
systemPromptAddition: combined,
|
|
385
524
|
};
|
|
386
525
|
} catch (error) {
|
|
387
526
|
logger.warn(`[palaia] ContextEngine assemble failed: ${error}`);
|
|
388
|
-
return {
|
|
527
|
+
return {
|
|
528
|
+
messages: params.messages || [],
|
|
529
|
+
estimatedTokens: 0,
|
|
530
|
+
};
|
|
389
531
|
}
|
|
390
532
|
},
|
|
391
533
|
|
|
392
534
|
/**
|
|
393
535
|
* Compact: trigger `palaia gc` via runner.
|
|
394
536
|
*/
|
|
395
|
-
async compact()
|
|
537
|
+
async compact(_params) {
|
|
396
538
|
try {
|
|
397
539
|
await run(["gc"], { ...opts, timeoutMs: 30_000 });
|
|
398
540
|
logger.info("[palaia] GC compaction completed");
|
|
541
|
+
return { ok: true, compacted: true };
|
|
399
542
|
} catch (error) {
|
|
400
543
|
logger.warn(`[palaia] GC compaction failed: ${error}`);
|
|
544
|
+
return { ok: false, compacted: false, reason: String(error) };
|
|
401
545
|
}
|
|
402
546
|
},
|
|
403
547
|
|
|
404
548
|
/**
|
|
405
|
-
*
|
|
406
|
-
* Called after each agent turn completes.
|
|
549
|
+
* PrepareSubagentSpawn: load session summary for sub-agent context.
|
|
407
550
|
*/
|
|
408
|
-
async
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
551
|
+
async prepareSubagentSpawn(_params) {
|
|
552
|
+
// Sub-agents share the same palaia store via workspace,
|
|
553
|
+
// so no explicit context passing is needed.
|
|
554
|
+
// Future: use OpenClaw's extraSystemPrompt to pass session summary.
|
|
555
|
+
return undefined;
|
|
413
556
|
},
|
|
414
557
|
|
|
415
558
|
/**
|
|
416
|
-
*
|
|
417
|
-
* Returns context for the sub-agent to inherit.
|
|
559
|
+
* OnSubagentEnded: capture sub-agent result as memory.
|
|
418
560
|
*/
|
|
419
|
-
async
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
561
|
+
async onSubagentEnded(params) {
|
|
562
|
+
if (!config.autoCapture) return;
|
|
563
|
+
// Only capture results for successfully completed sub-agents
|
|
564
|
+
if (params?.reason !== "completed") return;
|
|
565
|
+
try {
|
|
566
|
+
// Try to get sub-agent's last messages for summary
|
|
567
|
+
if (api.runtime?.subagent?.getSessionMessages && params?.childSessionKey) {
|
|
568
|
+
const result = await api.runtime.subagent.getSessionMessages({
|
|
569
|
+
sessionKey: params.childSessionKey,
|
|
570
|
+
limit: 10,
|
|
571
|
+
});
|
|
572
|
+
if (result?.messages?.length) {
|
|
573
|
+
const texts = extractMessageTexts(result.messages);
|
|
574
|
+
const summaryParts = texts.slice(-4).map(
|
|
575
|
+
(t: { role: string; text: string }) => `[${t.role}]: ${t.text.slice(0, 200)}`
|
|
576
|
+
);
|
|
577
|
+
if (summaryParts.length > 0) {
|
|
578
|
+
const summaryText = `Sub-agent result:\n${summaryParts.join("\n")}`.slice(0, 500);
|
|
579
|
+
const subagentScope = await getEffectiveCaptureScope(config);
|
|
580
|
+
await run([
|
|
581
|
+
"write", summaryText,
|
|
582
|
+
"--type", "memory",
|
|
583
|
+
"--tags", "subagent-result,auto-capture",
|
|
584
|
+
"--scope", subagentScope,
|
|
585
|
+
], { ...opts, timeoutMs: 10_000 });
|
|
586
|
+
logger.info(`[palaia] Sub-agent result captured (${summaryText.length} chars)`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
logger.warn(`[palaia] Sub-agent result capture failed: ${error}`);
|
|
592
|
+
}
|
|
439
593
|
},
|
|
440
594
|
};
|
|
595
|
+
|
|
596
|
+
return engine;
|
|
441
597
|
}
|