@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.
@@ -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.3",
3
+ "version": "2.0.5",
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
@@ -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 && isValidScope(rawScope)) return rawScope;
159
- return fallback;
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
- console.warn("[palaia] Cannot send Slack reaction: no bot token found");
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
- console.warn(`[palaia] Slack reaction failed: ${data.error} (${normalizedEmoji} on ${channelId})`);
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
- console.warn(`[palaia] Slack reaction error (${normalizedEmoji}): ${err}`);
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
- console.warn(`[palaia] No captureModel configured — using primary model. Set captureModel in plugin config for cost savings.`);
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
- // Count a new pair when we see a user message after having seen an assistant
838
- if (exchanges[i].role === "user" && lastRole === "assistant") {
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
- console.warn(
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
- console.warn(
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
- console.warn(
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.prompt
1349
- || (event.messages ? getLastUserMessage(event.messages) : null);
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
- console.warn(`[palaia] Query recall failed, falling back to list: ${queryError}`);
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 line = `**${entry.title}** [${entry.scope}/${entry.type}]\n${entry.body}\n\n`;
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
- const RECALL_RELEVANCE_THRESHOLD = 0.7;
1421
- const hasRelevantRecall = entries.some(
1422
- (e) => typeof e.score === "number" && e.score >= RECALL_RELEVANCE_THRESHOLD,
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
- console.warn(`[palaia] Memory injection failed: ${error}`);
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
- const scope = sanitizeScope(config.captureScope || itemScope, config.captureScope || "team");
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
- r.tags,
1560
- effectiveProject,
1692
+ tags,
1693
+ validatedProject,
1561
1694
  effectiveScope,
1562
1695
  );
1563
1696
  await run(args, { ...opts, timeoutMs: 10_000 });
1564
- console.log(
1565
- `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${r.tags.join(",")}, project=${effectiveProject || "none"}, scope=${effectiveScope || "team"}`
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
- console.warn(`[palaia] WARNING: captureModel failed (${errStr}). Using primary model as fallback. Please update captureModel in your config.`);
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
- console.warn(`[palaia] LLM extraction failed (primary model fallback also failed): ${fallbackError}`);
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
- console.warn(`[palaia] LLM extraction failed, using rule-based fallback: ${llmError}`);
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
- console.log(
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
- console.warn(`[palaia] Auto-capture failed: ${error}`);
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
- console.warn(`[palaia] Reaction sending failed: ${reactionError}`);
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
- console.warn(`[palaia] Recall reaction failed: ${err}`);
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
- console.log(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
1868
+ logger.info(`[palaia] WAL recovery: replayed ${result.replayed} entries`);
1731
1869
  }
1732
1870
  if (result.errors > 0) {
1733
- console.warn(`[palaia] WAL recovery completed with ${result.errors} error(s)`);
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)) {