@byte5ai/palaia 2.0.12 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/hooks.ts +144 -28
  3. package/src/runner.ts +51 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byte5ai/palaia",
3
- "version": "2.0.12",
3
+ "version": "2.1.0",
4
4
  "description": "Palaia memory backend for OpenClaw",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
package/src/hooks.ts CHANGED
@@ -293,10 +293,14 @@ export async function sendReaction(
293
293
 
294
294
  /** Cached Slack bot token resolved from env or OpenClaw config. */
295
295
  let _cachedSlackToken: string | null | undefined;
296
+ /** Timestamp when the token was cached (for TTL expiry). */
297
+ let _slackTokenCachedAt = 0;
298
+ /** TTL for cached Slack bot token in milliseconds (5 minutes). */
299
+ const SLACK_TOKEN_CACHE_TTL_MS = 5 * 60 * 1000;
296
300
 
297
301
  /**
298
302
  * Resolve the Slack bot token from environment or OpenClaw config file.
299
- * Caches the result for the lifetime of the process.
303
+ * Caches the result with a 5-minute TTL re-resolves after expiry.
300
304
  *
301
305
  * Resolution order:
302
306
  * 1. SLACK_BOT_TOKEN env var (explicit override)
@@ -306,12 +310,15 @@ let _cachedSlackToken: string | null | undefined;
306
310
  * Config path: OPENCLAW_CONFIG env var → ~/.openclaw/openclaw.json
307
311
  */
308
312
  async function resolveSlackBotToken(): Promise<string | null> {
309
- if (_cachedSlackToken !== undefined) return _cachedSlackToken;
313
+ if (_cachedSlackToken !== undefined && (Date.now() - _slackTokenCachedAt) < SLACK_TOKEN_CACHE_TTL_MS) {
314
+ return _cachedSlackToken;
315
+ }
310
316
 
311
317
  // 1) Environment variable
312
318
  const envToken = process.env.SLACK_BOT_TOKEN?.trim();
313
319
  if (envToken) {
314
320
  _cachedSlackToken = envToken;
321
+ _slackTokenCachedAt = Date.now();
315
322
  return envToken;
316
323
  }
317
324
 
@@ -330,6 +337,7 @@ async function resolveSlackBotToken(): Promise<string | null> {
330
337
  const directToken = config?.channels?.slack?.botToken?.trim();
331
338
  if (directToken) {
332
339
  _cachedSlackToken = directToken;
340
+ _slackTokenCachedAt = Date.now();
333
341
  return directToken;
334
342
  }
335
343
 
@@ -337,6 +345,7 @@ async function resolveSlackBotToken(): Promise<string | null> {
337
345
  const accountToken = config?.channels?.slack?.accounts?.default?.botToken?.trim();
338
346
  if (accountToken) {
339
347
  _cachedSlackToken = accountToken;
348
+ _slackTokenCachedAt = Date.now();
340
349
  return accountToken;
341
350
  }
342
351
  } catch {
@@ -345,12 +354,14 @@ async function resolveSlackBotToken(): Promise<string | null> {
345
354
  }
346
355
 
347
356
  _cachedSlackToken = null;
357
+ _slackTokenCachedAt = Date.now();
348
358
  return null;
349
359
  }
350
360
 
351
361
  /** Reset cached token (for testing). */
352
362
  export function resetSlackTokenCache(): void {
353
363
  _cachedSlackToken = undefined;
364
+ _slackTokenCachedAt = 0;
354
365
  }
355
366
 
356
367
  async function sendSlackReaction(
@@ -733,6 +744,13 @@ For each piece of knowledge, return a JSON array of objects:
733
744
  - "project": which project this belongs to (from known projects list, or null if unclear)
734
745
  - "scope": "private" (personal preference, agent-specific), "team" (shared knowledge), or "public" (documentation)
735
746
 
747
+ STRICT TASK CLASSIFICATION RULES — a "task" MUST have ALL three of:
748
+ 1. A clear, completable action (not just an observation or idea)
749
+ 2. An identifiable responsible party (explicitly named or unambiguously inferable from context)
750
+ 3. A concrete deliverable or measurable end state
751
+ If ANY of these is missing, classify as "memory" instead of "task". When in doubt, use "memory".
752
+ Observations, learnings, insights, opinions, and general knowledge are ALWAYS "memory", never "task".
753
+
736
754
  Only extract genuinely significant knowledge. Skip small talk, acknowledgments, routine exchanges.
737
755
  Do NOT extract if similar knowledge was likely captured in a recent exchange. Prefer quality over quantity. Skip routine status updates and acknowledgments.
738
756
  Return empty array [] if nothing is worth remembering.
@@ -892,9 +910,15 @@ export async function extractWithLLM(
892
910
  }
893
911
 
894
912
  const allTexts = extractMessageTexts(messages);
913
+ // Strip Palaia-injected recall context from user messages to prevent feedback loop
914
+ const cleanedTexts = allTexts.map(t =>
915
+ t.role === "user"
916
+ ? { ...t, text: stripPalaiaInjectedContext(t.text) }
917
+ : t
918
+ );
895
919
  // Only extract from recent exchanges — full history causes LLM timeouts
896
920
  // and dilutes extraction quality
897
- const recentTexts = trimToRecentExchanges(allTexts);
921
+ const recentTexts = trimToRecentExchanges(cleanedTexts);
898
922
  const exchangeText = recentTexts
899
923
  .map((t) => `[${t.role}]: ${t.text}`)
900
924
  .join("\n");
@@ -908,7 +932,25 @@ export async function extractWithLLM(
908
932
 
909
933
  let tmpDir: string | null = null;
910
934
  try {
911
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "palaia-extract-"));
935
+ // Use a fixed base directory for extraction temp dirs and clean up stale ones
936
+ const extractBaseDir = path.join(os.tmpdir(), "palaia-extractions");
937
+ await fs.mkdir(extractBaseDir, { recursive: true });
938
+ // Clean up stale extraction dirs (older than 5 minutes)
939
+ try {
940
+ const entries = await fs.readdir(extractBaseDir, { withFileTypes: true });
941
+ const now = Date.now();
942
+ for (const entry of entries) {
943
+ if (entry.isDirectory()) {
944
+ try {
945
+ const stat = await fs.stat(path.join(extractBaseDir, entry.name));
946
+ if (now - stat.mtimeMs > 5 * 60 * 1000) {
947
+ await fs.rm(path.join(extractBaseDir, entry.name), { recursive: true, force: true });
948
+ }
949
+ } catch { /* ignore individual cleanup errors */ }
950
+ }
951
+ }
952
+ } catch { /* ignore cleanup errors */ }
953
+ tmpDir = await fs.mkdtemp(path.join(extractBaseDir, "ext-"));
912
954
  const sessionId = `palaia-extract-${Date.now()}`;
913
955
  const sessionFile = path.join(tmpDir, "session.json");
914
956
 
@@ -1103,6 +1145,24 @@ export function extractSignificance(
1103
1145
  return { tags, type: primaryType, summary };
1104
1146
  }
1105
1147
 
1148
+ /**
1149
+ * Strip Palaia-injected recall context from message text.
1150
+ * The recall block is prepended to user messages by before_prompt_build via prependContext.
1151
+ * OpenClaw merges it into the user message, so agent_end sees it as user content.
1152
+ * Without stripping, auto-capture re-captures the injected memories → feedback loop.
1153
+ *
1154
+ * The block has a stable structure:
1155
+ * - Starts with "## Active Memory (Palaia)"
1156
+ * - Contains [t/m], [t/pr], [t/tk] prefixed entries
1157
+ * - Ends with "[palaia] auto-capture=on..." nudge line
1158
+ */
1159
+ export function stripPalaiaInjectedContext(text: string): string {
1160
+ // Pattern: "## Active Memory (Palaia)" ... "[palaia] auto-capture=on..." + optional trailing newlines
1161
+ // The nudge line is always present and marks the end of the injected block
1162
+ const PALAIA_BLOCK_RE = /## Active Memory \(Palaia\)[\s\S]*?\[palaia\][^\n]*\n*/;
1163
+ return text.replace(PALAIA_BLOCK_RE, '').trim();
1164
+ }
1165
+
1106
1166
  export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string; provenance?: string }> {
1107
1167
  const result: Array<{ role: string; text: string; provenance?: string }> = [];
1108
1168
 
@@ -1232,7 +1292,11 @@ function isSystemOnlyContent(text: string): boolean {
1232
1292
  * - Hard-caps at 500 characters.
1233
1293
  */
1234
1294
  export function buildRecallQuery(messages: unknown[]): string {
1235
- const texts = extractMessageTexts(messages);
1295
+ const texts = extractMessageTexts(messages).map(t =>
1296
+ t.role === "user"
1297
+ ? { ...t, text: stripPalaiaInjectedContext(t.text) }
1298
+ : t
1299
+ );
1236
1300
 
1237
1301
  // Step 1: Filter out inter_session messages (sub-agent results, sessions_send)
1238
1302
  const candidates = texts.filter(
@@ -1240,11 +1304,29 @@ export function buildRecallQuery(messages: unknown[]): string {
1240
1304
  );
1241
1305
 
1242
1306
  // Fallback: if no messages without provenance, use all user messages
1243
- const userMsgs = candidates.length > 0
1307
+ const allUserMsgs = candidates.length > 0
1244
1308
  ? candidates
1245
1309
  : texts.filter(t => t.role === "user");
1246
1310
 
1247
- if (userMsgs.length === 0) return "";
1311
+ if (allUserMsgs.length === 0) return "";
1312
+
1313
+ // Early exit: only scan the last 3 user messages or 2000 chars, whichever comes first
1314
+ const MAX_SCAN_MSGS = 3;
1315
+ const MAX_SCAN_CHARS = 2000;
1316
+ let userMsgs: typeof allUserMsgs;
1317
+ if (allUserMsgs.length <= MAX_SCAN_MSGS) {
1318
+ userMsgs = allUserMsgs;
1319
+ } else {
1320
+ userMsgs = allUserMsgs.slice(-MAX_SCAN_MSGS);
1321
+ // Extend backwards if total chars < MAX_SCAN_CHARS and more messages available
1322
+ let totalChars = userMsgs.reduce((sum, m) => sum + m.text.length, 0);
1323
+ let startIdx = allUserMsgs.length - MAX_SCAN_MSGS;
1324
+ while (startIdx > 0 && totalChars < MAX_SCAN_CHARS) {
1325
+ startIdx--;
1326
+ totalChars += allUserMsgs[startIdx].text.length;
1327
+ userMsgs = allUserMsgs.slice(startIdx);
1328
+ }
1329
+ }
1248
1330
 
1249
1331
  // Step 2: Strip envelopes from the last user message(s)
1250
1332
  let lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[userMsgs.length - 1].text.trim()));
@@ -1313,10 +1395,22 @@ export function rerankByTypeWeight(
1313
1395
  // Hook helpers
1314
1396
  // ============================================================================
1315
1397
 
1316
- function buildRunnerOpts(config: PalaiaPluginConfig): RunnerOpts {
1398
+ /**
1399
+ * Resolve per-agent workspace and agentId from hook context.
1400
+ * Fallback chain: ctx.workspaceDir → config.workspace → cwd
1401
+ * Agent chain: ctx.agentId → PALAIA_AGENT env var → undefined
1402
+ */
1403
+ export function resolvePerAgentContext(ctx: any, config: PalaiaPluginConfig) {
1404
+ return {
1405
+ workspace: ctx?.workspaceDir || config.workspace,
1406
+ agentId: ctx?.agentId || process.env.PALAIA_AGENT || undefined,
1407
+ };
1408
+ }
1409
+
1410
+ function buildRunnerOpts(config: PalaiaPluginConfig, overrides?: { workspace?: string }): RunnerOpts {
1317
1411
  return {
1318
1412
  binaryPath: config.binaryPath,
1319
- workspace: config.workspace,
1413
+ workspace: overrides?.workspace || config.workspace,
1320
1414
  timeoutMs: config.timeoutMs,
1321
1415
  };
1322
1416
  }
@@ -1510,6 +1604,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1510
1604
  // Prune stale entries to prevent memory leaks from crashed sessions (C-2)
1511
1605
  pruneStaleEntries();
1512
1606
 
1607
+ // Per-agent workspace resolution (Issue #111)
1608
+ const resolved = resolvePerAgentContext(ctx, config);
1609
+ const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
1610
+
1513
1611
  try {
1514
1612
  const maxChars = config.maxInjectedChars || 4000;
1515
1613
  const limit = Math.min(config.maxResults || 10, 20);
@@ -1525,15 +1623,22 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1525
1623
  let serverQueried = false;
1526
1624
  if (config.embeddingServer) {
1527
1625
  try {
1528
- const mgr = getEmbedServerManager(opts);
1529
- const resp = await mgr.query({
1530
- text: userMessage,
1531
- top_k: limit,
1532
- include_cold: config.tier === "all",
1533
- }, config.timeoutMs || 3000);
1534
- if (resp?.result?.results && Array.isArray(resp.result.results)) {
1535
- entries = resp.result.results;
1536
- serverQueried = true;
1626
+ const mgr = getEmbedServerManager(hookOpts);
1627
+ // If embed server workspace differs from resolved workspace, skip server and use CLI
1628
+ const serverWorkspace = hookOpts.workspace;
1629
+ const embedOpts = buildRunnerOpts(config);
1630
+ if (serverWorkspace !== embedOpts.workspace) {
1631
+ logger.info(`[palaia] Embed server workspace mismatch (agent=${resolved.workspace}), falling back to CLI`);
1632
+ } else {
1633
+ const resp = await mgr.query({
1634
+ text: userMessage,
1635
+ top_k: limit,
1636
+ include_cold: config.tier === "all",
1637
+ }, config.timeoutMs || 3000);
1638
+ if (resp?.result?.results && Array.isArray(resp.result.results)) {
1639
+ entries = resp.result.results;
1640
+ serverQueried = true;
1641
+ }
1537
1642
  }
1538
1643
  } catch (serverError) {
1539
1644
  logger.warn(`[palaia] Embed server query failed, falling back to CLI: ${serverError}`);
@@ -1547,7 +1652,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1547
1652
  if (config.tier === "all") {
1548
1653
  queryArgs.push("--all");
1549
1654
  }
1550
- const result = await runJson<QueryResult>(queryArgs, { ...opts, timeoutMs: 15000 });
1655
+ const result = await runJson<QueryResult>(queryArgs, { ...hookOpts, timeoutMs: 15000 });
1551
1656
  if (result && Array.isArray(result.results)) {
1552
1657
  entries = result.results;
1553
1658
  }
@@ -1569,7 +1674,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1569
1674
  } else {
1570
1675
  listArgs.push("--tier", config.tier || "hot");
1571
1676
  }
1572
- const result = await runJson<QueryResult>(listArgs, opts);
1677
+ const result = await runJson<QueryResult>(listArgs, hookOpts);
1573
1678
  if (result && Array.isArray(result.results)) {
1574
1679
  entries = result.results;
1575
1680
  }
@@ -1615,7 +1720,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1615
1720
  // Update recall counter for satisfaction/transparency nudges (Issue #87)
1616
1721
  let nudgeContext = "";
1617
1722
  try {
1618
- const pluginState = await loadPluginState(config.workspace);
1723
+ const pluginState = await loadPluginState(resolved.workspace);
1619
1724
  pluginState.successfulRecalls++;
1620
1725
  if (!pluginState.firstRecallTimestamp) {
1621
1726
  pluginState.firstRecallTimestamp = new Date().toISOString();
@@ -1624,7 +1729,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1624
1729
  if (nudges.length > 0) {
1625
1730
  nudgeContext = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
1626
1731
  }
1627
- await savePluginState(pluginState, config.workspace);
1732
+ await savePluginState(pluginState, resolved.workspace);
1628
1733
  } catch {
1629
1734
  // Non-fatal
1630
1735
  }
@@ -1686,14 +1791,16 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1686
1791
  // Resolve session key for turn state
1687
1792
  const sessionKey = resolveSessionKeyFromCtx(ctx);
1688
1793
 
1689
- // DEBUG: always log agent_end firing
1794
+ // Per-agent workspace resolution (Issue #111)
1795
+ const resolved = resolvePerAgentContext(ctx, config);
1796
+ const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
1690
1797
 
1691
1798
  if (!event.success || !event.messages || event.messages.length === 0) {
1692
1799
  return;
1693
1800
  }
1694
1801
 
1695
1802
  try {
1696
- const agentName = process.env.PALAIA_AGENT || undefined;
1803
+ const agentName = resolved.agentId;
1697
1804
 
1698
1805
  const allTexts = extractMessageTexts(event.messages);
1699
1806
 
@@ -1709,9 +1816,18 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1709
1816
  collectedHints.push(...hints);
1710
1817
  }
1711
1818
 
1819
+ // Strip Palaia-injected recall context from user messages to prevent feedback loop.
1820
+ // The recall block is prepended to user messages by before_prompt_build.
1821
+ // Without stripping, auto-capture would re-capture previously recalled memories.
1822
+ const cleanedTexts = allTexts.map(t =>
1823
+ t.role === "user"
1824
+ ? { ...t, text: stripPalaiaInjectedContext(t.text) }
1825
+ : t
1826
+ );
1827
+
1712
1828
  // Only extract from recent exchanges — full history causes LLM timeouts
1713
1829
  // and dilutes extraction quality
1714
- const recentTexts = trimToRecentExchanges(allTexts);
1830
+ const recentTexts = trimToRecentExchanges(cleanedTexts);
1715
1831
 
1716
1832
  // Build exchange text from recent window only
1717
1833
  const exchangeParts: string[] = [];
@@ -1725,7 +1841,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1725
1841
  return;
1726
1842
  }
1727
1843
 
1728
- const knownProjects = await loadProjects(opts);
1844
+ const knownProjects = await loadProjects(hookOpts);
1729
1845
 
1730
1846
  // Helper: build CLI args with metadata
1731
1847
  const buildWriteArgs = (
@@ -1793,7 +1909,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1793
1909
  validatedProject,
1794
1910
  effectiveScope,
1795
1911
  );
1796
- await run(args, { ...opts, timeoutMs: 10_000 });
1912
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
1797
1913
  logger.info(
1798
1914
  `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
1799
1915
  );
@@ -1877,7 +1993,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1877
1993
  hintForScope?.scope,
1878
1994
  );
1879
1995
 
1880
- await run(args, { ...opts, timeoutMs: 10_000 });
1996
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
1881
1997
  logger.info(
1882
1998
  `[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
1883
1999
  );
package/src/runner.ts CHANGED
@@ -361,6 +361,11 @@ export class EmbedServerManager {
361
361
  clearTimeout(this.pendingRequest.timer);
362
362
  this.pendingRequest = null;
363
363
  }
364
+ // Reject all queued requests
365
+ for (const queued of this.requestQueue) {
366
+ queued.reject(new Error(`Embed server exited with code ${code}`));
367
+ }
368
+ this.requestQueue = [];
364
369
  });
365
370
 
366
371
  // Register cleanup
@@ -456,6 +461,16 @@ export class EmbedServerManager {
456
461
  await this.start();
457
462
  }
458
463
 
464
+ /** Maximum number of queued requests before rejecting */
465
+ private maxQueueSize = 10;
466
+ /** Queue of pending requests waiting to be sent */
467
+ private requestQueue: Array<{
468
+ request: Record<string, unknown>;
469
+ timeoutMs: number;
470
+ resolve: (value: any) => void;
471
+ reject: (reason: any) => void;
472
+ }> = [];
473
+
459
474
  private sendRequest(request: Record<string, unknown>, timeoutMs: number): Promise<any> {
460
475
  return new Promise((resolve, reject) => {
461
476
  if (!this.proc?.stdin?.writable) {
@@ -463,22 +478,46 @@ export class EmbedServerManager {
463
478
  return;
464
479
  }
465
480
 
466
- // Only one request at a time (sequential protocol)
481
+ // If a request is already in flight, queue this one
467
482
  if (this.pendingRequest) {
468
- reject(new Error("Embed server busy"));
483
+ if (this.requestQueue.length >= this.maxQueueSize) {
484
+ reject(new Error("Embed server queue full"));
485
+ return;
486
+ }
487
+ this.requestQueue.push({ request, timeoutMs, resolve, reject });
469
488
  return;
470
489
  }
471
490
 
472
- const timer = setTimeout(() => {
473
- this.pendingRequest = null;
474
- reject(new Error(`Embed server request timed out after ${timeoutMs}ms`));
475
- }, timeoutMs);
491
+ this._sendImmediate(request, timeoutMs, resolve, reject);
492
+ });
493
+ }
494
+
495
+ private _sendImmediate(
496
+ request: Record<string, unknown>,
497
+ timeoutMs: number,
498
+ resolve: (value: any) => void,
499
+ reject: (reason: any) => void,
500
+ ): void {
501
+ const timer = setTimeout(() => {
502
+ this.pendingRequest = null;
503
+ reject(new Error(`Embed server request timed out after ${timeoutMs}ms`));
504
+ this._drainQueue();
505
+ }, timeoutMs);
476
506
 
477
- this.pendingRequest = { resolve, reject, timer };
507
+ this.pendingRequest = { resolve, reject, timer };
478
508
 
479
- const line = JSON.stringify(request) + "\n";
480
- this.proc.stdin!.write(line);
481
- });
509
+ const line = JSON.stringify(request) + "\n";
510
+ this.proc!.stdin!.write(line);
511
+ }
512
+
513
+ private _drainQueue(): void {
514
+ if (this.pendingRequest || this.requestQueue.length === 0) return;
515
+ const next = this.requestQueue.shift()!;
516
+ if (!this.proc?.stdin?.writable) {
517
+ next.reject(new Error("Embed server not running"));
518
+ return;
519
+ }
520
+ this._sendImmediate(next.request, next.timeoutMs, next.resolve, next.reject);
482
521
  }
483
522
 
484
523
  private handleLine(line: string): void {
@@ -493,6 +532,8 @@ export class EmbedServerManager {
493
532
  } else {
494
533
  pending.resolve(msg);
495
534
  }
535
+ // Process next queued request
536
+ this._drainQueue();
496
537
  } catch {
497
538
  // Ignore non-JSON lines
498
539
  }