@byte5ai/palaia 2.0.13 → 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 +104 -25
  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.13",
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.
@@ -914,7 +932,25 @@ export async function extractWithLLM(
914
932
 
915
933
  let tmpDir: string | null = null;
916
934
  try {
917
- 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-"));
918
954
  const sessionId = `palaia-extract-${Date.now()}`;
919
955
  const sessionFile = path.join(tmpDir, "session.json");
920
956
 
@@ -1268,11 +1304,29 @@ export function buildRecallQuery(messages: unknown[]): string {
1268
1304
  );
1269
1305
 
1270
1306
  // Fallback: if no messages without provenance, use all user messages
1271
- const userMsgs = candidates.length > 0
1307
+ const allUserMsgs = candidates.length > 0
1272
1308
  ? candidates
1273
1309
  : texts.filter(t => t.role === "user");
1274
1310
 
1275
- 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
+ }
1276
1330
 
1277
1331
  // Step 2: Strip envelopes from the last user message(s)
1278
1332
  let lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[userMsgs.length - 1].text.trim()));
@@ -1341,10 +1395,22 @@ export function rerankByTypeWeight(
1341
1395
  // Hook helpers
1342
1396
  // ============================================================================
1343
1397
 
1344
- 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 {
1345
1411
  return {
1346
1412
  binaryPath: config.binaryPath,
1347
- workspace: config.workspace,
1413
+ workspace: overrides?.workspace || config.workspace,
1348
1414
  timeoutMs: config.timeoutMs,
1349
1415
  };
1350
1416
  }
@@ -1538,6 +1604,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1538
1604
  // Prune stale entries to prevent memory leaks from crashed sessions (C-2)
1539
1605
  pruneStaleEntries();
1540
1606
 
1607
+ // Per-agent workspace resolution (Issue #111)
1608
+ const resolved = resolvePerAgentContext(ctx, config);
1609
+ const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
1610
+
1541
1611
  try {
1542
1612
  const maxChars = config.maxInjectedChars || 4000;
1543
1613
  const limit = Math.min(config.maxResults || 10, 20);
@@ -1553,15 +1623,22 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1553
1623
  let serverQueried = false;
1554
1624
  if (config.embeddingServer) {
1555
1625
  try {
1556
- const mgr = getEmbedServerManager(opts);
1557
- const resp = await mgr.query({
1558
- text: userMessage,
1559
- top_k: limit,
1560
- include_cold: config.tier === "all",
1561
- }, config.timeoutMs || 3000);
1562
- if (resp?.result?.results && Array.isArray(resp.result.results)) {
1563
- entries = resp.result.results;
1564
- 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
+ }
1565
1642
  }
1566
1643
  } catch (serverError) {
1567
1644
  logger.warn(`[palaia] Embed server query failed, falling back to CLI: ${serverError}`);
@@ -1575,7 +1652,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1575
1652
  if (config.tier === "all") {
1576
1653
  queryArgs.push("--all");
1577
1654
  }
1578
- const result = await runJson<QueryResult>(queryArgs, { ...opts, timeoutMs: 15000 });
1655
+ const result = await runJson<QueryResult>(queryArgs, { ...hookOpts, timeoutMs: 15000 });
1579
1656
  if (result && Array.isArray(result.results)) {
1580
1657
  entries = result.results;
1581
1658
  }
@@ -1597,7 +1674,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1597
1674
  } else {
1598
1675
  listArgs.push("--tier", config.tier || "hot");
1599
1676
  }
1600
- const result = await runJson<QueryResult>(listArgs, opts);
1677
+ const result = await runJson<QueryResult>(listArgs, hookOpts);
1601
1678
  if (result && Array.isArray(result.results)) {
1602
1679
  entries = result.results;
1603
1680
  }
@@ -1643,7 +1720,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1643
1720
  // Update recall counter for satisfaction/transparency nudges (Issue #87)
1644
1721
  let nudgeContext = "";
1645
1722
  try {
1646
- const pluginState = await loadPluginState(config.workspace);
1723
+ const pluginState = await loadPluginState(resolved.workspace);
1647
1724
  pluginState.successfulRecalls++;
1648
1725
  if (!pluginState.firstRecallTimestamp) {
1649
1726
  pluginState.firstRecallTimestamp = new Date().toISOString();
@@ -1652,7 +1729,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1652
1729
  if (nudges.length > 0) {
1653
1730
  nudgeContext = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
1654
1731
  }
1655
- await savePluginState(pluginState, config.workspace);
1732
+ await savePluginState(pluginState, resolved.workspace);
1656
1733
  } catch {
1657
1734
  // Non-fatal
1658
1735
  }
@@ -1714,14 +1791,16 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1714
1791
  // Resolve session key for turn state
1715
1792
  const sessionKey = resolveSessionKeyFromCtx(ctx);
1716
1793
 
1717
- // 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 });
1718
1797
 
1719
1798
  if (!event.success || !event.messages || event.messages.length === 0) {
1720
1799
  return;
1721
1800
  }
1722
1801
 
1723
1802
  try {
1724
- const agentName = process.env.PALAIA_AGENT || undefined;
1803
+ const agentName = resolved.agentId;
1725
1804
 
1726
1805
  const allTexts = extractMessageTexts(event.messages);
1727
1806
 
@@ -1762,7 +1841,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1762
1841
  return;
1763
1842
  }
1764
1843
 
1765
- const knownProjects = await loadProjects(opts);
1844
+ const knownProjects = await loadProjects(hookOpts);
1766
1845
 
1767
1846
  // Helper: build CLI args with metadata
1768
1847
  const buildWriteArgs = (
@@ -1830,7 +1909,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1830
1909
  validatedProject,
1831
1910
  effectiveScope,
1832
1911
  );
1833
- await run(args, { ...opts, timeoutMs: 10_000 });
1912
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
1834
1913
  logger.info(
1835
1914
  `[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
1836
1915
  );
@@ -1914,7 +1993,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
1914
1993
  hintForScope?.scope,
1915
1994
  );
1916
1995
 
1917
- await run(args, { ...opts, timeoutMs: 10_000 });
1996
+ await run(args, { ...hookOpts, timeoutMs: 10_000 });
1918
1997
  logger.info(
1919
1998
  `[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
1920
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
  }