@pentatonic-ai/ai-agent-sdk 0.4.8 → 0.5.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 (39) hide show
  1. package/README.md +59 -0
  2. package/bin/cli.js +70 -9
  3. package/dist/index.cjs +25 -3
  4. package/dist/index.js +25 -3
  5. package/package.json +4 -2
  6. package/packages/doctor/README.md +106 -0
  7. package/packages/doctor/__tests__/checks.test.js +187 -0
  8. package/packages/doctor/__tests__/detect.test.js +101 -0
  9. package/packages/doctor/__tests__/output.test.js +92 -0
  10. package/packages/doctor/__tests__/plugins.test.js +111 -0
  11. package/packages/doctor/__tests__/runner.test.js +131 -0
  12. package/packages/doctor/package.json +6 -0
  13. package/packages/doctor/src/checks/hosted-tes.js +109 -0
  14. package/packages/doctor/src/checks/local-memory.js +290 -0
  15. package/packages/doctor/src/checks/platform.js +170 -0
  16. package/packages/doctor/src/checks/universal.js +121 -0
  17. package/packages/doctor/src/detect.js +102 -0
  18. package/packages/doctor/src/index.js +33 -0
  19. package/packages/doctor/src/output.js +55 -0
  20. package/packages/doctor/src/plugins.js +81 -0
  21. package/packages/doctor/src/runner.js +136 -0
  22. package/packages/memory/migrations/005-atomic-memories.sql +16 -0
  23. package/packages/memory/migrations/006-fix-vector-dim.sql +97 -0
  24. package/packages/memory/openclaw-plugin/__tests__/chat-turn.test.js +208 -0
  25. package/packages/memory/openclaw-plugin/__tests__/indicator.test.js +142 -0
  26. package/packages/memory/openclaw-plugin/__tests__/version-check.test.js +136 -0
  27. package/packages/memory/openclaw-plugin/index.js +369 -58
  28. package/packages/memory/openclaw-plugin/openclaw.plugin.json +11 -1
  29. package/packages/memory/openclaw-plugin/package.json +1 -1
  30. package/packages/memory/src/__tests__/distill.test.js +175 -0
  31. package/packages/memory/src/__tests__/openclaw-chat-turn.test.js +289 -0
  32. package/packages/memory/src/distill.js +162 -0
  33. package/packages/memory/src/index.js +1 -0
  34. package/packages/memory/src/ingest.js +10 -0
  35. package/packages/memory/src/openclaw/index.js +280 -23
  36. package/packages/memory/src/openclaw/package.json +1 -1
  37. package/packages/memory/src/server.js +59 -5
  38. package/src/normalizer.js +16 -0
  39. package/src/session.js +21 -2
@@ -21,6 +21,49 @@
21
21
 
22
22
  const TES_ENDPOINT = "https://api.pentatonic.com";
23
23
 
24
+ // Minimum local memory server version the plugin expects. Bump this when
25
+ // the plugin starts relying on new endpoints, schema, or query params.
26
+ // A server older than this still works for common operations — the
27
+ // mismatch just surfaces as a stderr warning so users know to update.
28
+ const MIN_SERVER_VERSION = "0.5.0";
29
+
30
+ // Track whether we've already warned for a given server version so we
31
+ // don't spam stderr every health check.
32
+ const warnedServerVersions = new Set();
33
+
34
+ function parseVersion(v) {
35
+ if (typeof v !== "string") return null;
36
+ const parts = v.split(".").slice(0, 3).map((n) => parseInt(n, 10));
37
+ if (parts.some(Number.isNaN)) return null;
38
+ while (parts.length < 3) parts.push(0);
39
+ return parts;
40
+ }
41
+
42
+ // Returns true when a >= b. Missing or unparseable versions are treated
43
+ // as "newer than anything" to avoid false warnings when a server
44
+ // pre-dates the /health version field.
45
+ function versionGte(a, b) {
46
+ const pa = parseVersion(a);
47
+ const pb = parseVersion(b);
48
+ if (!pa) return true;
49
+ if (!pb) return true;
50
+ for (let i = 0; i < 3; i++) {
51
+ if (pa[i] > pb[i]) return true;
52
+ if (pa[i] < pb[i]) return false;
53
+ }
54
+ return true;
55
+ }
56
+
57
+ function warnIfServerTooOld(serverVersion) {
58
+ if (warnedServerVersions.has(serverVersion)) return;
59
+ if (versionGte(serverVersion, MIN_SERVER_VERSION)) return;
60
+ warnedServerVersions.add(serverVersion);
61
+ console.error(
62
+ `[pentatonic-memory] WARNING: memory server is ${serverVersion}, plugin needs >= ${MIN_SERVER_VERSION}. ` +
63
+ `Some features may not work until you update — run: npx @pentatonic-ai/ai-agent-sdk@latest memory`
64
+ );
65
+ }
66
+
24
67
  const SUCCESS_GIFS = [
25
68
  "https://media.giphy.com/media/l0MYt5jPR6QX5APm0/giphy.gif", // brain expanding
26
69
  "https://media.giphy.com/media/3o7btNa0RUYa5E7iiQ/giphy.gif", // elephant never forgets
@@ -55,12 +98,13 @@ async function localSearch(baseUrl, query, limit = 5, minScore = 0.3) {
55
98
  body: JSON.stringify({ query, limit, min_score: minScore }),
56
99
  signal: AbortSignal.timeout(5000),
57
100
  });
58
- if (!res.ok) { stats.backendReachable = false; return []; }
101
+ if (!res.ok) { stats.backendReachable = false; console.error(`[pentatonic-memory] search HTTP ${res.status}`); return []; }
59
102
  stats.backendReachable = true;
60
103
  const data = await res.json();
61
104
  return data.results || [];
62
- } catch {
105
+ } catch (err) {
63
106
  stats.backendReachable = false;
107
+ console.error(`[pentatonic-memory] search fetch error: ${err.message}`);
64
108
  return [];
65
109
  }
66
110
  }
@@ -71,13 +115,15 @@ async function localStore(baseUrl, content, metadata = {}) {
71
115
  method: "POST",
72
116
  headers: { "Content-Type": "application/json" },
73
117
  body: JSON.stringify({ content, metadata }),
74
- signal: AbortSignal.timeout(10000),
118
+ // Generous timeout — on a Pi, Ollama embed + HyDE generation can take 30-60s per message
119
+ signal: AbortSignal.timeout(120000),
75
120
  });
76
- if (!res.ok) { stats.backendReachable = false; return null; }
121
+ if (!res.ok) { stats.backendReachable = false; console.error(`[pentatonic-memory] store HTTP ${res.status}`); return null; }
77
122
  stats.backendReachable = true;
78
123
  return res.json();
79
- } catch {
124
+ } catch (err) {
80
125
  stats.backendReachable = false;
126
+ console.error(`[pentatonic-memory] store fetch error: ${err.message}`);
81
127
  return null;
82
128
  }
83
129
  }
@@ -86,7 +132,18 @@ async function localHealth(baseUrl) {
86
132
  try {
87
133
  const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(3000) });
88
134
  stats.backendReachable = res.ok;
89
- return res.ok;
135
+ if (!res.ok) return false;
136
+ // Check for server version mismatch. Warns loudly (but non-fatal)
137
+ // when the server is older than what this plugin expects — the
138
+ // common case is a user who updated the plugin but forgot to
139
+ // re-run `npx @pentatonic-ai/ai-agent-sdk@latest memory`.
140
+ try {
141
+ const data = await res.json();
142
+ if (data?.version) {
143
+ warnIfServerTooOld(data.version);
144
+ }
145
+ } catch { /* health body missing version — older server, no-op */ }
146
+ return true;
90
147
  } catch {
91
148
  stats.backendReachable = false;
92
149
  return false;
@@ -164,6 +221,108 @@ async function hostedStore(config, content, metadata = {}) {
164
221
  }
165
222
  }
166
223
 
224
+ /**
225
+ * Emit a CHAT_TURN event to TES so the conversation-analytics dashboard
226
+ * (Token Universe + Tools tabs) can render. Without this, the dashboard
227
+ * filters on eventType=CHAT_TURN and shows nothing for OpenClaw users
228
+ * because the only events emitted are STORE_MEMORY.
229
+ *
230
+ * Missing metadata is omitted rather than zeroed — the dashboard
231
+ * distinguishes "no data" from "zero usage".
232
+ */
233
+ async function hostedEmitChatTurn(config, sessionId, turn) {
234
+ const attributes = {
235
+ source: "openclaw-plugin",
236
+ user_message: turn.userMessage,
237
+ assistant_response: turn.assistantResponse,
238
+ };
239
+ if (turn.model) attributes.model = turn.model;
240
+ if (turn.usage) attributes.usage = turn.usage;
241
+ if (turn.toolCalls?.length) attributes.tool_calls = turn.toolCalls;
242
+ if (turn.turnNumber !== undefined) attributes.turn_number = turn.turnNumber;
243
+ if (turn.systemPrompt) attributes.system_prompt = turn.systemPrompt;
244
+
245
+ try {
246
+ const res = await fetch(`${config.tes_endpoint}/api/graphql`, {
247
+ method: "POST",
248
+ headers: tesHeaders(config),
249
+ body: JSON.stringify({
250
+ query: `mutation Cme($moduleId: String!, $input: ModuleEventInput!) {
251
+ createModuleEvent(moduleId: $moduleId, input: $input) { success eventId }
252
+ }`,
253
+ variables: {
254
+ moduleId: "conversation-analytics",
255
+ input: {
256
+ eventType: "CHAT_TURN",
257
+ data: { entity_id: sessionId, attributes },
258
+ },
259
+ },
260
+ }),
261
+ signal: AbortSignal.timeout(10000),
262
+ });
263
+ if (!res.ok) return null;
264
+ return res.json();
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ // Pull model/usage/tool_calls from whatever shape the runtime hands us.
271
+ // Different OpenClaw versions wrap provider responses differently — we
272
+ // check the obvious places and silently omit fields we can't find.
273
+ function extractAssistantMetadata(message) {
274
+ const meta = {};
275
+ if (message?.model) meta.model = message.model;
276
+ if (message?.usage) meta.usage = message.usage;
277
+ if (Array.isArray(message?.tool_calls) && message.tool_calls.length) {
278
+ meta.toolCalls = message.tool_calls;
279
+ } else if (Array.isArray(message?.toolCalls) && message.toolCalls.length) {
280
+ meta.toolCalls = message.toolCalls;
281
+ }
282
+ const raw = message?.raw || message?.response || message?._raw;
283
+ if (raw && typeof raw === "object") {
284
+ if (!meta.model && raw.model) meta.model = raw.model;
285
+ if (!meta.usage && raw.usage) meta.usage = raw.usage;
286
+ if (!meta.toolCalls) {
287
+ if (Array.isArray(raw.content)) {
288
+ const tc = raw.content
289
+ .filter((b) => b?.type === "tool_use")
290
+ .map((b) => ({ tool: b.name, args: b.input || {} }));
291
+ if (tc.length) meta.toolCalls = tc;
292
+ }
293
+ if (!meta.toolCalls && Array.isArray(raw.choices) && raw.choices[0]?.message?.tool_calls) {
294
+ meta.toolCalls = raw.choices[0].message.tool_calls.map((tc) => ({
295
+ tool: tc.function?.name || tc.name,
296
+ args: tc.function?.arguments,
297
+ }));
298
+ }
299
+ }
300
+ }
301
+ return meta;
302
+ }
303
+
304
+ // Per-session turn buffer for CHAT_TURN emission. User message waits
305
+ // for the next assistant message in the same session, then emits as
306
+ // a paired turn. Capped to avoid unbounded growth.
307
+ const MAX_SESSIONS = 500;
308
+ const turnBuffers = new Map();
309
+ const turnCounters = new Map();
310
+
311
+ function capSessionMaps() {
312
+ while (turnBuffers.size > MAX_SESSIONS) {
313
+ turnBuffers.delete(turnBuffers.keys().next().value);
314
+ }
315
+ while (turnCounters.size > MAX_SESSIONS) {
316
+ turnCounters.delete(turnCounters.keys().next().value);
317
+ }
318
+ }
319
+
320
+ /** Test helper — clear turn buffers and counters between tests. */
321
+ export function _resetTurnBuffersForTest() {
322
+ turnBuffers.clear();
323
+ turnCounters.clear();
324
+ }
325
+
167
326
  // --- TES account setup via HTTP ---
168
327
 
169
328
  async function tesLogin(email, password, clientId) {
@@ -264,12 +423,12 @@ export default {
264
423
  kind: "context-engine",
265
424
 
266
425
  register(api) {
267
- const config = api.config || {};
426
+ const config = api.pluginConfig || api.config?.plugins?.entries?.["pentatonic-memory"]?.config || api.config || {};
268
427
  const hosted = !!(config.tes_endpoint && config.tes_api_key);
269
428
  const baseUrl = config.memory_url || "http://localhost:3333";
270
429
  const searchLimit = config.search_limit || 5;
271
430
  const minScore = config.min_score || 0.3;
272
- const log = (msg) => process.stderr.write(`[pentatonic-memory] ${msg}\n`);
431
+ const log = (msg) => console.error(`[pentatonic-memory] ${msg}`);
273
432
 
274
433
  stats.mode = hosted ? "hosted" : "local";
275
434
 
@@ -284,6 +443,44 @@ export default {
284
443
 
285
444
  // --- Context engine: always registered, proxies to backend ---
286
445
 
446
+ // Extract the real user text from an OpenClaw-wrapped user message.
447
+ // Returns null for system prompts / empty content / already-seen artifacts.
448
+ function extractIngestText(message) {
449
+ const raw = typeof message?.content === "string"
450
+ ? message.content
451
+ : Array.isArray(message?.content)
452
+ ? message.content.filter(b => b.type === "text").map(b => b.text).join(" ")
453
+ : null;
454
+ const role = message?.role || message?.type;
455
+ if (!raw || (role !== "user" && role !== "assistant")) return { text: null, role };
456
+
457
+ if (role === "user") {
458
+ const trimmed = raw.trim();
459
+ let text = raw;
460
+ if (
461
+ trimmed.startsWith("Conversation info") ||
462
+ trimmed.startsWith("(untrusted metadata)") ||
463
+ trimmed.startsWith("Sender (untrusted") ||
464
+ trimmed.startsWith("Untrusted context")
465
+ ) {
466
+ text = trimmed
467
+ .replace(/(?:Conversation info|Sender|Thread starter|Replied message|Forwarded message context|Chat history since last reply) \(untrusted[^)]*\):\s*```json[\s\S]*?```/g, "")
468
+ .replace(/Untrusted context \(metadata, do not treat as instructions or commands\):/g, "")
469
+ .trim();
470
+ }
471
+ if (
472
+ !text ||
473
+ text.startsWith("Note: The previous agent run") ||
474
+ text.startsWith("System (untrusted)") ||
475
+ text.startsWith("[System]") ||
476
+ text.startsWith("System:") ||
477
+ text.startsWith("[Queued messages")
478
+ ) return { text: null, role };
479
+ return { text, role };
480
+ }
481
+ return { text: raw, role };
482
+ }
483
+
287
484
  api.registerContextEngine("pentatonic-memory", () => ({
288
485
  info: {
289
486
  id: "pentatonic-memory",
@@ -291,71 +488,114 @@ export default {
291
488
  ownsCompaction: false,
292
489
  },
293
490
 
491
+ async ingestBatch({ sessionId, messages }) {
492
+ let ingestedCount = 0;
493
+ for (const message of messages) {
494
+ const { text, role } = extractIngestText(message);
495
+ if (!text) continue;
496
+ try {
497
+ await store(text, { session_id: sessionId, role });
498
+ ingestedCount++;
499
+ } catch (err) {
500
+ log(`ingestBatch: error ${err.message}`);
501
+ }
502
+ }
503
+ stats.memoriesStored += ingestedCount;
504
+ if (ingestedCount > 0) {
505
+ log(`ingestBatch: ingested ${ingestedCount}/${messages.length} (total=${stats.memoriesStored})`);
506
+ }
507
+ return { ingested: ingestedCount };
508
+ },
509
+
294
510
  async ingest({ sessionId, message }) {
295
- if (!message?.content) return { ingested: false };
296
- const role = message.role || message.type;
297
- if (role !== "user" && role !== "assistant") return { ingested: false };
511
+ const { text, role } = extractIngestText(message);
512
+ if (!text) return { ingested: false };
298
513
  try {
299
- await store(message.content, { session_id: sessionId, role });
514
+ await store(text, { session_id: sessionId, role });
300
515
  stats.memoriesStored++;
301
516
  return { ingested: true };
302
- } catch {
517
+ } catch (err) {
518
+ log(`ingest: error ${err.message}`);
303
519
  return { ingested: false };
304
520
  }
305
521
  },
306
522
 
307
523
  async assemble({ sessionId, messages }) {
308
- const lastUserMsg = [...messages].reverse().find((m) => m.role === "user" || m.type === "user");
309
- if (!lastUserMsg?.content) return { messages, estimatedTokens: 0 };
310
-
311
- // First interaction: welcome the user and offer TES upgrade
312
- if (!stats.setupPrompted) {
313
- stats.setupPrompted = true;
524
+ // Extract text from message content (may be string or array of content blocks)
525
+ function getTextContent(msg) {
526
+ if (!msg) return null;
527
+ if (typeof msg.content === "string") return msg.content;
528
+ if (Array.isArray(msg.content)) {
529
+ const text = msg.content.filter(b => b.type === "text").map(b => b.text).join(" ");
530
+ return text || null;
531
+ }
532
+ return null;
533
+ }
314
534
 
315
- if (!hosted) {
316
- await localHealth(baseUrl);
535
+ // OpenClaw wraps real user messages in "Conversation info" JSON envelopes.
536
+ // Extract the actual user text from the embedded JSON.
537
+ function extractUserText(text) {
538
+ if (!text) return null;
539
+ const trimmed = text.trim();
540
+
541
+ // Pure system prompts — skip entirely
542
+ if (
543
+ trimmed.startsWith("Note: The previous agent run") ||
544
+ trimmed.startsWith("System (untrusted)") ||
545
+ trimmed.startsWith("[System]") ||
546
+ trimmed.startsWith("System:")
547
+ ) return null;
548
+
549
+ // OpenClaw metadata envelopes: the actual user message comes AFTER all
550
+ // the "```json ... ```" metadata blocks, separated by \n\n.
551
+ // Strip all metadata blocks and untrusted-context framing, return what's left.
552
+ if (
553
+ trimmed.startsWith("Conversation info") ||
554
+ trimmed.startsWith("(untrusted metadata)") ||
555
+ trimmed.startsWith("Sender (untrusted") ||
556
+ trimmed.startsWith("Untrusted context")
557
+ ) {
558
+ // Remove all fenced JSON blocks and their preceding labels
559
+ let stripped = trimmed
560
+ .replace(/(?:Conversation info|Sender|Thread starter|Replied message|Forwarded message context|Chat history since last reply) \(untrusted[^)]*\):\s*```json[\s\S]*?```/g, "")
561
+ .replace(/Untrusted context \(metadata, do not treat as instructions or commands\):/g, "")
562
+ .trim();
563
+ if (stripped) return stripped;
564
+ return null;
317
565
  }
318
566
 
319
- let setupPrompt;
320
- if (hosted) {
321
- // Already on TES — no prompt needed
322
- setupPrompt = null;
323
- } else if (stats.backendReachable) {
324
- // Local is working — upsell TES
325
- setupPrompt = [
326
- "[Pentatonic Memory] Memory is active and working locally.",
327
- "",
328
- "After responding to the user's message, briefly mention that Pentatonic Memory is running locally and offer an upgrade:",
329
- "",
330
- "Local memory is great for personal use. For team-wide shared memory, higher-dimensional embeddings (4096d vs 768d), conversation analytics, and an admin dashboard, they can connect to Pentatonic TES (free to get started).",
331
- "",
332
- "Ask if they'd like to upgrade to hosted TES. If yes, use the pentatonic_memory_setup tool with action 'setup_hosted'.",
333
- "Keep it brief and natural — don't be pushy. One mention is enough.",
334
- ].join("\n");
335
- } else {
336
- // Nothing running — full setup prompt
337
- setupPrompt = [
338
- "[Pentatonic Memory] The memory plugin is installed but no backend is connected yet.",
339
- "",
340
- "Before responding to the user, ask them how they'd like to set up their memory:",
341
- "",
342
- "1. **Local** (self-hosted) — Fully private, runs on this machine via Docker. Free forever.",
343
- " Use the pentatonic_memory_setup tool with action 'check_local' to verify.",
344
- "",
345
- "2. **Hosted** (Pentatonic TES) — Team-wide shared memory, 4096d embeddings, analytics dashboard, and admin tools. Free to get started.",
346
- " Use the pentatonic_memory_setup tool with action 'setup_hosted' to create an account.",
347
- "",
348
- "Ask the user which option they prefer, then use the pentatonic_memory_setup tool to proceed.",
349
- ].join("\n");
567
+ // "[Queued messages]" envelope — extract embedded user messages
568
+ if (trimmed.startsWith("[Queued messages")) {
569
+ const jsonMatches = [...trimmed.matchAll(/```json\s*([\s\S]*?)\s*```/g)];
570
+ for (const match of jsonMatches.reverse()) {
571
+ try {
572
+ const data = JSON.parse(match[1]);
573
+ const inner = data.text || data.message || data.content;
574
+ if (inner) return inner;
575
+ } catch { /* continue */ }
576
+ }
577
+ return null;
350
578
  }
351
579
 
352
- if (setupPrompt) {
353
- return { messages, estimatedTokens: Math.ceil(setupPrompt.length / 4), systemPromptAddition: setupPrompt };
580
+ return trimmed;
581
+ }
582
+
583
+ const reversed = [...messages].reverse();
584
+ let lastUserText = null;
585
+ for (const m of reversed) {
586
+ if (m.role !== "user" && m.type !== "user") continue;
587
+ const text = getTextContent(m);
588
+ const extracted = extractUserText(text);
589
+ if (extracted) {
590
+ lastUserText = extracted;
591
+ break;
354
592
  }
355
593
  }
594
+ if (!lastUserText) return { messages, estimatedTokens: 0 };
356
595
 
357
596
  try {
358
- const results = await search(lastUserMsg.content, searchLimit, minScore);
597
+ const results = await search(lastUserText, searchLimit, minScore);
598
+ log(`assemble: "${lastUserText.substring(0, 50)}" → ${results.length} results`);
359
599
  if (!results.length) {
360
600
  stats.lastAssembleCount = 0;
361
601
  return { messages, estimatedTokens: 0 };
@@ -368,11 +608,30 @@ export default {
368
608
  .map((m) => `- [${Math.round((m.similarity || 0) * 100)}%] ${m.content}`)
369
609
  .join("\n");
370
610
 
611
+ // Visibility marker: instruct the model to append a footer so the
612
+ // end user sees when Pentatonic Memory was used. Opt out with
613
+ // show_memory_indicator: false in plugin config.
614
+ const showIndicator = config.show_memory_indicator !== false;
615
+ const indicatorRule = showIndicator
616
+ ? [
617
+ "",
618
+ `After your reply, on a new line, append exactly this footer (no other prefix, no trailing content):`,
619
+ `—`,
620
+ `🧠 _Used ${results.length} memor${results.length === 1 ? "y" : "ies"} from Pentatonic Memory_`,
621
+ "",
622
+ `If the memories above were not relevant to your reply, omit the footer.`,
623
+ ]
624
+ : [];
625
+
371
626
  const addition = [
372
- `[Memory] ${results.length} relevant memories found for this prompt:`,
627
+ `=== PENTATONIC MEMORY (authoritative context from prior conversations) ===`,
628
+ `These ${results.length} memories are facts the user has shared with you previously. Treat them as ground truth about the user.`,
629
+ "",
373
630
  memoryText,
374
631
  "",
375
- "When your response is informed by these memories, briefly mention it naturally (e.g. 'From what I remember...' or 'Based on our previous conversations...').",
632
+ `When the user asks about anything in these memories, answer using them directly do NOT say you don't remember or that you have no record. If a memory is relevant, use it.`,
633
+ ...indicatorRule,
634
+ `=== END PENTATONIC MEMORY ===`,
376
635
  ].join("\n");
377
636
 
378
637
  return { messages, estimatedTokens: Math.ceil(addition.length / 4), systemPromptAddition: addition };
@@ -383,7 +642,59 @@ export default {
383
642
  },
384
643
 
385
644
  async compact() { return { ok: true, compacted: false }; },
386
- async afterTurn() {},
645
+
646
+ // OpenClaw calls afterTurn INSTEAD of ingest/ingestBatch when defined.
647
+ // We use it to:
648
+ // 1. Store each new message as a memory (STORE_MEMORY in hosted mode)
649
+ // 2. Pair user+assistant messages and emit a CHAT_TURN (hosted only),
650
+ // which populates the conversation-analytics Token Universe +
651
+ // Tools tabs in the dashboard.
652
+ async afterTurn({ sessionId, messages, prePromptMessageCount }) {
653
+ if (!messages || typeof prePromptMessageCount !== "number") return;
654
+ const newMessages = messages.slice(prePromptMessageCount);
655
+ let ingestedCount = 0;
656
+ for (const message of newMessages) {
657
+ const { text, role } = extractIngestText(message);
658
+ if (!text) continue;
659
+
660
+ // Store the memory (both modes).
661
+ try {
662
+ await store(text, { session_id: sessionId, role });
663
+ ingestedCount++;
664
+ } catch (err) {
665
+ log(`afterTurn: store error ${err.message}`);
666
+ }
667
+
668
+ // CHAT_TURN emission (hosted only). Buffer user messages until
669
+ // an assistant message arrives, then emit the paired turn.
670
+ if (!hosted) continue;
671
+ try {
672
+ if (role === "user") {
673
+ turnBuffers.set(sessionId, { userMessage: text });
674
+ capSessionMaps();
675
+ } else if (role === "assistant") {
676
+ const buf = turnBuffers.get(sessionId);
677
+ const turnNumber = (turnCounters.get(sessionId) || 0) + 1;
678
+ turnCounters.set(sessionId, turnNumber);
679
+ capSessionMaps();
680
+ const meta = extractAssistantMetadata(message);
681
+ await hostedEmitChatTurn(config, sessionId, {
682
+ userMessage: buf?.userMessage,
683
+ assistantResponse: text,
684
+ turnNumber,
685
+ ...meta,
686
+ });
687
+ turnBuffers.delete(sessionId);
688
+ }
689
+ } catch (err) {
690
+ log(`afterTurn: CHAT_TURN emit error ${err.message}`);
691
+ }
692
+ }
693
+ stats.memoriesStored += ingestedCount;
694
+ if (ingestedCount > 0) {
695
+ log(`afterTurn: ingested ${ingestedCount}/${newMessages.length} (total=${stats.memoriesStored})`);
696
+ }
697
+ },
387
698
  }));
388
699
 
389
700
  // --- Tools ---
@@ -2,7 +2,7 @@
2
2
  "id": "pentatonic-memory",
3
3
  "name": "Pentatonic Memory",
4
4
  "description": "Persistent, searchable memory with multi-signal retrieval and HyDE query expansion. Local (Docker + Ollama) or hosted (Pentatonic TES).",
5
- "version": "0.4.4",
5
+ "version": "0.5.0",
6
6
  "kind": "context-engine",
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -14,6 +14,11 @@
14
14
  "default": "local",
15
15
  "description": "Local (Docker + Ollama) or hosted (Pentatonic TES)"
16
16
  },
17
+ "memory_url": {
18
+ "type": "string",
19
+ "default": "http://localhost:3333",
20
+ "description": "Memory server HTTP URL (local mode, default: http://localhost:3333)"
21
+ },
17
22
  "database_url": {
18
23
  "type": "string",
19
24
  "description": "PostgreSQL connection string (local mode)"
@@ -60,6 +65,11 @@
60
65
  "type": "number",
61
66
  "default": 0.3,
62
67
  "description": "Minimum relevance threshold"
68
+ },
69
+ "show_memory_indicator": {
70
+ "type": "boolean",
71
+ "default": true,
72
+ "description": "Tell the LLM to append a footer to replies informed by memory, so the user sees when Pentatonic Memory was used"
63
73
  }
64
74
  }
65
75
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/openclaw-memory-plugin",
3
- "version": "0.5.3",
3
+ "version": "0.8.0",
4
4
  "description": "Pentatonic Memory plugin for OpenClaw — persistent, searchable memory with multi-signal retrieval and HyDE query expansion",
5
5
  "type": "module",
6
6
  "main": "index.js",