@byte5ai/palaia 2.0.3 → 2.0.4
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 +146 -16
- 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
|
@@ -153,10 +153,13 @@ export function isValidScope(s: string): boolean {
|
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* Sanitize a scope value — returns the value if valid, otherwise fallback.
|
|
156
|
+
* Enforces: LLM may suggest private or team, but NEVER public (unless explicitly configured).
|
|
156
157
|
*/
|
|
157
|
-
export function sanitizeScope(rawScope: string | null | undefined, fallback = "team"): string {
|
|
158
|
-
if (rawScope
|
|
159
|
-
|
|
158
|
+
export function sanitizeScope(rawScope: string | null | undefined, fallback = "team", allowPublic = false): string {
|
|
159
|
+
if (!rawScope || !isValidScope(rawScope)) return fallback;
|
|
160
|
+
// Block public scope unless explicitly allowed (config-level override)
|
|
161
|
+
if (rawScope === "public" && !allowPublic) return fallback;
|
|
162
|
+
return rawScope;
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
// ============================================================================
|
|
@@ -721,6 +724,7 @@ For each piece of knowledge, return a JSON array of objects:
|
|
|
721
724
|
- "scope": "private" (personal preference, agent-specific), "team" (shared knowledge), or "public" (documentation)
|
|
722
725
|
|
|
723
726
|
Only extract genuinely significant knowledge. Skip small talk, acknowledgments, routine exchanges.
|
|
727
|
+
Do NOT extract if similar knowledge was likely captured in a recent exchange. Prefer quality over quantity. Skip routine status updates and acknowledgments.
|
|
724
728
|
Return empty array [] if nothing is worth remembering.
|
|
725
729
|
Return ONLY valid JSON, no markdown fences.`;
|
|
726
730
|
|
|
@@ -1052,6 +1056,10 @@ export function extractSignificance(
|
|
|
1052
1056
|
|
|
1053
1057
|
if (matched.length === 0) return null;
|
|
1054
1058
|
|
|
1059
|
+
// Require at least 2 different significance tags for rule-based capture
|
|
1060
|
+
const uniqueTags = new Set(matched.map((m) => m.tag));
|
|
1061
|
+
if (uniqueTags.size < 2) return null;
|
|
1062
|
+
|
|
1055
1063
|
const typePriority: Record<string, number> = { task: 3, process: 2, memory: 1 };
|
|
1056
1064
|
const primaryType = matched.reduce(
|
|
1057
1065
|
(best, m) => (typePriority[m.type] > typePriority[best] ? m.type : best),
|
|
@@ -1119,6 +1127,83 @@ export function getLastUserMessage(messages: unknown[]): string | null {
|
|
|
1119
1127
|
return null;
|
|
1120
1128
|
}
|
|
1121
1129
|
|
|
1130
|
+
// ============================================================================
|
|
1131
|
+
// Recall Query Builder (Issue #65 upgrade: robust user message extraction)
|
|
1132
|
+
// ============================================================================
|
|
1133
|
+
|
|
1134
|
+
/** Day-of-week prefixes used as system markers in messages. */
|
|
1135
|
+
const DAY_PREFIXES = ["[Mon ", "[Tue ", "[Wed ", "[Thu ", "[Fri ", "[Sat ", "[Sun "];
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Clean a raw message string by removing system markers, JSON blocks,
|
|
1139
|
+
* and other noise that degrades semantic search quality.
|
|
1140
|
+
*/
|
|
1141
|
+
export function cleanMessageForQuery(text: string): string {
|
|
1142
|
+
let cleaned = text;
|
|
1143
|
+
|
|
1144
|
+
// Remove JSON code blocks (```json ... ```)
|
|
1145
|
+
cleaned = cleaned.replace(/```json[\s\S]*?```/gi, "");
|
|
1146
|
+
|
|
1147
|
+
// Remove lines starting with system markers
|
|
1148
|
+
cleaned = cleaned
|
|
1149
|
+
.split("\n")
|
|
1150
|
+
.filter((line) => {
|
|
1151
|
+
const trimmed = line.trimStart();
|
|
1152
|
+
if (trimmed.startsWith("System:")) return false;
|
|
1153
|
+
if (trimmed.startsWith("[Queued")) return false;
|
|
1154
|
+
if (trimmed.startsWith("[Inter-session")) return false;
|
|
1155
|
+
for (const prefix of DAY_PREFIXES) {
|
|
1156
|
+
if (trimmed.startsWith(prefix)) return false;
|
|
1157
|
+
}
|
|
1158
|
+
return true;
|
|
1159
|
+
})
|
|
1160
|
+
.join("\n")
|
|
1161
|
+
.trim();
|
|
1162
|
+
|
|
1163
|
+
return cleaned;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Build a recall query from message history.
|
|
1168
|
+
*
|
|
1169
|
+
* - Always uses actual user messages (ignores event.prompt which may be stale/synthetic).
|
|
1170
|
+
* - If the last user message is short (< 30 chars), prepends the previous user message
|
|
1171
|
+
* for better semantic context ("Ja", "OK", "Status" alone are poor queries).
|
|
1172
|
+
* - Strips system markers, JSON blocks, and other noise.
|
|
1173
|
+
* - Hard-caps at 500 characters.
|
|
1174
|
+
*
|
|
1175
|
+
* Returns empty string if nothing usable remains.
|
|
1176
|
+
*/
|
|
1177
|
+
export function buildRecallQuery(messages: unknown[]): string {
|
|
1178
|
+
const texts = extractMessageTexts(messages);
|
|
1179
|
+
const userMessages: string[] = [];
|
|
1180
|
+
for (let i = texts.length - 1; i >= 0 && userMessages.length < 2; i--) {
|
|
1181
|
+
if (texts[i].role === "user") {
|
|
1182
|
+
const cleaned = cleanMessageForQuery(texts[i].text);
|
|
1183
|
+
if (cleaned) userMessages.unshift(cleaned);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (userMessages.length === 0) return "";
|
|
1188
|
+
|
|
1189
|
+
const lastMsg = userMessages[userMessages.length - 1];
|
|
1190
|
+
|
|
1191
|
+
// If the last user message is very short, include previous for context
|
|
1192
|
+
let query: string;
|
|
1193
|
+
if (lastMsg.length < 30 && userMessages.length > 1) {
|
|
1194
|
+
query = `${userMessages[userMessages.length - 2]} ${lastMsg}`;
|
|
1195
|
+
} else {
|
|
1196
|
+
query = lastMsg;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Hard cap at 500 characters
|
|
1200
|
+
if (query.length > 500) {
|
|
1201
|
+
query = query.slice(0, 500);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return query.trim();
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1122
1207
|
// ============================================================================
|
|
1123
1208
|
// Query-based Recall: Type-weighted reranking (Issue #65)
|
|
1124
1209
|
// ============================================================================
|
|
@@ -1345,8 +1430,9 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1345
1430
|
let entries: QueryResult["results"] = [];
|
|
1346
1431
|
|
|
1347
1432
|
if (config.recallMode === "query") {
|
|
1348
|
-
const userMessage = event.
|
|
1349
|
-
|
|
1433
|
+
const userMessage = event.messages
|
|
1434
|
+
? buildRecallQuery(event.messages)
|
|
1435
|
+
: (event.prompt || null);
|
|
1350
1436
|
|
|
1351
1437
|
if (userMessage && userMessage.length >= 5) {
|
|
1352
1438
|
try {
|
|
@@ -1364,8 +1450,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1364
1450
|
}
|
|
1365
1451
|
}
|
|
1366
1452
|
|
|
1367
|
-
// Fallback: list mode
|
|
1453
|
+
// Fallback: list mode (no emoji — list-based recall is not query-relevant)
|
|
1454
|
+
let isListFallback = false;
|
|
1368
1455
|
if (entries.length === 0) {
|
|
1456
|
+
isListFallback = true;
|
|
1369
1457
|
try {
|
|
1370
1458
|
const listArgs: string[] = ["list"];
|
|
1371
1459
|
if (config.tier === "all") {
|
|
@@ -1387,17 +1475,35 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1387
1475
|
// Apply type-weighted reranking
|
|
1388
1476
|
const ranked = rerankByTypeWeight(entries, config.recallTypeWeight);
|
|
1389
1477
|
|
|
1390
|
-
// Build context string with char budget
|
|
1478
|
+
// Build context string with char budget (compact format for token efficiency)
|
|
1479
|
+
const SCOPE_SHORT: Record<string, string> = { team: "t", private: "p", public: "pub" };
|
|
1480
|
+
const TYPE_SHORT: Record<string, string> = { memory: "m", process: "pr", task: "tk" };
|
|
1481
|
+
|
|
1391
1482
|
let text = "## Active Memory (Palaia)\n\n";
|
|
1392
1483
|
let chars = text.length;
|
|
1393
1484
|
|
|
1394
1485
|
for (const entry of ranked) {
|
|
1395
|
-
const
|
|
1486
|
+
const scopeKey = SCOPE_SHORT[entry.scope] || entry.scope;
|
|
1487
|
+
const typeKey = TYPE_SHORT[entry.type] || entry.type;
|
|
1488
|
+
const prefix = `[${scopeKey}/${typeKey}]`;
|
|
1489
|
+
|
|
1490
|
+
// If body starts with title (common), skip title to save tokens
|
|
1491
|
+
let line: string;
|
|
1492
|
+
if (entry.body.toLowerCase().startsWith(entry.title.toLowerCase())) {
|
|
1493
|
+
line = `${prefix} ${entry.body}\n\n`;
|
|
1494
|
+
} else {
|
|
1495
|
+
line = `${prefix} ${entry.title}\n${entry.body}\n\n`;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1396
1498
|
if (chars + line.length > maxChars) break;
|
|
1397
1499
|
text += line;
|
|
1398
1500
|
chars += line.length;
|
|
1399
1501
|
}
|
|
1400
1502
|
|
|
1503
|
+
// Persistent usage nudge — compact guidance for the agent
|
|
1504
|
+
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.";
|
|
1505
|
+
text += USAGE_NUDGE + "\n\n";
|
|
1506
|
+
|
|
1401
1507
|
// Update recall counter for satisfaction/transparency nudges (Issue #87)
|
|
1402
1508
|
let nudgeContext = "";
|
|
1403
1509
|
try {
|
|
@@ -1417,9 +1523,9 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1417
1523
|
|
|
1418
1524
|
// Track recall in session-isolated turn state for emoji reactions
|
|
1419
1525
|
// 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 >=
|
|
1526
|
+
// List-fallback never triggers brain emoji (not query-relevant)
|
|
1527
|
+
const hasRelevantRecall = !isListFallback && entries.some(
|
|
1528
|
+
(e) => typeof e.score === "number" && e.score >= config.recallMinScore,
|
|
1423
1529
|
);
|
|
1424
1530
|
const sessionKey = resolveSessionKeyFromCtx(ctx);
|
|
1425
1531
|
if (sessionKey && hasRelevantRecall) {
|
|
@@ -1528,7 +1634,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1528
1634
|
"--tags", tags.join(",") || "auto-capture",
|
|
1529
1635
|
];
|
|
1530
1636
|
|
|
1531
|
-
|
|
1637
|
+
// Scope guardrail: config.captureScope overrides everything; otherwise max team (no public)
|
|
1638
|
+
const scope = config.captureScope
|
|
1639
|
+
? sanitizeScope(config.captureScope, "team", true)
|
|
1640
|
+
: sanitizeScope(itemScope, "team", false);
|
|
1532
1641
|
args.push("--scope", scope);
|
|
1533
1642
|
|
|
1534
1643
|
const project = config.captureProject || itemProject;
|
|
@@ -1553,16 +1662,32 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1553
1662
|
const effectiveProject = hintForProject?.project || r.project;
|
|
1554
1663
|
const effectiveScope = hintForScope?.scope || r.scope;
|
|
1555
1664
|
|
|
1665
|
+
// Project validation: reject unknown projects
|
|
1666
|
+
let validatedProject = effectiveProject;
|
|
1667
|
+
if (validatedProject && knownProjects.length > 0) {
|
|
1668
|
+
const isKnown = knownProjects.some(
|
|
1669
|
+
(p) => p.name.toLowerCase() === validatedProject!.toLowerCase(),
|
|
1670
|
+
);
|
|
1671
|
+
if (!isKnown) {
|
|
1672
|
+
console.log(`[palaia] Auto-capture: unknown project "${validatedProject}" ignored`);
|
|
1673
|
+
validatedProject = null;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// Always include auto-capture tag for GC identification
|
|
1678
|
+
const tags = [...r.tags];
|
|
1679
|
+
if (!tags.includes("auto-capture")) tags.push("auto-capture");
|
|
1680
|
+
|
|
1556
1681
|
const args = buildWriteArgs(
|
|
1557
1682
|
r.content,
|
|
1558
1683
|
r.type,
|
|
1559
|
-
|
|
1560
|
-
|
|
1684
|
+
tags,
|
|
1685
|
+
validatedProject,
|
|
1561
1686
|
effectiveScope,
|
|
1562
1687
|
);
|
|
1563
1688
|
await run(args, { ...opts, timeoutMs: 10_000 });
|
|
1564
1689
|
console.log(
|
|
1565
|
-
`[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${
|
|
1690
|
+
`[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
|
|
1566
1691
|
);
|
|
1567
1692
|
}
|
|
1568
1693
|
}
|
|
@@ -1609,7 +1734,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1609
1734
|
}
|
|
1610
1735
|
}
|
|
1611
1736
|
|
|
1612
|
-
// Rule-based fallback
|
|
1737
|
+
// Rule-based fallback (max 1 per turn)
|
|
1613
1738
|
if (!llmHandled) {
|
|
1614
1739
|
let captureData: { tags: string[]; type: string; summary: string } | null = null;
|
|
1615
1740
|
|
|
@@ -1628,6 +1753,11 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1628
1753
|
captureData = { tags: ["auto-capture"], type: "memory", summary };
|
|
1629
1754
|
}
|
|
1630
1755
|
|
|
1756
|
+
// Always include auto-capture tag for GC identification
|
|
1757
|
+
if (!captureData.tags.includes("auto-capture")) {
|
|
1758
|
+
captureData.tags.push("auto-capture");
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1631
1761
|
const hintForProject = collectedHints.find((h) => h.project);
|
|
1632
1762
|
const hintForScope = collectedHints.find((h) => h.scope);
|
|
1633
1763
|
|
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)) {
|