@byte5ai/palaia 2.0.3 → 2.0.5
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/openclaw.plugin.json +7 -2
- package/package.json +1 -1
- package/src/config.ts +7 -2
- package/src/hooks.ts +182 -44
- package/src/tools.ts +46 -1
package/openclaw.plugin.json
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"maxInjectedChars": {
|
|
35
35
|
"type": "number",
|
|
36
|
-
"default":
|
|
36
|
+
"default": 4000,
|
|
37
37
|
"description": "Max characters for injected memory context"
|
|
38
38
|
},
|
|
39
39
|
"autoCapture": {
|
|
@@ -86,6 +86,11 @@
|
|
|
86
86
|
"default": "query",
|
|
87
87
|
"description": "Recall mode: list (context-independent) or query (context-relevant)"
|
|
88
88
|
},
|
|
89
|
+
"recallMinScore": {
|
|
90
|
+
"type": "number",
|
|
91
|
+
"default": 0.7,
|
|
92
|
+
"description": "Minimum score for a recall result to be considered relevant (default: 0.7)"
|
|
93
|
+
},
|
|
89
94
|
"recallTypeWeight": {
|
|
90
95
|
"type": "object",
|
|
91
96
|
"default": { "process": 1.5, "task": 1.2, "memory": 1.0 },
|
|
@@ -113,7 +118,7 @@
|
|
|
113
118
|
},
|
|
114
119
|
"maxInjectedChars": {
|
|
115
120
|
"label": "Max Injected Characters",
|
|
116
|
-
"description": "Maximum characters of memory context injected per prompt (recommended:
|
|
121
|
+
"description": "Maximum characters of memory context injected per prompt (recommended: 4000)"
|
|
117
122
|
},
|
|
118
123
|
"autoCapture": {
|
|
119
124
|
"label": "Auto-Capture",
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -54,6 +54,10 @@ export interface PalaiaPluginConfig {
|
|
|
54
54
|
recallMode: "list" | "query";
|
|
55
55
|
/** Type-aware weighting for recall results */
|
|
56
56
|
recallTypeWeight: RecallTypeWeights;
|
|
57
|
+
|
|
58
|
+
// ── Recall Quality (Issue #65) ───────────────────────────────
|
|
59
|
+
/** Minimum score for a recall result to be considered relevant (default: 0.7) */
|
|
60
|
+
recallMinScore: number;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
export const DEFAULT_RECALL_TYPE_WEIGHTS: RecallTypeWeights = {
|
|
@@ -67,15 +71,16 @@ export const DEFAULT_CONFIG: PalaiaPluginConfig = {
|
|
|
67
71
|
maxResults: 10,
|
|
68
72
|
timeoutMs: 3000,
|
|
69
73
|
memoryInject: true,
|
|
70
|
-
maxInjectedChars:
|
|
74
|
+
maxInjectedChars: 4000,
|
|
71
75
|
autoCapture: true,
|
|
72
76
|
captureFrequency: "significant",
|
|
73
77
|
captureMinTurns: 2,
|
|
74
|
-
captureMinSignificance: 0.
|
|
78
|
+
captureMinSignificance: 0.5,
|
|
75
79
|
showMemorySources: true,
|
|
76
80
|
showCaptureConfirm: true,
|
|
77
81
|
recallMode: "query",
|
|
78
82
|
recallTypeWeight: { ...DEFAULT_RECALL_TYPE_WEIGHTS },
|
|
83
|
+
recallMinScore: 0.7,
|
|
79
84
|
};
|
|
80
85
|
|
|
81
86
|
/**
|
package/src/hooks.ts
CHANGED
|
@@ -137,6 +137,16 @@ const lastInboundMessageByChannel = new Map<string, { messageId: string; provide
|
|
|
137
137
|
/** Channels that support emoji reactions. */
|
|
138
138
|
const REACTION_SUPPORTED_PROVIDERS = new Set(["slack", "discord"]);
|
|
139
139
|
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// Logger (Issue: api.logger integration)
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
/** Module-level logger — defaults to console, replaced by api.logger in registerHooks. */
|
|
145
|
+
let logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void } = {
|
|
146
|
+
info: (...args: any[]) => console.log(...args),
|
|
147
|
+
warn: (...args: any[]) => console.warn(...args),
|
|
148
|
+
};
|
|
149
|
+
|
|
140
150
|
// ============================================================================
|
|
141
151
|
// Scope Validation (Issue #90)
|
|
142
152
|
// ============================================================================
|
|
@@ -153,10 +163,13 @@ export function isValidScope(s: string): boolean {
|
|
|
153
163
|
|
|
154
164
|
/**
|
|
155
165
|
* Sanitize a scope value — returns the value if valid, otherwise fallback.
|
|
166
|
+
* Enforces: LLM may suggest private or team, but NEVER public (unless explicitly configured).
|
|
156
167
|
*/
|
|
157
|
-
export function sanitizeScope(rawScope: string | null | undefined, fallback = "team"): string {
|
|
158
|
-
if (rawScope
|
|
159
|
-
|
|
168
|
+
export function sanitizeScope(rawScope: string | null | undefined, fallback = "team", allowPublic = false): string {
|
|
169
|
+
if (!rawScope || !isValidScope(rawScope)) return fallback;
|
|
170
|
+
// Block public scope unless explicitly allowed (config-level override)
|
|
171
|
+
if (rawScope === "public" && !allowPublic) return fallback;
|
|
172
|
+
return rawScope;
|
|
160
173
|
}
|
|
161
174
|
|
|
162
175
|
// ============================================================================
|
|
@@ -347,7 +360,7 @@ async function sendSlackReaction(
|
|
|
347
360
|
): Promise<void> {
|
|
348
361
|
const token = await resolveSlackBotToken();
|
|
349
362
|
if (!token) {
|
|
350
|
-
|
|
363
|
+
logger.warn("[palaia] Cannot send Slack reaction: no bot token found");
|
|
351
364
|
return;
|
|
352
365
|
}
|
|
353
366
|
|
|
@@ -372,11 +385,11 @@ async function sendSlackReaction(
|
|
|
372
385
|
});
|
|
373
386
|
const data = await response.json() as { ok: boolean; error?: string };
|
|
374
387
|
if (!data.ok && data.error !== "already_reacted") {
|
|
375
|
-
|
|
388
|
+
logger.warn(`[palaia] Slack reaction failed: ${data.error} (${normalizedEmoji} on ${channelId})`);
|
|
376
389
|
}
|
|
377
390
|
} catch (err) {
|
|
378
391
|
if ((err as Error).name !== "AbortError") {
|
|
379
|
-
|
|
392
|
+
logger.warn(`[palaia] Slack reaction error (${normalizedEmoji}): ${err}`);
|
|
380
393
|
}
|
|
381
394
|
} finally {
|
|
382
395
|
clearTimeout(timeout);
|
|
@@ -721,6 +734,7 @@ For each piece of knowledge, return a JSON array of objects:
|
|
|
721
734
|
- "scope": "private" (personal preference, agent-specific), "team" (shared knowledge), or "public" (documentation)
|
|
722
735
|
|
|
723
736
|
Only extract genuinely significant knowledge. Skip small talk, acknowledgments, routine exchanges.
|
|
737
|
+
Do NOT extract if similar knowledge was likely captured in a recent exchange. Prefer quality over quantity. Skip routine status updates and acknowledgments.
|
|
724
738
|
Return empty array [] if nothing is worth remembering.
|
|
725
739
|
Return ONLY valid JSON, no markdown fences.`;
|
|
726
740
|
|
|
@@ -788,7 +802,7 @@ export function resolveCaptureModel(
|
|
|
788
802
|
if (parts.length >= 2) {
|
|
789
803
|
if (!_captureModelFallbackWarned) {
|
|
790
804
|
_captureModelFallbackWarned = true;
|
|
791
|
-
|
|
805
|
+
logger.warn(`[palaia] No captureModel configured — using primary model. Set captureModel in plugin config for cost savings.`);
|
|
792
806
|
}
|
|
793
807
|
return { provider: parts[0], model: parts.slice(1).join("/") };
|
|
794
808
|
}
|
|
@@ -821,21 +835,27 @@ function collectText(payloads: Array<{ text?: string; isError?: boolean }> | und
|
|
|
821
835
|
* then hard-cap at maxChars from the end (newest messages kept).
|
|
822
836
|
*/
|
|
823
837
|
export function trimToRecentExchanges(
|
|
824
|
-
texts: Array<{ role: string; text: string }>,
|
|
838
|
+
texts: Array<{ role: string; text: string; provenance?: string }>,
|
|
825
839
|
maxPairs = 5,
|
|
826
840
|
maxChars = 10_000,
|
|
827
|
-
): Array<{ role: string; text: string }> {
|
|
841
|
+
): Array<{ role: string; text: string; provenance?: string }> {
|
|
828
842
|
// Filter to only user + assistant messages (skip tool, toolResult, system, etc.)
|
|
829
843
|
const exchanges = texts.filter((t) => t.role === "user" || t.role === "assistant");
|
|
830
844
|
|
|
831
845
|
// Keep the last N pairs (a pair = one user + one assistant message)
|
|
846
|
+
// Only count external_user messages as real user turns.
|
|
847
|
+
// System-injected user messages (inter_session, internal_system) don't count as conversation turns.
|
|
832
848
|
// Walk backwards, count pairs
|
|
833
849
|
let pairCount = 0;
|
|
834
850
|
let lastRole = "";
|
|
835
851
|
let cutIndex = 0; // default: keep everything
|
|
836
852
|
for (let i = exchanges.length - 1; i >= 0; i--) {
|
|
837
|
-
|
|
838
|
-
|
|
853
|
+
const isRealUser = exchanges[i].role === "user" && (
|
|
854
|
+
exchanges[i].provenance === "external_user" ||
|
|
855
|
+
!exchanges[i].provenance // backward compat: no provenance = treat as real user
|
|
856
|
+
);
|
|
857
|
+
// Count a new pair when we see a real user message after having seen an assistant
|
|
858
|
+
if (isRealUser && lastRole === "assistant") {
|
|
839
859
|
pairCount++;
|
|
840
860
|
if (pairCount > maxPairs) {
|
|
841
861
|
cutIndex = i + 1; // keep from next message onwards
|
|
@@ -1052,6 +1072,10 @@ export function extractSignificance(
|
|
|
1052
1072
|
|
|
1053
1073
|
if (matched.length === 0) return null;
|
|
1054
1074
|
|
|
1075
|
+
// Require at least 2 different significance tags for rule-based capture
|
|
1076
|
+
const uniqueTags = new Set(matched.map((m) => m.tag));
|
|
1077
|
+
if (uniqueTags.size < 2) return null;
|
|
1078
|
+
|
|
1055
1079
|
const typePriority: Record<string, number> = { task: 3, process: 2, memory: 1 };
|
|
1056
1080
|
const primaryType = matched.reduce(
|
|
1057
1081
|
(best, m) => (typePriority[m.type] > typePriority[best] ? m.type : best),
|
|
@@ -1079,8 +1103,8 @@ export function extractSignificance(
|
|
|
1079
1103
|
return { tags, type: primaryType, summary };
|
|
1080
1104
|
}
|
|
1081
1105
|
|
|
1082
|
-
export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string }> {
|
|
1083
|
-
const result: Array<{ role: string; text: string }> = [];
|
|
1106
|
+
export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string; provenance?: string }> {
|
|
1107
|
+
const result: Array<{ role: string; text: string; provenance?: string }> = [];
|
|
1084
1108
|
|
|
1085
1109
|
for (const msg of messages) {
|
|
1086
1110
|
if (!msg || typeof msg !== "object") continue;
|
|
@@ -1088,8 +1112,12 @@ export function extractMessageTexts(messages: unknown[]): Array<{ role: string;
|
|
|
1088
1112
|
const role = m.role;
|
|
1089
1113
|
if (!role || typeof role !== "string") continue;
|
|
1090
1114
|
|
|
1115
|
+
// Extract provenance kind (string or object with .kind)
|
|
1116
|
+
const rawProvenance = (m as any).provenance?.kind ?? (m as any).provenance;
|
|
1117
|
+
const provenance = typeof rawProvenance === "string" ? rawProvenance : undefined;
|
|
1118
|
+
|
|
1091
1119
|
if (typeof m.content === "string" && m.content.trim()) {
|
|
1092
|
-
result.push({ role, text: m.content.trim() });
|
|
1120
|
+
result.push({ role, text: m.content.trim(), provenance });
|
|
1093
1121
|
continue;
|
|
1094
1122
|
}
|
|
1095
1123
|
|
|
@@ -1102,7 +1130,7 @@ export function extractMessageTexts(messages: unknown[]): Array<{ role: string;
|
|
|
1102
1130
|
typeof block.text === "string" &&
|
|
1103
1131
|
block.text.trim()
|
|
1104
1132
|
) {
|
|
1105
|
-
result.push({ role, text: block.text.trim() });
|
|
1133
|
+
result.push({ role, text: block.text.trim(), provenance });
|
|
1106
1134
|
}
|
|
1107
1135
|
}
|
|
1108
1136
|
}
|
|
@@ -1113,12 +1141,59 @@ export function extractMessageTexts(messages: unknown[]): Array<{ role: string;
|
|
|
1113
1141
|
|
|
1114
1142
|
export function getLastUserMessage(messages: unknown[]): string | null {
|
|
1115
1143
|
const texts = extractMessageTexts(messages);
|
|
1144
|
+
// Prefer external_user provenance (real human input)
|
|
1145
|
+
for (let i = texts.length - 1; i >= 0; i--) {
|
|
1146
|
+
if (texts[i].role === "user" && texts[i].provenance === "external_user")
|
|
1147
|
+
return texts[i].text;
|
|
1148
|
+
}
|
|
1149
|
+
// Fallback: any user message (backward compat for OpenClaw without provenance)
|
|
1116
1150
|
for (let i = texts.length - 1; i >= 0; i--) {
|
|
1117
1151
|
if (texts[i].role === "user") return texts[i].text;
|
|
1118
1152
|
}
|
|
1119
1153
|
return null;
|
|
1120
1154
|
}
|
|
1121
1155
|
|
|
1156
|
+
// ============================================================================
|
|
1157
|
+
// Recall Query Builder (provenance-based, Issue #65 upgrade)
|
|
1158
|
+
// ============================================================================
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Build a recall query from message history using provenance to identify real user input.
|
|
1162
|
+
*
|
|
1163
|
+
* - Prefers external_user messages (real human input from Slack/Telegram).
|
|
1164
|
+
* - Falls back to any user message for backward compat (OpenClaw without provenance).
|
|
1165
|
+
* - If the last user message is short (< 30 chars), prepends the previous for context.
|
|
1166
|
+
* - Hard-caps at 500 characters.
|
|
1167
|
+
*
|
|
1168
|
+
* Provenance makes the old heuristic cleaners (DAY_PREFIXES, system marker stripping) obsolete.
|
|
1169
|
+
*/
|
|
1170
|
+
export function buildRecallQuery(messages: unknown[]): string {
|
|
1171
|
+
const texts = extractMessageTexts(messages);
|
|
1172
|
+
|
|
1173
|
+
// Prefer external_user messages (real human input)
|
|
1174
|
+
const externalUserMsgs = texts.filter(
|
|
1175
|
+
t => t.role === "user" && t.provenance === "external_user"
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
// Fallback: any user message (backward compat for OpenClaw without provenance)
|
|
1179
|
+
const userMsgs = externalUserMsgs.length > 0
|
|
1180
|
+
? externalUserMsgs
|
|
1181
|
+
: texts.filter(t => t.role === "user");
|
|
1182
|
+
|
|
1183
|
+
if (userMsgs.length === 0) return "";
|
|
1184
|
+
|
|
1185
|
+
const lastMsg = userMsgs[userMsgs.length - 1].text.trim();
|
|
1186
|
+
|
|
1187
|
+
// Short messages: include previous for context
|
|
1188
|
+
if (lastMsg.length < 30 && userMsgs.length > 1) {
|
|
1189
|
+
const prevMsg = userMsgs[userMsgs.length - 2].text.trim();
|
|
1190
|
+
const combined = `${prevMsg} ${lastMsg}`.slice(0, 500);
|
|
1191
|
+
return combined;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return lastMsg.slice(0, 500);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1122
1197
|
// ============================================================================
|
|
1123
1198
|
// Query-based Recall: Type-weighted reranking (Issue #65)
|
|
1124
1199
|
// ============================================================================
|
|
@@ -1219,22 +1294,27 @@ export function resetTurnState(): void {
|
|
|
1219
1294
|
* Register lifecycle hooks on the plugin API.
|
|
1220
1295
|
*/
|
|
1221
1296
|
export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
1297
|
+
// Store api.logger for module-wide use (integrates into OpenClaw log system)
|
|
1298
|
+
if (api.logger && typeof api.logger.info === "function") {
|
|
1299
|
+
logger = api.logger;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1222
1302
|
const opts = buildRunnerOpts(config);
|
|
1223
1303
|
|
|
1224
|
-
// ── Startup checks (H-2, H-3)
|
|
1304
|
+
// ── Startup checks (H-2, H-3, captureModel validation) ────────
|
|
1225
1305
|
(async () => {
|
|
1226
1306
|
// H-2: Warn if no agent is configured
|
|
1227
1307
|
if (!process.env.PALAIA_AGENT) {
|
|
1228
1308
|
try {
|
|
1229
1309
|
const statusOut = await run(["config", "get", "agent"], { ...opts, timeoutMs: 3000 });
|
|
1230
1310
|
if (!statusOut.trim()) {
|
|
1231
|
-
|
|
1311
|
+
logger.warn(
|
|
1232
1312
|
"[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
|
|
1233
1313
|
"Auto-captured entries will have no agent attribution."
|
|
1234
1314
|
);
|
|
1235
1315
|
}
|
|
1236
1316
|
} catch {
|
|
1237
|
-
|
|
1317
|
+
logger.warn(
|
|
1238
1318
|
"[palaia] No agent configured. Set PALAIA_AGENT env var or run 'palaia init --agent <name>'. " +
|
|
1239
1319
|
"Auto-captured entries will have no agent attribution."
|
|
1240
1320
|
);
|
|
@@ -1261,7 +1341,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1261
1341
|
|| status.config?.embedding_provider
|
|
1262
1342
|
);
|
|
1263
1343
|
if (!hasSemanticProvider && !hasProviderConfig) {
|
|
1264
|
-
|
|
1344
|
+
logger.warn(
|
|
1265
1345
|
"[palaia] No embedding provider configured. Semantic search is inactive (BM25 keyword-only). " +
|
|
1266
1346
|
"Run 'pip install palaia[fastembed]' and 'palaia doctor --fix' for better recall quality."
|
|
1267
1347
|
);
|
|
@@ -1271,6 +1351,19 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1271
1351
|
} catch {
|
|
1272
1352
|
// Non-fatal — status check failed, skip warning (avoid false positive)
|
|
1273
1353
|
}
|
|
1354
|
+
|
|
1355
|
+
// Validate captureModel auth at plugin startup via modelAuth API
|
|
1356
|
+
if (config.captureModel && api.runtime?.modelAuth) {
|
|
1357
|
+
try {
|
|
1358
|
+
const resolved = resolveCaptureModel(api.config, config.captureModel);
|
|
1359
|
+
if (resolved?.provider) {
|
|
1360
|
+
const key = await api.runtime.modelAuth.resolveApiKeyForProvider({ provider: resolved.provider, cfg: api.config });
|
|
1361
|
+
if (!key) {
|
|
1362
|
+
logger.warn(`[palaia] captureModel provider "${resolved.provider}" has no API key — auto-capture LLM extraction will fail`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
} catch { /* non-fatal */ }
|
|
1366
|
+
}
|
|
1274
1367
|
})();
|
|
1275
1368
|
|
|
1276
1369
|
// ── /palaia status command ─────────────────────────────────────
|
|
@@ -1345,8 +1438,9 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1345
1438
|
let entries: QueryResult["results"] = [];
|
|
1346
1439
|
|
|
1347
1440
|
if (config.recallMode === "query") {
|
|
1348
|
-
const userMessage = event.
|
|
1349
|
-
|
|
1441
|
+
const userMessage = event.messages
|
|
1442
|
+
? buildRecallQuery(event.messages)
|
|
1443
|
+
: (event.prompt || null);
|
|
1350
1444
|
|
|
1351
1445
|
if (userMessage && userMessage.length >= 5) {
|
|
1352
1446
|
try {
|
|
@@ -1359,13 +1453,15 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1359
1453
|
entries = result.results;
|
|
1360
1454
|
}
|
|
1361
1455
|
} catch (queryError) {
|
|
1362
|
-
|
|
1456
|
+
logger.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
|
|
1363
1457
|
}
|
|
1364
1458
|
}
|
|
1365
1459
|
}
|
|
1366
1460
|
|
|
1367
|
-
// Fallback: list mode
|
|
1461
|
+
// Fallback: list mode (no emoji — list-based recall is not query-relevant)
|
|
1462
|
+
let isListFallback = false;
|
|
1368
1463
|
if (entries.length === 0) {
|
|
1464
|
+
isListFallback = true;
|
|
1369
1465
|
try {
|
|
1370
1466
|
const listArgs: string[] = ["list"];
|
|
1371
1467
|
if (config.tier === "all") {
|
|
@@ -1387,17 +1483,35 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1387
1483
|
// Apply type-weighted reranking
|
|
1388
1484
|
const ranked = rerankByTypeWeight(entries, config.recallTypeWeight);
|
|
1389
1485
|
|
|
1390
|
-
// Build context string with char budget
|
|
1486
|
+
// Build context string with char budget (compact format for token efficiency)
|
|
1487
|
+
const SCOPE_SHORT: Record<string, string> = { team: "t", private: "p", public: "pub" };
|
|
1488
|
+
const TYPE_SHORT: Record<string, string> = { memory: "m", process: "pr", task: "tk" };
|
|
1489
|
+
|
|
1391
1490
|
let text = "## Active Memory (Palaia)\n\n";
|
|
1392
1491
|
let chars = text.length;
|
|
1393
1492
|
|
|
1394
1493
|
for (const entry of ranked) {
|
|
1395
|
-
const
|
|
1494
|
+
const scopeKey = SCOPE_SHORT[entry.scope] || entry.scope;
|
|
1495
|
+
const typeKey = TYPE_SHORT[entry.type] || entry.type;
|
|
1496
|
+
const prefix = `[${scopeKey}/${typeKey}]`;
|
|
1497
|
+
|
|
1498
|
+
// If body starts with title (common), skip title to save tokens
|
|
1499
|
+
let line: string;
|
|
1500
|
+
if (entry.body.toLowerCase().startsWith(entry.title.toLowerCase())) {
|
|
1501
|
+
line = `${prefix} ${entry.body}\n\n`;
|
|
1502
|
+
} else {
|
|
1503
|
+
line = `${prefix} ${entry.title}\n${entry.body}\n\n`;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1396
1506
|
if (chars + line.length > maxChars) break;
|
|
1397
1507
|
text += line;
|
|
1398
1508
|
chars += line.length;
|
|
1399
1509
|
}
|
|
1400
1510
|
|
|
1511
|
+
// Persistent usage nudge — compact guidance for the agent
|
|
1512
|
+
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.";
|
|
1513
|
+
text += USAGE_NUDGE + "\n\n";
|
|
1514
|
+
|
|
1401
1515
|
// Update recall counter for satisfaction/transparency nudges (Issue #87)
|
|
1402
1516
|
let nudgeContext = "";
|
|
1403
1517
|
try {
|
|
@@ -1417,9 +1531,9 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1417
1531
|
|
|
1418
1532
|
// Track recall in session-isolated turn state for emoji reactions
|
|
1419
1533
|
// Only flag recall as meaningful if at least one result scores above threshold
|
|
1420
|
-
|
|
1421
|
-
const hasRelevantRecall = entries.some(
|
|
1422
|
-
(e) => typeof e.score === "number" && e.score >=
|
|
1534
|
+
// List-fallback never triggers brain emoji (not query-relevant)
|
|
1535
|
+
const hasRelevantRecall = !isListFallback && entries.some(
|
|
1536
|
+
(e) => typeof e.score === "number" && e.score >= config.recallMinScore,
|
|
1423
1537
|
);
|
|
1424
1538
|
const sessionKey = resolveSessionKeyFromCtx(ctx);
|
|
1425
1539
|
if (sessionKey && hasRelevantRecall) {
|
|
@@ -1450,7 +1564,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1450
1564
|
: undefined,
|
|
1451
1565
|
};
|
|
1452
1566
|
} catch (error) {
|
|
1453
|
-
|
|
1567
|
+
logger.warn(`[palaia] Memory injection failed: ${error}`);
|
|
1454
1568
|
}
|
|
1455
1569
|
});
|
|
1456
1570
|
}
|
|
@@ -1528,7 +1642,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1528
1642
|
"--tags", tags.join(",") || "auto-capture",
|
|
1529
1643
|
];
|
|
1530
1644
|
|
|
1531
|
-
|
|
1645
|
+
// Scope guardrail: config.captureScope overrides everything; otherwise max team (no public)
|
|
1646
|
+
const scope = config.captureScope
|
|
1647
|
+
? sanitizeScope(config.captureScope, "team", true)
|
|
1648
|
+
: sanitizeScope(itemScope, "team", false);
|
|
1532
1649
|
args.push("--scope", scope);
|
|
1533
1650
|
|
|
1534
1651
|
const project = config.captureProject || itemProject;
|
|
@@ -1553,16 +1670,32 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1553
1670
|
const effectiveProject = hintForProject?.project || r.project;
|
|
1554
1671
|
const effectiveScope = hintForScope?.scope || r.scope;
|
|
1555
1672
|
|
|
1673
|
+
// Project validation: reject unknown projects
|
|
1674
|
+
let validatedProject = effectiveProject;
|
|
1675
|
+
if (validatedProject && knownProjects.length > 0) {
|
|
1676
|
+
const isKnown = knownProjects.some(
|
|
1677
|
+
(p) => p.name.toLowerCase() === validatedProject!.toLowerCase(),
|
|
1678
|
+
);
|
|
1679
|
+
if (!isKnown) {
|
|
1680
|
+
logger.info(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
|
|
1681
|
+
validatedProject = null;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Always include auto-capture tag for GC identification
|
|
1686
|
+
const tags = [...r.tags];
|
|
1687
|
+
if (!tags.includes("auto-capture")) tags.push("auto-capture");
|
|
1688
|
+
|
|
1556
1689
|
const args = buildWriteArgs(
|
|
1557
1690
|
r.content,
|
|
1558
1691
|
r.type,
|
|
1559
|
-
|
|
1560
|
-
|
|
1692
|
+
tags,
|
|
1693
|
+
validatedProject,
|
|
1561
1694
|
effectiveScope,
|
|
1562
1695
|
);
|
|
1563
1696
|
await run(args, { ...opts, timeoutMs: 10_000 });
|
|
1564
|
-
|
|
1565
|
-
`[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${
|
|
1697
|
+
logger.info(
|
|
1698
|
+
`[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
|
|
1566
1699
|
);
|
|
1567
1700
|
}
|
|
1568
1701
|
}
|
|
@@ -1586,7 +1719,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1586
1719
|
// captureModel is broken — try primary model as fallback
|
|
1587
1720
|
if (!_captureModelFailoverWarned) {
|
|
1588
1721
|
_captureModelFailoverWarned = true;
|
|
1589
|
-
|
|
1722
|
+
logger.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
|
|
1590
1723
|
}
|
|
1591
1724
|
try {
|
|
1592
1725
|
// Retry without captureModel → resolveCaptureModel will use primary model
|
|
@@ -1597,19 +1730,19 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1597
1730
|
llmHandled = true;
|
|
1598
1731
|
} catch (fallbackError) {
|
|
1599
1732
|
if (!_llmImportFailureLogged) {
|
|
1600
|
-
|
|
1733
|
+
logger.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
|
|
1601
1734
|
_llmImportFailureLogged = true;
|
|
1602
1735
|
}
|
|
1603
1736
|
}
|
|
1604
1737
|
} else {
|
|
1605
1738
|
if (!_llmImportFailureLogged) {
|
|
1606
|
-
|
|
1739
|
+
logger.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
|
|
1607
1740
|
_llmImportFailureLogged = true;
|
|
1608
1741
|
}
|
|
1609
1742
|
}
|
|
1610
1743
|
}
|
|
1611
1744
|
|
|
1612
|
-
// Rule-based fallback
|
|
1745
|
+
// Rule-based fallback (max 1 per turn)
|
|
1613
1746
|
if (!llmHandled) {
|
|
1614
1747
|
let captureData: { tags: string[]; type: string; summary: string } | null = null;
|
|
1615
1748
|
|
|
@@ -1628,6 +1761,11 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1628
1761
|
captureData = { tags: ["auto-capture"], type: "memory", summary };
|
|
1629
1762
|
}
|
|
1630
1763
|
|
|
1764
|
+
// Always include auto-capture tag for GC identification
|
|
1765
|
+
if (!captureData.tags.includes("auto-capture")) {
|
|
1766
|
+
captureData.tags.push("auto-capture");
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1631
1769
|
const hintForProject = collectedHints.find((h) => h.project);
|
|
1632
1770
|
const hintForScope = collectedHints.find((h) => h.scope);
|
|
1633
1771
|
|
|
@@ -1640,7 +1778,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1640
1778
|
);
|
|
1641
1779
|
|
|
1642
1780
|
await run(args, { ...opts, timeoutMs: 10_000 });
|
|
1643
|
-
|
|
1781
|
+
logger.info(
|
|
1644
1782
|
`[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
|
|
1645
1783
|
);
|
|
1646
1784
|
}
|
|
@@ -1652,7 +1790,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1652
1790
|
} else {
|
|
1653
1791
|
}
|
|
1654
1792
|
} catch (error) {
|
|
1655
|
-
|
|
1793
|
+
logger.warn(`[palaia] Auto-capture failed: ${error}`);
|
|
1656
1794
|
}
|
|
1657
1795
|
|
|
1658
1796
|
// ── Emoji Reactions (Issue #87) ──────────────────────────
|
|
@@ -1684,7 +1822,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1684
1822
|
}
|
|
1685
1823
|
}
|
|
1686
1824
|
} catch (reactionError) {
|
|
1687
|
-
|
|
1825
|
+
logger.warn(`[palaia] Reaction sending failed: ${reactionError}`);
|
|
1688
1826
|
} finally {
|
|
1689
1827
|
// Always clean up turn state
|
|
1690
1828
|
deleteTurnState(sessionKey);
|
|
@@ -1714,7 +1852,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1714
1852
|
}
|
|
1715
1853
|
}
|
|
1716
1854
|
} catch (err) {
|
|
1717
|
-
|
|
1855
|
+
logger.warn(`[palaia] Recall reaction failed: ${err}`);
|
|
1718
1856
|
} finally {
|
|
1719
1857
|
deleteTurnState(sessionKey);
|
|
1720
1858
|
}
|
|
@@ -1727,10 +1865,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1727
1865
|
start: async () => {
|
|
1728
1866
|
const result = await recover(opts);
|
|
1729
1867
|
if (result.replayed > 0) {
|
|
1730
|
-
|
|
1868
|
+
logger.info(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
|
|
1731
1869
|
}
|
|
1732
1870
|
if (result.errors > 0) {
|
|
1733
|
-
|
|
1871
|
+
logger.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
|
|
1734
1872
|
}
|
|
1735
1873
|
},
|
|
1736
1874
|
});
|
package/src/tools.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Type } from "@sinclair/typebox";
|
|
10
|
-
import { runJson, type RunnerOpts } from "./runner.js";
|
|
10
|
+
import { run, runJson, type RunnerOpts } from "./runner.js";
|
|
11
11
|
import type { PalaiaPluginConfig } from "./config.js";
|
|
12
12
|
import { sanitizeScope, isValidScope } from "./hooks.js";
|
|
13
13
|
|
|
@@ -217,6 +217,12 @@ export function registerTools(api: any, config: PalaiaPluginConfig): void {
|
|
|
217
217
|
description: "Title for the entry",
|
|
218
218
|
})
|
|
219
219
|
),
|
|
220
|
+
force: Type.Optional(
|
|
221
|
+
Type.Boolean({
|
|
222
|
+
description: "Skip duplicate check and write anyway",
|
|
223
|
+
default: false,
|
|
224
|
+
})
|
|
225
|
+
),
|
|
220
226
|
}),
|
|
221
227
|
async execute(
|
|
222
228
|
_id: string,
|
|
@@ -227,8 +233,47 @@ export function registerTools(api: any, config: PalaiaPluginConfig): void {
|
|
|
227
233
|
type?: string;
|
|
228
234
|
project?: string;
|
|
229
235
|
title?: string;
|
|
236
|
+
force?: boolean;
|
|
230
237
|
}
|
|
231
238
|
) {
|
|
239
|
+
// Duplicate guard: check for similar recent entries before writing
|
|
240
|
+
if (!params.force) {
|
|
241
|
+
try {
|
|
242
|
+
const dupCheckResult = await runJson<QueryResult>(
|
|
243
|
+
["query", params.content, "--limit", "5"],
|
|
244
|
+
{ ...opts, timeoutMs: 2000 },
|
|
245
|
+
);
|
|
246
|
+
if (dupCheckResult && Array.isArray(dupCheckResult.results)) {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
249
|
+
for (const r of dupCheckResult.results) {
|
|
250
|
+
if (r.score > 0.8) {
|
|
251
|
+
// Check if created in last 24h — use any available date field
|
|
252
|
+
const meta = r as any;
|
|
253
|
+
const createdStr = meta.created_at || meta.createdAt || meta.date || "";
|
|
254
|
+
if (createdStr) {
|
|
255
|
+
const createdTime = new Date(createdStr).getTime();
|
|
256
|
+
if (!isNaN(createdTime) && (now - createdTime) < oneDayMs) {
|
|
257
|
+
const title = r.title || (r.content || r.body || "").slice(0, 60);
|
|
258
|
+
const dateStr = new Date(createdTime).toISOString().split("T")[0];
|
|
259
|
+
return {
|
|
260
|
+
content: [
|
|
261
|
+
{
|
|
262
|
+
type: "text" as const,
|
|
263
|
+
text: `Similar entry already exists (score: ${r.score.toFixed(2)}, created: ${dateStr}): '${title}'. Use palaia edit ${r.id} to update, or confirm with --force to write anyway.`,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Duplicate check timed out or failed — proceed with write
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
232
277
|
const args: string[] = ["write", params.content];
|
|
233
278
|
if (params.scope) {
|
|
234
279
|
if (!isValidScope(params.scope)) {
|