@byte5ai/palaia 2.0.2 → 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.
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "maxInjectedChars": {
35
35
  "type": "number",
36
- "default": 8000,
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: 8000)"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byte5ai/palaia",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Palaia memory backend for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
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: 8000,
74
+ maxInjectedChars: 4000,
71
75
  autoCapture: true,
72
76
  captureFrequency: "significant",
73
77
  captureMinTurns: 2,
74
- captureMinSignificance: 0.3,
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 && isValidScope(rawScope)) return rawScope;
159
- return fallback;
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.prompt
1349
- || (event.messages ? getLastUserMessage(event.messages) : null);
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 line = `**${entry.title}** [${entry.scope}/${entry.type}]\n${entry.body}\n\n`;
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 {
@@ -1416,8 +1522,13 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1416
1522
  }
1417
1523
 
1418
1524
  // Track recall in session-isolated turn state for emoji reactions
1525
+ // Only flag recall as meaningful if at least one result scores above threshold
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,
1529
+ );
1419
1530
  const sessionKey = resolveSessionKeyFromCtx(ctx);
1420
- if (sessionKey) {
1531
+ if (sessionKey && hasRelevantRecall) {
1421
1532
  const turnState = getOrCreateTurnState(sessionKey);
1422
1533
  turnState.recallOccurred = true;
1423
1534
 
@@ -1523,7 +1634,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1523
1634
  "--tags", tags.join(",") || "auto-capture",
1524
1635
  ];
1525
1636
 
1526
- const scope = sanitizeScope(config.captureScope || itemScope, config.captureScope || "team");
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);
1527
1641
  args.push("--scope", scope);
1528
1642
 
1529
1643
  const project = config.captureProject || itemProject;
@@ -1548,16 +1662,32 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1548
1662
  const effectiveProject = hintForProject?.project || r.project;
1549
1663
  const effectiveScope = hintForScope?.scope || r.scope;
1550
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
+
1551
1681
  const args = buildWriteArgs(
1552
1682
  r.content,
1553
1683
  r.type,
1554
- r.tags,
1555
- effectiveProject,
1684
+ tags,
1685
+ validatedProject,
1556
1686
  effectiveScope,
1557
1687
  );
1558
1688
  await run(args, { ...opts, timeoutMs: 10_000 });
1559
1689
  console.log(
1560
- `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${r.tags.join(",")}, project=${effectiveProject || "none"}, scope=${effectiveScope || "team"}`
1690
+ `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
1561
1691
  );
1562
1692
  }
1563
1693
  }
@@ -1604,7 +1734,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1604
1734
  }
1605
1735
  }
1606
1736
 
1607
- // Rule-based fallback
1737
+ // Rule-based fallback (max 1 per turn)
1608
1738
  if (!llmHandled) {
1609
1739
  let captureData: { tags: string[]; type: string; summary: string } | null = null;
1610
1740
 
@@ -1623,6 +1753,11 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1623
1753
  captureData = { tags: ["auto-capture"], type: "memory", summary };
1624
1754
  }
1625
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
+
1626
1761
  const hintForProject = collectedHints.find((h) => h.project);
1627
1762
  const hintForScope = collectedHints.find((h) => h.scope);
1628
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)) {