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