@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,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Memory path checks.
|
|
3
|
+
*
|
|
4
|
+
* Targets the stack started by `npx @pentatonic-ai/ai-agent-sdk memory`:
|
|
5
|
+
* - PostgreSQL with pgvector
|
|
6
|
+
* - Ollama (or any OpenAI-compatible embedding + chat endpoint)
|
|
7
|
+
* - Memory MCP server on PORT (default 3333)
|
|
8
|
+
*
|
|
9
|
+
* Configuration is read from env vars used by packages/memory/src/server.js
|
|
10
|
+
* (DATABASE_URL, EMBEDDING_URL/MODEL, LLM_URL/MODEL, PORT, API_KEY) so this
|
|
11
|
+
* stays drift-free with the actual server.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { SEVERITY } from "../index.js";
|
|
15
|
+
|
|
16
|
+
async function fetchWithTimeout(url, opts = {}, timeoutMs = 5000) {
|
|
17
|
+
return await fetch(url, {
|
|
18
|
+
...opts,
|
|
19
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkPostgres() {
|
|
24
|
+
return {
|
|
25
|
+
name: "postgres reachable",
|
|
26
|
+
severity: SEVERITY.CRITICAL,
|
|
27
|
+
run: async () => {
|
|
28
|
+
const dsn = process.env.DATABASE_URL;
|
|
29
|
+
if (!dsn) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
msg: "DATABASE_URL not set",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// Lazy-import pg so users on hosted/platform paths don't pay the cost.
|
|
36
|
+
let pg;
|
|
37
|
+
try {
|
|
38
|
+
pg = (await import("pg")).default;
|
|
39
|
+
} catch {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
msg: "'pg' not installed — run `npm install pg`",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const client = new pg.Client({ connectionString: dsn });
|
|
46
|
+
try {
|
|
47
|
+
await client.connect();
|
|
48
|
+
const v = await client.query("SELECT version()");
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
msg: v.rows[0].version.split(",")[0],
|
|
52
|
+
detail: { version: v.rows[0].version },
|
|
53
|
+
};
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
msg: err.message,
|
|
58
|
+
};
|
|
59
|
+
} finally {
|
|
60
|
+
await client.end().catch(() => {});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkPgvector() {
|
|
67
|
+
return {
|
|
68
|
+
name: "pgvector extension",
|
|
69
|
+
severity: SEVERITY.CRITICAL,
|
|
70
|
+
run: async () => {
|
|
71
|
+
const dsn = process.env.DATABASE_URL;
|
|
72
|
+
if (!dsn) return { ok: false, msg: "DATABASE_URL not set" };
|
|
73
|
+
let pg;
|
|
74
|
+
try {
|
|
75
|
+
pg = (await import("pg")).default;
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, msg: "'pg' not installed" };
|
|
78
|
+
}
|
|
79
|
+
const client = new pg.Client({ connectionString: dsn });
|
|
80
|
+
try {
|
|
81
|
+
await client.connect();
|
|
82
|
+
const r = await client.query(
|
|
83
|
+
"SELECT extversion FROM pg_extension WHERE extname='vector'"
|
|
84
|
+
);
|
|
85
|
+
if (!r.rowCount) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
msg: "pgvector not installed — run CREATE EXTENSION vector",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
msg: `pgvector ${r.rows[0].extversion}`,
|
|
94
|
+
detail: { version: r.rows[0].extversion },
|
|
95
|
+
};
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { ok: false, msg: err.message };
|
|
98
|
+
} finally {
|
|
99
|
+
await client.end().catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkMigrations() {
|
|
106
|
+
return {
|
|
107
|
+
name: "schema migrations applied",
|
|
108
|
+
severity: SEVERITY.WARNING,
|
|
109
|
+
run: async () => {
|
|
110
|
+
const dsn = process.env.DATABASE_URL;
|
|
111
|
+
if (!dsn) return { ok: false, msg: "DATABASE_URL not set" };
|
|
112
|
+
let pg;
|
|
113
|
+
try {
|
|
114
|
+
pg = (await import("pg")).default;
|
|
115
|
+
} catch {
|
|
116
|
+
return { ok: false, msg: "'pg' not installed" };
|
|
117
|
+
}
|
|
118
|
+
const client = new pg.Client({ connectionString: dsn });
|
|
119
|
+
try {
|
|
120
|
+
await client.connect();
|
|
121
|
+
// The migration runner creates schema_migrations on first apply.
|
|
122
|
+
const r = await client.query(
|
|
123
|
+
"SELECT count(*) AS n FROM schema_migrations"
|
|
124
|
+
);
|
|
125
|
+
const n = parseInt(r.rows[0].n, 10);
|
|
126
|
+
if (n === 0) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
msg: "schema_migrations is empty — start the memory server to run migrations",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, msg: `${n} migrations applied`, detail: { n } };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (/relation .* does not exist/.test(err.message)) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
msg: "schema_migrations table missing — start the memory server first",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, msg: err.message };
|
|
141
|
+
} finally {
|
|
142
|
+
await client.end().catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkEmbeddingEndpoint() {
|
|
149
|
+
return {
|
|
150
|
+
name: "embedding endpoint",
|
|
151
|
+
severity: SEVERITY.CRITICAL,
|
|
152
|
+
run: async () => {
|
|
153
|
+
const url = process.env.EMBEDDING_URL;
|
|
154
|
+
const model = process.env.EMBEDDING_MODEL;
|
|
155
|
+
if (!url || !model) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
msg: "EMBEDDING_URL or EMBEDDING_MODEL not set",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// Probe with a 1-token embed call against the OpenAI-compatible API.
|
|
162
|
+
try {
|
|
163
|
+
const headers = { "Content-Type": "application/json" };
|
|
164
|
+
if (process.env.API_KEY) {
|
|
165
|
+
headers.Authorization = `Bearer ${process.env.API_KEY}`;
|
|
166
|
+
}
|
|
167
|
+
const res = await fetchWithTimeout(
|
|
168
|
+
`${url.replace(/\/$/, "")}/embeddings`,
|
|
169
|
+
{
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers,
|
|
172
|
+
body: JSON.stringify({ model, input: "ping" }),
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
const body = await res.text().catch(() => "");
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
msg: `HTTP ${res.status}: ${body.slice(0, 120)}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const data = await res.json();
|
|
183
|
+
const dim = data.data?.[0]?.embedding?.length;
|
|
184
|
+
if (!dim) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
msg: "endpoint responded but returned no embedding",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
msg: `${model} ok (${dim}-dim)`,
|
|
193
|
+
detail: { url, model, dim },
|
|
194
|
+
};
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return { ok: false, msg: err.message };
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function checkLlmEndpoint() {
|
|
203
|
+
return {
|
|
204
|
+
name: "llm endpoint",
|
|
205
|
+
severity: SEVERITY.CRITICAL,
|
|
206
|
+
run: async () => {
|
|
207
|
+
const url = process.env.LLM_URL;
|
|
208
|
+
const model = process.env.LLM_MODEL;
|
|
209
|
+
if (!url || !model) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
msg: "LLM_URL or LLM_MODEL not set",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// /models is cheap and present on every OpenAI-compatible server.
|
|
216
|
+
try {
|
|
217
|
+
const headers = {};
|
|
218
|
+
if (process.env.API_KEY) {
|
|
219
|
+
headers.Authorization = `Bearer ${process.env.API_KEY}`;
|
|
220
|
+
}
|
|
221
|
+
const res = await fetchWithTimeout(
|
|
222
|
+
`${url.replace(/\/$/, "")}/models`,
|
|
223
|
+
{ headers }
|
|
224
|
+
);
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
return { ok: false, msg: `HTTP ${res.status}` };
|
|
227
|
+
}
|
|
228
|
+
const data = await res.json();
|
|
229
|
+
const ids = (data.data || []).map((m) => m.id);
|
|
230
|
+
if (model && !ids.includes(model)) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
msg: `${model} not loaded; available: ${ids.slice(0, 3).join(", ")}`,
|
|
234
|
+
detail: { url, requested: model, available: ids },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
msg: `${model} loaded`,
|
|
240
|
+
detail: { url, model },
|
|
241
|
+
};
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return { ok: false, msg: err.message };
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function checkMemoryServer() {
|
|
250
|
+
return {
|
|
251
|
+
name: "memory server",
|
|
252
|
+
severity: SEVERITY.WARNING,
|
|
253
|
+
run: async () => {
|
|
254
|
+
// The MCP server uses stdio by default but exposes PORT for HTTP/SSE.
|
|
255
|
+
// If PORT isn't bound we can't probe — that's not an error, just info.
|
|
256
|
+
const port = process.env.PORT || "3333";
|
|
257
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetchWithTimeout(url, {}, 2000);
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
msg: `port ${port} reachable (HTTP ${res.status})`,
|
|
263
|
+
detail: { url, status: res.status },
|
|
264
|
+
};
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// Connection refused is the common case when the server is run
|
|
267
|
+
// via stdio only — surface as info so users aren't alarmed.
|
|
268
|
+
if (/ECONNREFUSED|fetch failed/.test(err.message)) {
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
msg: `port ${port} not bound (running via stdio? — skipped)`,
|
|
272
|
+
detail: { url },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return { ok: false, msg: err.message };
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function localMemoryChecks() {
|
|
282
|
+
return [
|
|
283
|
+
checkPostgres(),
|
|
284
|
+
checkPgvector(),
|
|
285
|
+
checkMigrations(),
|
|
286
|
+
checkEmbeddingEndpoint(),
|
|
287
|
+
checkLlmEndpoint(),
|
|
288
|
+
checkMemoryServer(),
|
|
289
|
+
];
|
|
290
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted Pentatonic platform checks.
|
|
3
|
+
*
|
|
4
|
+
* The full platform layers HybridRAG + Qdrant + Neo4j + a vLLM/Ollama stack
|
|
5
|
+
* on top of the Local-Memory pieces. URLs are entirely env-driven — no
|
|
6
|
+
* container names hardcoded — so this works whether the user runs Pip,
|
|
7
|
+
* Machinegenie, or any other instance with a different docker-compose
|
|
8
|
+
* naming scheme.
|
|
9
|
+
*
|
|
10
|
+
* Required env (all optional individually; check is skipped if missing):
|
|
11
|
+
* HYBRIDRAG_URL — e.g. http://hybridrag:8031
|
|
12
|
+
* QDRANT_URL — e.g. http://qdrant:6333
|
|
13
|
+
* NEO4J_HTTP — e.g. http://neo4j:7474
|
|
14
|
+
* NEO4J_USER — defaults to 'neo4j'
|
|
15
|
+
* NEO4J_PASSWORD — required if NEO4J_HTTP set
|
|
16
|
+
* VLLM_URL — e.g. http://host.docker.internal:8001
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { SEVERITY } from "../index.js";
|
|
20
|
+
|
|
21
|
+
async function fetchWithTimeout(url, opts = {}, timeoutMs = 5000) {
|
|
22
|
+
return await fetch(url, {
|
|
23
|
+
...opts,
|
|
24
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkHybridrag() {
|
|
29
|
+
return {
|
|
30
|
+
name: "hybridrag reachable",
|
|
31
|
+
severity: SEVERITY.CRITICAL,
|
|
32
|
+
run: async () => {
|
|
33
|
+
const url = process.env.HYBRIDRAG_URL;
|
|
34
|
+
if (!url) {
|
|
35
|
+
return { ok: true, msg: "HYBRIDRAG_URL not set (skipped)" };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetchWithTimeout(`${url.replace(/\/$/, "")}/health`);
|
|
39
|
+
if (res.ok) return { ok: true, msg: `${url} healthy` };
|
|
40
|
+
// Some deployments don't expose /health — fall back to a search probe.
|
|
41
|
+
const probe = await fetchWithTimeout(
|
|
42
|
+
`${url.replace(/\/$/, "")}/v1/search`,
|
|
43
|
+
{
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ query: "ping", limit: 1 }),
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
if (probe.ok) return { ok: true, msg: `${url} healthy (search probe)` };
|
|
50
|
+
return { ok: false, msg: `HTTP ${probe.status}` };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { ok: false, msg: err.message };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function checkQdrant() {
|
|
59
|
+
return {
|
|
60
|
+
name: "qdrant reachable",
|
|
61
|
+
severity: SEVERITY.WARNING,
|
|
62
|
+
run: async () => {
|
|
63
|
+
const url = process.env.QDRANT_URL;
|
|
64
|
+
if (!url) {
|
|
65
|
+
return { ok: true, msg: "QDRANT_URL not set (skipped)" };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetchWithTimeout(
|
|
69
|
+
`${url.replace(/\/$/, "")}/collections`
|
|
70
|
+
);
|
|
71
|
+
if (!res.ok) return { ok: false, msg: `HTTP ${res.status}` };
|
|
72
|
+
const data = await res.json();
|
|
73
|
+
const cols = (data.result?.collections || []).map((c) => c.name);
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
msg: `${cols.length} collections: ${cols.slice(0, 5).join(", ")}${cols.length > 5 ? ", ..." : ""}`,
|
|
77
|
+
detail: { collections: cols },
|
|
78
|
+
};
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { ok: false, msg: err.message };
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkNeo4j() {
|
|
87
|
+
return {
|
|
88
|
+
name: "neo4j reachable",
|
|
89
|
+
severity: SEVERITY.WARNING,
|
|
90
|
+
run: async () => {
|
|
91
|
+
const url = process.env.NEO4J_HTTP;
|
|
92
|
+
if (!url) {
|
|
93
|
+
return { ok: true, msg: "NEO4J_HTTP not set (skipped)" };
|
|
94
|
+
}
|
|
95
|
+
const user = process.env.NEO4J_USER || "neo4j";
|
|
96
|
+
const pw = process.env.NEO4J_PASSWORD || process.env.NEO4J_PW;
|
|
97
|
+
if (!pw) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
msg: "NEO4J_PASSWORD not set (NEO4J_HTTP requires auth)",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const auth = Buffer.from(`${user}:${pw}`).toString("base64");
|
|
105
|
+
const res = await fetchWithTimeout(
|
|
106
|
+
`${url.replace(/\/$/, "")}/db/neo4j/tx/commit`,
|
|
107
|
+
{
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
Authorization: `Basic ${auth}`,
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
statements: [{ statement: "RETURN 1 AS ok" }],
|
|
115
|
+
}),
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
if (res.status === 401) {
|
|
119
|
+
return { ok: false, msg: "auth rejected — check NEO4J_PASSWORD" };
|
|
120
|
+
}
|
|
121
|
+
if (!res.ok) return { ok: false, msg: `HTTP ${res.status}` };
|
|
122
|
+
const data = await res.json();
|
|
123
|
+
if (data.errors?.length) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
msg: `query error: ${data.errors[0].message?.slice(0, 80)}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, msg: "ok" };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { ok: false, msg: err.message };
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function checkVllm() {
|
|
138
|
+
return {
|
|
139
|
+
name: "vllm reachable",
|
|
140
|
+
severity: SEVERITY.WARNING,
|
|
141
|
+
run: async () => {
|
|
142
|
+
const url = process.env.VLLM_URL;
|
|
143
|
+
if (!url) {
|
|
144
|
+
return { ok: true, msg: "VLLM_URL not set (skipped)" };
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetchWithTimeout(
|
|
148
|
+
`${url.replace(/\/$/, "")}/v1/models`
|
|
149
|
+
);
|
|
150
|
+
if (!res.ok) return { ok: false, msg: `HTTP ${res.status}` };
|
|
151
|
+
const data = await res.json();
|
|
152
|
+
const ids = (data.data || []).map((m) => m.id);
|
|
153
|
+
if (!ids.length) {
|
|
154
|
+
return { ok: false, msg: "no models loaded" };
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
msg: `serving ${ids.join(", ")}`,
|
|
159
|
+
detail: { models: ids },
|
|
160
|
+
};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { ok: false, msg: err.message };
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function platformChecks() {
|
|
169
|
+
return [checkHybridrag(), checkQdrant(), checkNeo4j(), checkVllm()];
|
|
170
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal checks — apply regardless of install path.
|
|
3
|
+
*
|
|
4
|
+
* Kept deliberately small: things that any SDK user benefits from
|
|
5
|
+
* regardless of which path they're on.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { statfsSync, statSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir, tmpdir } from "os";
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import { SEVERITY } from "../index.js";
|
|
13
|
+
|
|
14
|
+
function checkNodeVersion() {
|
|
15
|
+
return {
|
|
16
|
+
name: "node version",
|
|
17
|
+
severity: SEVERITY.WARNING,
|
|
18
|
+
run: async () => {
|
|
19
|
+
const v = process.versions.node;
|
|
20
|
+
const major = parseInt(v.split(".")[0], 10);
|
|
21
|
+
// The SDK's package.json doesn't pin engines, but the memory subsystem
|
|
22
|
+
// uses fetch and top-level await — needs ≥ 18.
|
|
23
|
+
if (major < 18) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
msg: `Node ${v} — SDK needs ≥18`,
|
|
27
|
+
detail: { version: v, major },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, msg: `Node ${v}`, detail: { version: v } };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkDiskSpace() {
|
|
36
|
+
return {
|
|
37
|
+
name: "disk space",
|
|
38
|
+
severity: SEVERITY.WARNING,
|
|
39
|
+
run: async () => {
|
|
40
|
+
const targets = [homedir(), tmpdir()];
|
|
41
|
+
const detail = {};
|
|
42
|
+
const tight = [];
|
|
43
|
+
for (const p of targets) {
|
|
44
|
+
try {
|
|
45
|
+
const s = statfsSync(p);
|
|
46
|
+
const free = Number(s.bavail) * Number(s.bsize);
|
|
47
|
+
const total = Number(s.blocks) * Number(s.bsize);
|
|
48
|
+
if (!total) continue;
|
|
49
|
+
const pctFree = (free / total) * 100;
|
|
50
|
+
detail[p] = `${pctFree.toFixed(1)}% free`;
|
|
51
|
+
if (pctFree < 10) {
|
|
52
|
+
tight.push(`${p}: ${pctFree.toFixed(1)}% free`);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// statfsSync can be missing on older Node; skip silently
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (tight.length) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
msg: tight.join("; "),
|
|
62
|
+
detail,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const summary = Object.entries(detail)
|
|
66
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
67
|
+
.join("; ");
|
|
68
|
+
return { ok: true, msg: summary || "skipped", detail };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkConfigPerms() {
|
|
74
|
+
return {
|
|
75
|
+
name: "config file perms",
|
|
76
|
+
severity: SEVERITY.CRITICAL,
|
|
77
|
+
run: async () => {
|
|
78
|
+
// Files most likely to contain credentials. Each is optional;
|
|
79
|
+
// we only fail if a file exists with overly-permissive mode.
|
|
80
|
+
const candidates = [
|
|
81
|
+
join(homedir(), ".claude", "tes-memory.local.md"),
|
|
82
|
+
join(homedir(), ".claude-pentatonic", "tes-memory.local.md"),
|
|
83
|
+
join(homedir(), ".config", "pentatonic-ai", "config.json"),
|
|
84
|
+
];
|
|
85
|
+
const bad = [];
|
|
86
|
+
const checked = [];
|
|
87
|
+
for (const f of candidates) {
|
|
88
|
+
if (!existsSync(f)) continue;
|
|
89
|
+
checked.push(f);
|
|
90
|
+
const mode = statSync(f).mode & 0o777;
|
|
91
|
+
// Anything readable by group/other on a credential file is too open.
|
|
92
|
+
if (mode & 0o077) {
|
|
93
|
+
bad.push(`${f}: mode ${mode.toString(8)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (bad.length) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
msg: `${bad.length} config file(s) world-readable; chmod 600`,
|
|
100
|
+
detail: { offenders: bad },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (!checked.length) {
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
msg: "no SDK config files present (skipped)",
|
|
107
|
+
detail: { checked: [] },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
msg: `${checked.length} config file(s) ok`,
|
|
113
|
+
detail: { checked },
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function universalChecks() {
|
|
120
|
+
return [checkNodeVersion(), checkDiskSpace(), checkConfigPerms()];
|
|
121
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-path detection.
|
|
3
|
+
*
|
|
4
|
+
* The SDK supports three deployment paths (see README "Overview"). Doctor
|
|
5
|
+
* needs to know which one this user is on so it only runs relevant checks.
|
|
6
|
+
*
|
|
7
|
+
* Detection signals (in priority order):
|
|
8
|
+
* 1. Explicit override via opts.path or PENTATONIC_DOCTOR_PATH env var.
|
|
9
|
+
* 2. Hosted TES — TES_ENDPOINT + TES_API_KEY both set in env.
|
|
10
|
+
* 3. Self-hosted platform — HYBRIDRAG_URL set, OR a Pentatonic platform
|
|
11
|
+
* config file present (~/.openclaw/openclaw.json).
|
|
12
|
+
* 4. Local memory — DATABASE_URL points at a memory-shaped DSN, OR
|
|
13
|
+
* ~/.claude/tes-memory.local.md exists, OR Local-Memory env vars set.
|
|
14
|
+
* 5. Fallback: 'unknown' — only universal checks run.
|
|
15
|
+
*
|
|
16
|
+
* Multiple paths can be active at once (e.g. an Optimus install runs both
|
|
17
|
+
* the platform stack AND has a hosted TES key). detectPaths() returns the
|
|
18
|
+
* full set; detectPath() returns the primary one for human-friendly output.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync as realExistsSync } from "fs";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
import { homedir as realHomedir } from "os";
|
|
24
|
+
|
|
25
|
+
export const PATHS = Object.freeze({
|
|
26
|
+
LOCAL: "local",
|
|
27
|
+
HOSTED: "hosted",
|
|
28
|
+
PLATFORM: "platform",
|
|
29
|
+
UNKNOWN: "unknown",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const VALID = new Set(Object.values(PATHS));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} opts
|
|
36
|
+
* @param {object} [opts.env] - process.env override; if set, filesystem
|
|
37
|
+
* detection is also disabled so tests get deterministic results without
|
|
38
|
+
* needing to mock the real homedir.
|
|
39
|
+
* @param {string} [opts.path] - explicit path or 'auto'
|
|
40
|
+
* @param {Function} [opts.fileExists] - test seam for filesystem probes
|
|
41
|
+
* @param {string} [opts.homedir] - test seam for homedir
|
|
42
|
+
*/
|
|
43
|
+
export function detectPaths(opts = {}) {
|
|
44
|
+
const env = opts.env || process.env;
|
|
45
|
+
const usingFakeEnv = Boolean(opts.env);
|
|
46
|
+
const fileExists = opts.fileExists ||
|
|
47
|
+
(usingFakeEnv ? () => false : realExistsSync);
|
|
48
|
+
const home = opts.homedir || realHomedir();
|
|
49
|
+
|
|
50
|
+
// Explicit override wins.
|
|
51
|
+
const override = opts.path || env.PENTATONIC_DOCTOR_PATH;
|
|
52
|
+
if (override && override !== "auto") {
|
|
53
|
+
if (!VALID.has(override)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Unknown path '${override}'. Valid: ${[...VALID].join(", ")}, auto`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return new Set([override]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const found = new Set();
|
|
62
|
+
|
|
63
|
+
if (env.TES_ENDPOINT && env.TES_API_KEY) {
|
|
64
|
+
found.add(PATHS.HOSTED);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const platformConfig = join(home, ".openclaw", "openclaw.json");
|
|
68
|
+
if (env.HYBRIDRAG_URL || fileExists(platformConfig)) {
|
|
69
|
+
found.add(PATHS.PLATFORM);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const localConfig = join(home, ".claude", "tes-memory.local.md");
|
|
73
|
+
const looksLocal =
|
|
74
|
+
(env.DATABASE_URL && env.EMBEDDING_URL && env.LLM_URL) ||
|
|
75
|
+
fileExists(localConfig);
|
|
76
|
+
if (looksLocal) {
|
|
77
|
+
found.add(PATHS.LOCAL);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!found.size) {
|
|
81
|
+
found.add(PATHS.UNKNOWN);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @returns the primary detected path for display purposes.
|
|
89
|
+
*/
|
|
90
|
+
export function detectPath(opts = {}) {
|
|
91
|
+
const paths = detectPaths(opts);
|
|
92
|
+
// Priority: platform > hosted > local > unknown.
|
|
93
|
+
for (const p of [
|
|
94
|
+
PATHS.PLATFORM,
|
|
95
|
+
PATHS.HOSTED,
|
|
96
|
+
PATHS.LOCAL,
|
|
97
|
+
PATHS.UNKNOWN,
|
|
98
|
+
]) {
|
|
99
|
+
if (paths.has(p)) return p;
|
|
100
|
+
}
|
|
101
|
+
return PATHS.UNKNOWN;
|
|
102
|
+
}
|