@pentatonic-ai/ai-agent-sdk 0.4.9 → 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 +27 -5
- package/src/normalizer.js +16 -0
- package/src/session.js +21 -2
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pentatonic-ai/ai-agent-sdk/doctor
|
|
3
|
+
*
|
|
4
|
+
* Generic health check runner for SDK installations.
|
|
5
|
+
*
|
|
6
|
+
* Auto-detects the install path (local memory / hosted TES / self-hosted
|
|
7
|
+
* platform), runs the appropriate built-in checks, plus any plugins the
|
|
8
|
+
* user has dropped into ~/.config/pentatonic-ai/doctor-plugins/.
|
|
9
|
+
*
|
|
10
|
+
* Each check is a small descriptor:
|
|
11
|
+
* { name, severity: 'critical'|'warning'|'info', run: async () => result }
|
|
12
|
+
* where result is { ok, msg, detail? }.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { runDoctor } from '@pentatonic-ai/ai-agent-sdk/doctor';
|
|
16
|
+
* const report = await runDoctor({ path: 'auto' });
|
|
17
|
+
* console.log(report.summary);
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { runDoctor } from "./runner.js";
|
|
21
|
+
export { detectPath, PATHS } from "./detect.js";
|
|
22
|
+
export { loadPlugins, PLUGIN_DIR } from "./plugins.js";
|
|
23
|
+
export { renderHuman, renderJson } from "./output.js";
|
|
24
|
+
export { universalChecks } from "./checks/universal.js";
|
|
25
|
+
export { localMemoryChecks } from "./checks/local-memory.js";
|
|
26
|
+
export { hostedTesChecks } from "./checks/hosted-tes.js";
|
|
27
|
+
export { platformChecks } from "./checks/platform.js";
|
|
28
|
+
|
|
29
|
+
export const SEVERITY = Object.freeze({
|
|
30
|
+
CRITICAL: "critical",
|
|
31
|
+
WARNING: "warning",
|
|
32
|
+
INFO: "info",
|
|
33
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output renderers for doctor reports.
|
|
3
|
+
*
|
|
4
|
+
* Two formats: human (table-ish, emoji per result) and JSON (machine).
|
|
5
|
+
* Both consume the report shape from runner.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SEVERITY } from "./index.js";
|
|
9
|
+
|
|
10
|
+
const EMOJI = {
|
|
11
|
+
ok: "✓",
|
|
12
|
+
critical: "✗",
|
|
13
|
+
warning: "!",
|
|
14
|
+
info: "i",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function emojiFor(result) {
|
|
18
|
+
if (result.ok) return EMOJI.ok;
|
|
19
|
+
if (result.severity === SEVERITY.CRITICAL) return EMOJI.critical;
|
|
20
|
+
return EMOJI.warning;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pad(s, width) {
|
|
24
|
+
if (s.length >= width) return s;
|
|
25
|
+
return s + " ".repeat(width - s.length);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderHuman(report) {
|
|
29
|
+
if (!report.checks.length) {
|
|
30
|
+
return "no checks were run";
|
|
31
|
+
}
|
|
32
|
+
const width = Math.min(
|
|
33
|
+
40,
|
|
34
|
+
report.checks.reduce((m, c) => Math.max(m, c.name.length), 0)
|
|
35
|
+
);
|
|
36
|
+
const lines = [];
|
|
37
|
+
lines.push(`paths detected: ${report.paths.join(", ")}`);
|
|
38
|
+
if (report.pluginCount) {
|
|
39
|
+
lines.push(`plugins loaded: ${report.pluginCount}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push("");
|
|
42
|
+
for (const r of report.checks) {
|
|
43
|
+
lines.push(`${emojiFor(r)} ${pad(r.name, width)} ${r.msg}`);
|
|
44
|
+
}
|
|
45
|
+
lines.push("");
|
|
46
|
+
const { ok, warning, critical, total } = report.summary;
|
|
47
|
+
lines.push(
|
|
48
|
+
`summary: ${ok} ok, ${warning} warning, ${critical} critical (of ${total})`
|
|
49
|
+
);
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderJson(report, { pretty = true } = {}) {
|
|
54
|
+
return JSON.stringify(report, null, pretty ? 2 : 0);
|
|
55
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin loader for doctor.
|
|
3
|
+
*
|
|
4
|
+
* Looks in ~/.config/pentatonic-ai/doctor-plugins/ (overridable) for
|
|
5
|
+
* .mjs files and dynamically imports each one. (.js is intentionally
|
|
6
|
+
* not supported — without a sibling package.json setting "type":"module",
|
|
7
|
+
* Node treats .js as CommonJS, which can't use `export default`. Forcing
|
|
8
|
+
* .mjs sidesteps that whole class of confusion.) The default export
|
|
9
|
+
* must look like:
|
|
10
|
+
*
|
|
11
|
+
* export default {
|
|
12
|
+
* name: 'my-plugin',
|
|
13
|
+
* checks: [
|
|
14
|
+
* { name: 'thing reachable', severity: 'warning',
|
|
15
|
+
* run: async () => ({ ok: true, msg: '...' }) },
|
|
16
|
+
* ],
|
|
17
|
+
* };
|
|
18
|
+
*
|
|
19
|
+
* This is how downstream agents (e.g. Optimus on the Machinegenie stack)
|
|
20
|
+
* register their own checks without forking the SDK.
|
|
21
|
+
*
|
|
22
|
+
* Bad plugins are skipped with a warning rather than aborting the run —
|
|
23
|
+
* a broken plugin should never block a doctor pass.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readdirSync } from "fs";
|
|
27
|
+
import { join } from "path";
|
|
28
|
+
import { homedir } from "os";
|
|
29
|
+
import { pathToFileURL } from "url";
|
|
30
|
+
|
|
31
|
+
export const PLUGIN_DIR = join(
|
|
32
|
+
homedir(),
|
|
33
|
+
".config",
|
|
34
|
+
"pentatonic-ai",
|
|
35
|
+
"doctor-plugins"
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function isValidPlugin(mod) {
|
|
39
|
+
if (!mod || typeof mod !== "object") return false;
|
|
40
|
+
if (typeof mod.name !== "string" || !mod.name) return false;
|
|
41
|
+
if (!Array.isArray(mod.checks)) return false;
|
|
42
|
+
for (const c of mod.checks) {
|
|
43
|
+
if (!c || typeof c.name !== "string" || typeof c.run !== "function") {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function loadPlugins({ dir = PLUGIN_DIR, onError } = {}) {
|
|
51
|
+
if (!existsSync(dir)) return [];
|
|
52
|
+
|
|
53
|
+
const warn = onError || (() => {});
|
|
54
|
+
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(dir);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
warn(`could not read plugin dir ${dir}: ${err.message}`);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const plugins = [];
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.endsWith(".mjs")) continue;
|
|
66
|
+
const filePath = join(dir, entry);
|
|
67
|
+
try {
|
|
68
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
69
|
+
const plugin = mod.default || mod;
|
|
70
|
+
if (!isValidPlugin(plugin)) {
|
|
71
|
+
warn(`${entry}: not a valid plugin (missing name/checks)`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
plugins.push(plugin);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
warn(`${entry}: failed to load — ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return plugins;
|
|
81
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor runner.
|
|
3
|
+
*
|
|
4
|
+
* Composes the active check set from detected paths + plugins, runs each
|
|
5
|
+
* check (catching its own failures so one bad check can't take the rest
|
|
6
|
+
* down), and aggregates results into a single report.
|
|
7
|
+
*
|
|
8
|
+
* Each check is shaped:
|
|
9
|
+
* {
|
|
10
|
+
* name: string,
|
|
11
|
+
* severity: 'critical' | 'warning' | 'info',
|
|
12
|
+
* run: async () => { ok: boolean, msg: string, detail?: object }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* The runner does not print anything itself — pass the returned report to
|
|
16
|
+
* renderHuman or renderJson. This keeps the runner usable from tests, MCP
|
|
17
|
+
* servers, dashboards, etc.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { detectPaths, PATHS } from "./detect.js";
|
|
21
|
+
import { universalChecks } from "./checks/universal.js";
|
|
22
|
+
import { localMemoryChecks } from "./checks/local-memory.js";
|
|
23
|
+
import { hostedTesChecks } from "./checks/hosted-tes.js";
|
|
24
|
+
import { platformChecks } from "./checks/platform.js";
|
|
25
|
+
import { loadPlugins } from "./plugins.js";
|
|
26
|
+
import { SEVERITY } from "./index.js";
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
29
|
+
|
|
30
|
+
function pathChecks(path) {
|
|
31
|
+
switch (path) {
|
|
32
|
+
case PATHS.LOCAL:
|
|
33
|
+
return localMemoryChecks();
|
|
34
|
+
case PATHS.HOSTED:
|
|
35
|
+
return hostedTesChecks();
|
|
36
|
+
case PATHS.PLATFORM:
|
|
37
|
+
return platformChecks();
|
|
38
|
+
default:
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runOne(check, timeoutMs) {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
try {
|
|
46
|
+
const result = await Promise.race([
|
|
47
|
+
Promise.resolve().then(() => check.run()),
|
|
48
|
+
new Promise((_, reject) =>
|
|
49
|
+
setTimeout(
|
|
50
|
+
() => reject(new Error(`timed out after ${timeoutMs}ms`)),
|
|
51
|
+
timeoutMs
|
|
52
|
+
)
|
|
53
|
+
),
|
|
54
|
+
]);
|
|
55
|
+
if (!result || typeof result.ok !== "boolean") {
|
|
56
|
+
throw new Error("check returned invalid result (missing ok:boolean)");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
name: check.name,
|
|
60
|
+
severity: check.severity || SEVERITY.WARNING,
|
|
61
|
+
ok: result.ok,
|
|
62
|
+
msg: result.msg || (result.ok ? "ok" : "failed"),
|
|
63
|
+
detail: result.detail || {},
|
|
64
|
+
durationMs: Date.now() - start,
|
|
65
|
+
};
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return {
|
|
68
|
+
name: check.name,
|
|
69
|
+
severity: check.severity || SEVERITY.WARNING,
|
|
70
|
+
ok: false,
|
|
71
|
+
msg: `check itself failed: ${err.message}`,
|
|
72
|
+
detail: {},
|
|
73
|
+
durationMs: Date.now() - start,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function summarise(results) {
|
|
79
|
+
let ok = 0;
|
|
80
|
+
let warning = 0;
|
|
81
|
+
let critical = 0;
|
|
82
|
+
for (const r of results) {
|
|
83
|
+
if (r.ok) ok += 1;
|
|
84
|
+
else if (r.severity === SEVERITY.CRITICAL) critical += 1;
|
|
85
|
+
else warning += 1;
|
|
86
|
+
}
|
|
87
|
+
return { ok, warning, critical, total: results.length };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runDoctor(opts = {}) {
|
|
91
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
92
|
+
const paths = detectPaths(opts);
|
|
93
|
+
|
|
94
|
+
// Universal checks always run.
|
|
95
|
+
const checks = [...universalChecks()];
|
|
96
|
+
for (const p of paths) {
|
|
97
|
+
checks.push(...pathChecks(p));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Plugins — opt-out via opts.plugins === false.
|
|
101
|
+
let pluginCount = 0;
|
|
102
|
+
if (opts.plugins !== false) {
|
|
103
|
+
const plugins = await loadPlugins({ dir: opts.pluginDir });
|
|
104
|
+
pluginCount = plugins.length;
|
|
105
|
+
for (const plugin of plugins) {
|
|
106
|
+
for (const check of plugin.checks || []) {
|
|
107
|
+
checks.push({
|
|
108
|
+
...check,
|
|
109
|
+
name: `${plugin.name}: ${check.name}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Allow extra checks to be passed in directly (used by tests).
|
|
116
|
+
if (Array.isArray(opts.extraChecks)) {
|
|
117
|
+
checks.push(...opts.extraChecks);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Run sequentially to keep network probes from contending on the same
|
|
121
|
+
// hosts; a parallel mode can be added later behind opts.concurrency.
|
|
122
|
+
const results = [];
|
|
123
|
+
for (const check of checks) {
|
|
124
|
+
results.push(await runOne(check, timeoutMs));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
timestamp: new Date().toISOString(),
|
|
129
|
+
// Serialise as array so JSON.stringify produces something useful;
|
|
130
|
+
// detection logic still uses a Set internally for membership checks.
|
|
131
|
+
paths: [...paths],
|
|
132
|
+
pluginCount,
|
|
133
|
+
summary: summarise(results),
|
|
134
|
+
checks: results,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Migration 005: Atomic (distilled) memories
|
|
2
|
+
--
|
|
3
|
+
-- Adds source_id column to memory_nodes, linking distilled atomic facts
|
|
4
|
+
-- back to their source (raw) memory. Raw messages go in episodic, extracted
|
|
5
|
+
-- facts go in semantic with source_id pointing to the raw.
|
|
6
|
+
--
|
|
7
|
+
-- Atoms are searchable in the same table; source_id gives provenance
|
|
8
|
+
-- and allows filtering (atoms-only vs. raw-only retrieval).
|
|
9
|
+
|
|
10
|
+
ALTER TABLE memory_nodes
|
|
11
|
+
ADD COLUMN IF NOT EXISTS source_id TEXT
|
|
12
|
+
REFERENCES memory_nodes(id) ON DELETE CASCADE;
|
|
13
|
+
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_memory_nodes_source_id
|
|
15
|
+
ON memory_nodes(source_id)
|
|
16
|
+
WHERE source_id IS NOT NULL;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
-- Migration 006: Make embedding_vec dimension-agnostic
|
|
2
|
+
--
|
|
3
|
+
-- The original embedding_vec column was declared as vector(4096) assuming
|
|
4
|
+
-- TES's NV-Embed-v2 model. But users running with different embedding models
|
|
5
|
+
-- (e.g. nomic-embed-text at 768 dims) silently get NULL embedding_vec because
|
|
6
|
+
-- the trigger's cast fails on dimension mismatch.
|
|
7
|
+
--
|
|
8
|
+
-- Fix: use vector without a fixed dimension so any size fits. We lose the
|
|
9
|
+
-- ability to use HNSW indexes (which require fixed dims) but vector search
|
|
10
|
+
-- still works via sequential scan, which is fine for local/self-hosted setups
|
|
11
|
+
-- with <100k memories.
|
|
12
|
+
--
|
|
13
|
+
-- For TES production (4096d with HNSW), this migration is a no-op if the
|
|
14
|
+
-- column is already correctly sized.
|
|
15
|
+
|
|
16
|
+
-- Strategy: detect existing column definition and only resize if the
|
|
17
|
+
-- configured dimension doesn't match what the running memory server will
|
|
18
|
+
-- actually produce. We can't know the runtime embedding size from a SQL
|
|
19
|
+
-- migration, so we pick a simple heuristic:
|
|
20
|
+
--
|
|
21
|
+
-- * If any rows already have a non-NULL embedding_vec, the column dim
|
|
22
|
+
-- matches what's been inserted — leave it (and the HNSW index) alone.
|
|
23
|
+
-- * If all embedding_vec values are NULL but JSONB embeddings exist,
|
|
24
|
+
-- the cast has been failing silently. Check the JSONB dimension:
|
|
25
|
+
-- - If it matches the current column dim, repopulate and keep HNSW.
|
|
26
|
+
-- - If it differs, resize to dimensionless vector (loses HNSW,
|
|
27
|
+
-- gains compatibility with any model).
|
|
28
|
+
|
|
29
|
+
DO $$
|
|
30
|
+
DECLARE
|
|
31
|
+
jsonb_dim INTEGER;
|
|
32
|
+
col_has_data BOOLEAN;
|
|
33
|
+
BEGIN
|
|
34
|
+
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
|
|
35
|
+
RETURN;
|
|
36
|
+
END IF;
|
|
37
|
+
|
|
38
|
+
IF NOT EXISTS (
|
|
39
|
+
SELECT 1 FROM information_schema.columns
|
|
40
|
+
WHERE table_schema = current_schema()
|
|
41
|
+
AND table_name = 'memory_nodes'
|
|
42
|
+
AND column_name = 'embedding_vec'
|
|
43
|
+
) THEN
|
|
44
|
+
RETURN;
|
|
45
|
+
END IF;
|
|
46
|
+
|
|
47
|
+
-- Is the column already working (any populated rows)?
|
|
48
|
+
SELECT EXISTS (SELECT 1 FROM memory_nodes WHERE embedding_vec IS NOT NULL LIMIT 1)
|
|
49
|
+
INTO col_has_data;
|
|
50
|
+
|
|
51
|
+
IF col_has_data THEN
|
|
52
|
+
-- Column + HNSW are fine as-is. Just try to repopulate anything missing.
|
|
53
|
+
UPDATE memory_nodes
|
|
54
|
+
SET embedding_vec = embedding::text::vector
|
|
55
|
+
WHERE embedding IS NOT NULL
|
|
56
|
+
AND embedding != 'null'::jsonb
|
|
57
|
+
AND embedding != '[]'::jsonb
|
|
58
|
+
AND embedding_vec IS NULL;
|
|
59
|
+
RETURN;
|
|
60
|
+
END IF;
|
|
61
|
+
|
|
62
|
+
-- Column is empty. Check JSONB dim to decide if we need to resize.
|
|
63
|
+
SELECT jsonb_array_length(embedding) INTO jsonb_dim
|
|
64
|
+
FROM memory_nodes
|
|
65
|
+
WHERE embedding IS NOT NULL AND embedding != 'null'::jsonb AND embedding != '[]'::jsonb
|
|
66
|
+
LIMIT 1;
|
|
67
|
+
|
|
68
|
+
IF jsonb_dim IS NULL THEN
|
|
69
|
+
-- No data yet; leave column as-is, nothing to repopulate
|
|
70
|
+
RETURN;
|
|
71
|
+
END IF;
|
|
72
|
+
|
|
73
|
+
-- We have JSONB data but no vector data. Try a sample cast to see if
|
|
74
|
+
-- it works against the current column type.
|
|
75
|
+
BEGIN
|
|
76
|
+
UPDATE memory_nodes
|
|
77
|
+
SET embedding_vec = embedding::text::vector
|
|
78
|
+
WHERE id = (SELECT id FROM memory_nodes WHERE embedding IS NOT NULL LIMIT 1);
|
|
79
|
+
-- Cast succeeded — column dim matches, repopulate everything else
|
|
80
|
+
UPDATE memory_nodes
|
|
81
|
+
SET embedding_vec = embedding::text::vector
|
|
82
|
+
WHERE embedding IS NOT NULL
|
|
83
|
+
AND embedding != 'null'::jsonb
|
|
84
|
+
AND embedding != '[]'::jsonb
|
|
85
|
+
AND embedding_vec IS NULL;
|
|
86
|
+
EXCEPTION WHEN OTHERS THEN
|
|
87
|
+
-- Dimension mismatch. Resize to dimensionless to unblock local setups.
|
|
88
|
+
RAISE NOTICE 'Dimension mismatch detected (JSONB is %d); resizing embedding_vec to dimensionless', jsonb_dim;
|
|
89
|
+
DROP INDEX IF EXISTS idx_memory_nodes_embedding_vec;
|
|
90
|
+
ALTER TABLE memory_nodes ALTER COLUMN embedding_vec TYPE vector USING NULL;
|
|
91
|
+
UPDATE memory_nodes
|
|
92
|
+
SET embedding_vec = embedding::text::vector
|
|
93
|
+
WHERE embedding IS NOT NULL
|
|
94
|
+
AND embedding != 'null'::jsonb
|
|
95
|
+
AND embedding != '[]'::jsonb;
|
|
96
|
+
END;
|
|
97
|
+
END $$;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CHAT_TURN emission + envelope handling on the PUBLISHED
|
|
3
|
+
* OpenClaw plugin (`packages/memory/openclaw-plugin/`). This is the
|
|
4
|
+
* file OpenClaw actually installs via `openclaw plugins install
|
|
5
|
+
* @pentatonic-ai/openclaw-memory-plugin`, so these tests cover the
|
|
6
|
+
* runtime path the dashboard depends on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import plugin, { _resetTurnBuffersForTest } from "../index.js";
|
|
10
|
+
|
|
11
|
+
const realFetch = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
function captureFetch() {
|
|
14
|
+
const calls = [];
|
|
15
|
+
globalThis.fetch = async (url, init) => {
|
|
16
|
+
const body = init?.body ? JSON.parse(init.body) : null;
|
|
17
|
+
calls.push({ url, body });
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 200,
|
|
21
|
+
json: async () => ({ data: { createModuleEvent: { success: true, eventId: "e" } } }),
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
return calls;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Build a hosted engine via the plugin's register hook. The published
|
|
28
|
+
// plugin reads config from api.pluginConfig (newer OpenClaw) or
|
|
29
|
+
// api.config.plugins.entries[...].config (older), whichever is set.
|
|
30
|
+
function makeEngine(extraConfig = {}) {
|
|
31
|
+
let factory;
|
|
32
|
+
plugin.register({
|
|
33
|
+
pluginConfig: {
|
|
34
|
+
tes_endpoint: "https://x.test",
|
|
35
|
+
tes_client_id: "c",
|
|
36
|
+
tes_api_key: "tes_c_xyz",
|
|
37
|
+
...extraConfig,
|
|
38
|
+
},
|
|
39
|
+
registerTool: () => {},
|
|
40
|
+
registerContextEngine: (_name, fn) => {
|
|
41
|
+
factory = fn;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (!factory) throw new Error("plugin did not register a context engine");
|
|
45
|
+
return factory();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
globalThis.fetch = realFetch;
|
|
50
|
+
_resetTurnBuffersForTest();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("openclaw-memory-plugin — hosted CHAT_TURN via afterTurn", () => {
|
|
54
|
+
it("emits CHAT_TURN when afterTurn is called with user+assistant messages", async () => {
|
|
55
|
+
const calls = captureFetch();
|
|
56
|
+
const engine = makeEngine();
|
|
57
|
+
|
|
58
|
+
await engine.afterTurn({
|
|
59
|
+
sessionId: "sess-1",
|
|
60
|
+
messages: [
|
|
61
|
+
{ role: "user", content: "hi" },
|
|
62
|
+
{
|
|
63
|
+
role: "assistant",
|
|
64
|
+
content: "hello",
|
|
65
|
+
model: "claude-3-5",
|
|
66
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
prePromptMessageCount: 0,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const turn = calls.find(
|
|
73
|
+
(c) =>
|
|
74
|
+
c.body?.variables?.moduleId === "conversation-analytics" &&
|
|
75
|
+
c.body?.variables?.input?.eventType === "CHAT_TURN"
|
|
76
|
+
);
|
|
77
|
+
expect(turn).toBeDefined();
|
|
78
|
+
const attrs = turn.body.variables.input.data.attributes;
|
|
79
|
+
expect(attrs.user_message).toBe("hi");
|
|
80
|
+
expect(attrs.assistant_response).toBe("hello");
|
|
81
|
+
expect(attrs.model).toBe("claude-3-5");
|
|
82
|
+
expect(attrs.usage).toEqual({ input_tokens: 10, output_tokens: 5 });
|
|
83
|
+
expect(attrs.turn_number).toBe(1);
|
|
84
|
+
expect(attrs.source).toBe("openclaw-plugin");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("also emits STORE_MEMORY for retrieval (both events fire)", async () => {
|
|
88
|
+
const calls = captureFetch();
|
|
89
|
+
const engine = makeEngine();
|
|
90
|
+
|
|
91
|
+
await engine.afterTurn({
|
|
92
|
+
sessionId: "sess-2",
|
|
93
|
+
messages: [
|
|
94
|
+
{ role: "user", content: "question" },
|
|
95
|
+
{ role: "assistant", content: "answer" },
|
|
96
|
+
],
|
|
97
|
+
prePromptMessageCount: 0,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const storeMemory = calls.filter(
|
|
101
|
+
(c) => c.body?.variables?.moduleId === "deep-memory"
|
|
102
|
+
);
|
|
103
|
+
const chatTurn = calls.filter(
|
|
104
|
+
(c) => c.body?.variables?.moduleId === "conversation-analytics"
|
|
105
|
+
);
|
|
106
|
+
expect(storeMemory.length).toBeGreaterThan(0);
|
|
107
|
+
expect(chatTurn.length).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles content-block arrays (Anthropic-style) correctly", async () => {
|
|
111
|
+
const calls = captureFetch();
|
|
112
|
+
const engine = makeEngine();
|
|
113
|
+
|
|
114
|
+
await engine.afterTurn({
|
|
115
|
+
sessionId: "sess-blocks",
|
|
116
|
+
messages: [
|
|
117
|
+
{ role: "user", content: [{ type: "text", text: "what do i like?" }] },
|
|
118
|
+
{ role: "assistant", content: [{ type: "text", text: "coffee" }] },
|
|
119
|
+
],
|
|
120
|
+
prePromptMessageCount: 0,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const attrs = calls.find(
|
|
124
|
+
(c) => c.body?.variables?.moduleId === "conversation-analytics"
|
|
125
|
+
).body.variables.input.data.attributes;
|
|
126
|
+
expect(attrs.user_message).toBe("what do i like?");
|
|
127
|
+
expect(attrs.assistant_response).toBe("coffee");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("extracts tool_calls from wrapped Anthropic raw response", async () => {
|
|
131
|
+
const calls = captureFetch();
|
|
132
|
+
const engine = makeEngine();
|
|
133
|
+
|
|
134
|
+
await engine.afterTurn({
|
|
135
|
+
sessionId: "sess-tools",
|
|
136
|
+
messages: [
|
|
137
|
+
{ role: "user", content: "search" },
|
|
138
|
+
{
|
|
139
|
+
role: "assistant",
|
|
140
|
+
content: "looking",
|
|
141
|
+
raw: {
|
|
142
|
+
content: [
|
|
143
|
+
{ type: "text", text: "looking" },
|
|
144
|
+
{ type: "tool_use", name: "search", input: { q: "cheese" } },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
prePromptMessageCount: 0,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const attrs = calls.find(
|
|
153
|
+
(c) => c.body?.variables?.moduleId === "conversation-analytics"
|
|
154
|
+
).body.variables.input.data.attributes;
|
|
155
|
+
expect(attrs.tool_calls).toEqual([
|
|
156
|
+
{ tool: "search", args: { q: "cheese" } },
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("increments turn_number across multiple afterTurn invocations in the same session", async () => {
|
|
161
|
+
const calls = captureFetch();
|
|
162
|
+
const engine = makeEngine();
|
|
163
|
+
|
|
164
|
+
for (let i = 0; i < 3; i++) {
|
|
165
|
+
await engine.afterTurn({
|
|
166
|
+
sessionId: "sess-multi",
|
|
167
|
+
messages: [
|
|
168
|
+
{ role: "user", content: `q${i}` },
|
|
169
|
+
{ role: "assistant", content: `a${i}` },
|
|
170
|
+
],
|
|
171
|
+
prePromptMessageCount: 0,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const turnNumbers = calls
|
|
176
|
+
.filter(
|
|
177
|
+
(c) => c.body?.variables?.moduleId === "conversation-analytics"
|
|
178
|
+
)
|
|
179
|
+
.map((c) => c.body.variables.input.data.attributes.turn_number);
|
|
180
|
+
expect(turnNumbers).toEqual([1, 2, 3]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("only uses prePromptMessageCount=N to slice new messages", async () => {
|
|
184
|
+
const calls = captureFetch();
|
|
185
|
+
const engine = makeEngine();
|
|
186
|
+
|
|
187
|
+
await engine.afterTurn({
|
|
188
|
+
sessionId: "sess-slice",
|
|
189
|
+
messages: [
|
|
190
|
+
{ role: "user", content: "old-user" },
|
|
191
|
+
{ role: "assistant", content: "old-asst" },
|
|
192
|
+
{ role: "user", content: "new-user" },
|
|
193
|
+
{ role: "assistant", content: "new-asst" },
|
|
194
|
+
],
|
|
195
|
+
prePromptMessageCount: 2,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const turn = calls.find(
|
|
199
|
+
(c) => c.body?.variables?.moduleId === "conversation-analytics"
|
|
200
|
+
);
|
|
201
|
+
expect(turn.body.variables.input.data.attributes.user_message).toBe(
|
|
202
|
+
"new-user"
|
|
203
|
+
);
|
|
204
|
+
expect(turn.body.variables.input.data.attributes.assistant_response).toBe(
|
|
205
|
+
"new-asst"
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
});
|