@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.
- package/README.md +59 -0
- package/bin/cli.js +70 -9
- package/dist/index.cjs +25 -3
- package/dist/index.js +25 -3
- package/package.json +4 -2
- package/packages/doctor/README.md +106 -0
- package/packages/doctor/__tests__/checks.test.js +187 -0
- package/packages/doctor/__tests__/detect.test.js +101 -0
- package/packages/doctor/__tests__/output.test.js +92 -0
- package/packages/doctor/__tests__/plugins.test.js +111 -0
- package/packages/doctor/__tests__/runner.test.js +131 -0
- package/packages/doctor/package.json +6 -0
- package/packages/doctor/src/checks/hosted-tes.js +109 -0
- package/packages/doctor/src/checks/local-memory.js +290 -0
- package/packages/doctor/src/checks/platform.js +170 -0
- package/packages/doctor/src/checks/universal.js +121 -0
- package/packages/doctor/src/detect.js +102 -0
- package/packages/doctor/src/index.js +33 -0
- package/packages/doctor/src/output.js +55 -0
- package/packages/doctor/src/plugins.js +81 -0
- package/packages/doctor/src/runner.js +136 -0
- package/packages/memory/migrations/005-atomic-memories.sql +16 -0
- package/packages/memory/migrations/006-fix-vector-dim.sql +97 -0
- package/packages/memory/openclaw-plugin/__tests__/chat-turn.test.js +208 -0
- package/packages/memory/openclaw-plugin/__tests__/indicator.test.js +142 -0
- package/packages/memory/openclaw-plugin/__tests__/version-check.test.js +136 -0
- package/packages/memory/openclaw-plugin/index.js +369 -58
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +11 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/distill.test.js +175 -0
- package/packages/memory/src/__tests__/openclaw-chat-turn.test.js +289 -0
- package/packages/memory/src/distill.js +162 -0
- package/packages/memory/src/index.js +1 -0
- package/packages/memory/src/ingest.js +10 -0
- package/packages/memory/src/openclaw/index.js +280 -23
- package/packages/memory/src/openclaw/package.json +1 -1
- package/packages/memory/src/server.js +59 -5
- package/src/normalizer.js +16 -0
- 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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
296
|
-
|
|
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(
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|