@memtensor/memos-local-openclaw-plugin 1.0.4-beta.7 → 1.0.4-beta.9
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 +1 -1
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +6 -0
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts.map +1 -1
- package/dist/client/connector.js +61 -7
- package/dist/client/connector.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -2
- package/dist/config.js.map +1 -1
- package/dist/hub/server.d.ts +7 -0
- package/dist/hub/server.d.ts.map +1 -1
- package/dist/hub/server.js +171 -8
- package/dist/hub/server.js.map +1 -1
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +37 -6
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +78 -1
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts +1 -0
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +82 -8
- package/dist/shared/llm-call.js.map +1 -1
- package/dist/skill/evolver.d.ts +2 -0
- package/dist/skill/evolver.d.ts.map +1 -1
- package/dist/skill/evolver.js +3 -0
- package/dist/skill/evolver.js.map +1 -1
- package/dist/storage/sqlite.d.ts +5 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +13 -4
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts +12 -5
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +135 -38
- package/dist/telemetry.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +735 -285
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +16 -0
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +349 -21
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +26 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -2
- package/scripts/postinstall.cjs +1 -1
- package/src/capture/index.ts +8 -0
- package/src/client/connector.ts +62 -7
- package/src/config.ts +0 -2
- package/src/hub/server.ts +168 -8
- package/src/ingest/providers/index.ts +41 -7
- package/src/recall/engine.ts +73 -1
- package/src/shared/llm-call.ts +97 -9
- package/src/skill/evolver.ts +5 -0
- package/src/storage/sqlite.ts +19 -6
- package/src/telemetry.ts +152 -39
- package/src/types.ts +1 -2
- package/src/viewer/html.ts +735 -285
- package/src/viewer/server.ts +322 -21
package/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
9
9
|
import { Type } from "@sinclair/typebox";
|
|
10
10
|
import * as fs from "fs";
|
|
11
11
|
import * as path from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
12
13
|
import { buildContext } from "./src/config";
|
|
13
14
|
import type { HostModelsConfig } from "./src/openclaw-api";
|
|
14
15
|
import { ensureSqliteBinding } from "./src/storage/ensure-binding";
|
|
@@ -82,13 +83,20 @@ const memosLocalPlugin = {
|
|
|
82
83
|
|
|
83
84
|
register(api: OpenClawPluginApi) {
|
|
84
85
|
// ─── Ensure better-sqlite3 native module is available ───
|
|
85
|
-
const pluginDir = path.dirname(
|
|
86
|
+
const pluginDir = path.dirname(fileURLToPath(import.meta.url));
|
|
87
|
+
|
|
88
|
+
function normalizeFsPath(p: string): string {
|
|
89
|
+
return path.resolve(p).replace(/\\/g, "/").toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
86
92
|
let sqliteReady = false;
|
|
87
93
|
|
|
88
94
|
function trySqliteLoad(): boolean {
|
|
89
95
|
try {
|
|
90
96
|
const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
|
|
91
|
-
|
|
97
|
+
const resolvedNorm = normalizeFsPath(resolved);
|
|
98
|
+
const pluginNorm = normalizeFsPath(pluginDir);
|
|
99
|
+
if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
|
|
92
100
|
api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
|
|
93
101
|
return false;
|
|
94
102
|
}
|
|
@@ -208,6 +216,7 @@ const memosLocalPlugin = {
|
|
|
208
216
|
const workspaceDir = api.resolvePath("~/.openclaw/workspace");
|
|
209
217
|
const skillCtx = { ...ctx, workspaceDir };
|
|
210
218
|
const skillEvolver = new SkillEvolver(store, engine, skillCtx);
|
|
219
|
+
skillEvolver.onSkillEvolved = (name, type) => telemetry.trackSkillEvolved(name, type);
|
|
211
220
|
const skillInstaller = new SkillInstaller(store, skillCtx);
|
|
212
221
|
|
|
213
222
|
let pluginVersion = "0.0.0";
|
|
@@ -272,6 +281,18 @@ const memosLocalPlugin = {
|
|
|
272
281
|
// Falls back to "main" when no hook has fired yet (single-agent setups).
|
|
273
282
|
let currentAgentId = "main";
|
|
274
283
|
|
|
284
|
+
// ─── Check allowPromptInjection policy ───
|
|
285
|
+
// When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
|
|
286
|
+
// will be stripped by the framework. Skip auto-recall to avoid unnecessary LLM/embedding calls.
|
|
287
|
+
const pluginEntry = (api.config as any)?.plugins?.entries?.[api.id];
|
|
288
|
+
const allowPromptInjection = pluginEntry?.hooks?.allowPromptInjection !== false;
|
|
289
|
+
if (!allowPromptInjection) {
|
|
290
|
+
api.logger.info("memos-local: allowPromptInjection=false, auto-recall disabled");
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled");
|
|
294
|
+
}
|
|
295
|
+
|
|
275
296
|
const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
|
|
276
297
|
async (...args: any[]) => {
|
|
277
298
|
const t0 = performance.now();
|
|
@@ -283,6 +304,7 @@ const memosLocalPlugin = {
|
|
|
283
304
|
return result;
|
|
284
305
|
} catch (e) {
|
|
285
306
|
ok = false;
|
|
307
|
+
telemetry.trackError(toolName, (e as Error)?.name ?? "unknown");
|
|
286
308
|
throw e;
|
|
287
309
|
} finally {
|
|
288
310
|
const dur = performance.now() - t0;
|
|
@@ -1097,6 +1119,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
1097
1119
|
parameters: Type.Object({}),
|
|
1098
1120
|
execute: trackTool("memory_viewer", async () => {
|
|
1099
1121
|
ctx.log.debug(`memory_viewer called`);
|
|
1122
|
+
telemetry.trackViewerOpened();
|
|
1100
1123
|
const url = `http://127.0.0.1:${viewerPort}`;
|
|
1101
1124
|
return {
|
|
1102
1125
|
content: [
|
|
@@ -1548,6 +1571,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
|
|
|
1548
1571
|
// ─── Auto-recall: inject relevant memories before agent starts ───
|
|
1549
1572
|
|
|
1550
1573
|
api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
|
|
1574
|
+
if (!allowPromptInjection) return {};
|
|
1551
1575
|
if (!event.prompt || event.prompt.length < 3) return;
|
|
1552
1576
|
|
|
1553
1577
|
const recallAgentId = hookCtx?.agentId ?? "main";
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "MemOS Local Memory",
|
|
4
4
|
"description": "Full-write local conversation memory with hybrid search (RRF + MMR + recency). Provides memory_search, memory_get, task_summary, memory_timeline, memory_viewer for layered retrieval.",
|
|
5
5
|
"kind": "memory",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.12",
|
|
7
7
|
"skills": [
|
|
8
8
|
"skill/memos-memory-guide"
|
|
9
9
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memtensor/memos-local-openclaw-plugin",
|
|
3
|
-
"version": "1.0.4-beta.
|
|
3
|
+
"version": "1.0.4-beta.9",
|
|
4
4
|
"description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
"@huggingface/transformers": "^3.8.0",
|
|
52
52
|
"@sinclair/typebox": "^0.34.48",
|
|
53
53
|
"better-sqlite3": "^12.6.2",
|
|
54
|
-
"posthog-node": "^5.28.0",
|
|
55
54
|
"puppeteer": "^24.38.0",
|
|
56
55
|
"semver": "^7.7.4",
|
|
57
56
|
"uuid": "^10.0.0"
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -112,7 +112,7 @@ try {
|
|
|
112
112
|
function ensureDependencies() {
|
|
113
113
|
phase(0, "检测核心依赖 / Check core dependencies");
|
|
114
114
|
|
|
115
|
-
const coreDeps = ["@sinclair/typebox", "uuid", "
|
|
115
|
+
const coreDeps = ["@sinclair/typebox", "uuid", "@huggingface/transformers"];
|
|
116
116
|
const missing = [];
|
|
117
117
|
for (const dep of coreDeps) {
|
|
118
118
|
try {
|
package/src/capture/index.ts
CHANGED
|
@@ -75,6 +75,7 @@ export function captureMessages(
|
|
|
75
75
|
if (role === "user") {
|
|
76
76
|
content = stripInboundMetadata(content);
|
|
77
77
|
} else {
|
|
78
|
+
content = stripThinkingTags(content);
|
|
78
79
|
content = stripEvidenceWrappers(content, evidenceTag);
|
|
79
80
|
}
|
|
80
81
|
if (!content.trim()) continue;
|
|
@@ -163,6 +164,13 @@ export function stripInboundMetadata(text: string): string {
|
|
|
163
164
|
return stripEnvelopePrefix(result.join("\n")).trim();
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
/** Strip <think…>…</think> blocks emitted by DeepSeek-style reasoning models. */
|
|
168
|
+
const THINKING_TAG_RE = /<think[\s>][\s\S]*?<\/think>\s*/gi;
|
|
169
|
+
|
|
170
|
+
function stripThinkingTags(text: string): string {
|
|
171
|
+
return text.replace(THINKING_TAG_RE, "");
|
|
172
|
+
}
|
|
173
|
+
|
|
166
174
|
function extractEnvelopeTimestamp(text: string): number | null {
|
|
167
175
|
const m = ENVELOPE_EXTRACT_RE.exec(text);
|
|
168
176
|
if (!m) return null;
|
package/src/client/connector.ts
CHANGED
|
@@ -34,6 +34,41 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
|
|
|
34
34
|
|
|
35
35
|
if (!userToken && config.sharing?.client?.teamToken) {
|
|
36
36
|
if (!log) throw new Error("hub client connection is not configured (no userToken, has teamToken but no logger for auto-join)");
|
|
37
|
+
|
|
38
|
+
// If DB has a pending connection (userId exists, no token), check registration-status first
|
|
39
|
+
const persisted = store.getClientHubConnection();
|
|
40
|
+
if (persisted?.userId && !persisted.userToken && hubAddress) {
|
|
41
|
+
const hubUrl = normalizeHubUrl(hubAddress);
|
|
42
|
+
const teamToken = config.sharing.client!.teamToken!;
|
|
43
|
+
try {
|
|
44
|
+
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/registration-status", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
body: JSON.stringify({ teamToken, userId: persisted.userId }),
|
|
47
|
+
}) as any;
|
|
48
|
+
if (result.status === "active" && result.userToken) {
|
|
49
|
+
log.info(`Pending user approved! Connecting with token. userId=${persisted.userId}`);
|
|
50
|
+
store.setClientHubConnection({
|
|
51
|
+
hubUrl,
|
|
52
|
+
userId: persisted.userId,
|
|
53
|
+
username: persisted.username || "",
|
|
54
|
+
userToken: result.userToken,
|
|
55
|
+
role: "member",
|
|
56
|
+
connectedAt: Date.now(),
|
|
57
|
+
});
|
|
58
|
+
return store.getClientHubConnection()!;
|
|
59
|
+
}
|
|
60
|
+
if (result.status === "pending") {
|
|
61
|
+
throw new PendingApprovalError(persisted.userId);
|
|
62
|
+
}
|
|
63
|
+
if (result.status === "rejected") {
|
|
64
|
+
throw new Error("Join request was rejected by the Hub admin.");
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err instanceof PendingApprovalError) throw err;
|
|
68
|
+
log.warn(`registration-status check failed, falling back to autoJoinHub: ${err}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
return autoJoinHub(store, config, log);
|
|
38
73
|
}
|
|
39
74
|
|
|
@@ -56,16 +91,23 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
|
|
|
56
91
|
|
|
57
92
|
export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig): Promise<HubStatusInfo> {
|
|
58
93
|
const conn = store.getClientHubConnection();
|
|
59
|
-
const
|
|
94
|
+
const configHubAddress = config.sharing?.client?.hubAddress || "";
|
|
95
|
+
const hubAddress = conn?.hubUrl || (configHubAddress ? normalizeHubUrl(configHubAddress) : "");
|
|
60
96
|
const userToken = conn?.userToken || config.sharing?.client?.userToken || "";
|
|
61
97
|
|
|
98
|
+
// If DB has a connection to a different Hub than config, the DB data is stale
|
|
99
|
+
if (conn && configHubAddress && conn.hubUrl && normalizeHubUrl(configHubAddress) !== conn.hubUrl) {
|
|
100
|
+
store.clearClientHubConnection();
|
|
101
|
+
return { connected: false, user: null };
|
|
102
|
+
}
|
|
103
|
+
|
|
62
104
|
if (conn && conn.userId && (!userToken || userToken === "")) {
|
|
63
105
|
const teamToken = config.sharing?.client?.teamToken ?? "";
|
|
64
106
|
if (hubAddress && teamToken) {
|
|
65
107
|
try {
|
|
66
|
-
const result = await hubRequestJson(normalizeHubUrl(hubAddress), "", "/api/v1/hub/
|
|
108
|
+
const result = await hubRequestJson(normalizeHubUrl(hubAddress), "", "/api/v1/hub/registration-status", {
|
|
67
109
|
method: "POST",
|
|
68
|
-
body: JSON.stringify({ teamToken,
|
|
110
|
+
body: JSON.stringify({ teamToken, userId: conn.userId }),
|
|
69
111
|
}) as any;
|
|
70
112
|
if (result.status === "pending") {
|
|
71
113
|
return {
|
|
@@ -82,7 +124,7 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
|
|
|
82
124
|
if (result.status === "active" && result.userToken) {
|
|
83
125
|
store.setClientHubConnection({
|
|
84
126
|
hubUrl: normalizeHubUrl(hubAddress),
|
|
85
|
-
userId:
|
|
127
|
+
userId: conn.userId,
|
|
86
128
|
username: conn.username || "",
|
|
87
129
|
userToken: result.userToken,
|
|
88
130
|
role: "member",
|
|
@@ -123,13 +165,25 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
|
|
|
123
165
|
|
|
124
166
|
try {
|
|
125
167
|
const me = await hubRequestJson(normalizeHubUrl(hubAddress), userToken, "/api/v1/hub/me", { method: "GET" }) as any;
|
|
168
|
+
const latestUsername = String(me.username ?? "");
|
|
169
|
+
const latestRole = String(me.role ?? "member") as UserRole;
|
|
170
|
+
if (conn && (conn.username !== latestUsername || conn.role !== latestRole)) {
|
|
171
|
+
store.setClientHubConnection({
|
|
172
|
+
hubUrl: conn.hubUrl,
|
|
173
|
+
userId: conn.userId,
|
|
174
|
+
username: latestUsername,
|
|
175
|
+
userToken: conn.userToken,
|
|
176
|
+
role: latestRole,
|
|
177
|
+
connectedAt: conn.connectedAt,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
126
180
|
return {
|
|
127
181
|
connected: true,
|
|
128
182
|
hubUrl: normalizeHubUrl(hubAddress),
|
|
129
183
|
user: {
|
|
130
184
|
id: String(me.id),
|
|
131
|
-
username:
|
|
132
|
-
role:
|
|
185
|
+
username: latestUsername,
|
|
186
|
+
role: latestRole,
|
|
133
187
|
status: String(me.status ?? "active"),
|
|
134
188
|
},
|
|
135
189
|
};
|
|
@@ -151,7 +205,8 @@ export async function autoJoinHub(
|
|
|
151
205
|
const hubUrl = normalizeHubUrl(hubAddress);
|
|
152
206
|
const osModule = typeof globalThis.process !== "undefined" ? await import("os") : null;
|
|
153
207
|
const hostname = osModule ? osModule.hostname() : "unknown";
|
|
154
|
-
const
|
|
208
|
+
const nickname = config.sharing?.client?.nickname;
|
|
209
|
+
const username = nickname || (osModule ? osModule.userInfo().username : "user");
|
|
155
210
|
let clientIp = "";
|
|
156
211
|
if (osModule) {
|
|
157
212
|
const nets = osModule.networkInterfaces();
|
package/src/config.ts
CHANGED
|
@@ -75,8 +75,6 @@ export function resolveConfig(raw: Partial<MemosLocalConfig> | undefined, stateD
|
|
|
75
75
|
},
|
|
76
76
|
telemetry: {
|
|
77
77
|
enabled: telemetryEnabled,
|
|
78
|
-
posthogApiKey: cfg.telemetry?.posthogApiKey ?? process.env.POSTHOG_API_KEY ?? "",
|
|
79
|
-
posthogHost: cfg.telemetry?.posthogHost ?? process.env.POSTHOG_HOST ?? "",
|
|
80
78
|
},
|
|
81
79
|
summarizer: (() => {
|
|
82
80
|
const summarizerConfig = resolveProviderFallback<SummarizerConfig>(
|
package/src/hub/server.ts
CHANGED
|
@@ -34,6 +34,11 @@ export class HubServer {
|
|
|
34
34
|
private static readonly RATE_LIMIT_SEARCH = 30;
|
|
35
35
|
private rateBuckets = new Map<string, { count: number; windowStart: number }>();
|
|
36
36
|
|
|
37
|
+
private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
|
|
38
|
+
private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
|
|
39
|
+
private offlineCheckTimer?: ReturnType<typeof setInterval>;
|
|
40
|
+
private knownOnlineUsers = new Set<string>();
|
|
41
|
+
|
|
37
42
|
constructor(private opts: HubServerOptions) {
|
|
38
43
|
this.userManager = new HubUserManager(opts.store, opts.log);
|
|
39
44
|
this.authStatePath = path.join(opts.dataDir, "hub-auth.json");
|
|
@@ -101,10 +106,14 @@ export class HubServer {
|
|
|
101
106
|
this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
this.initOnlineTracking();
|
|
110
|
+
this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
|
|
111
|
+
|
|
104
112
|
return `http://127.0.0.1:${this.port}`;
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
async stop(): Promise<void> {
|
|
116
|
+
if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
|
|
108
117
|
if (!this.server) return;
|
|
109
118
|
const server = this.server;
|
|
110
119
|
this.server = undefined;
|
|
@@ -209,12 +218,17 @@ export class HubServer {
|
|
|
209
218
|
return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
|
|
210
219
|
}
|
|
211
220
|
if (existingUser.status === "pending") {
|
|
221
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
212
222
|
return this.json(res, 200, { status: "pending", userId: existingUser.id });
|
|
213
223
|
}
|
|
214
224
|
if (existingUser.status === "rejected") {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
if (body.reapply === true) {
|
|
226
|
+
this.userManager.resetToPending(existingUser.id);
|
|
227
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
228
|
+
this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
|
|
229
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id });
|
|
230
|
+
}
|
|
231
|
+
return this.json(res, 200, { status: "rejected", userId: existingUser.id });
|
|
218
232
|
}
|
|
219
233
|
}
|
|
220
234
|
const user = this.userManager.createPendingUser({
|
|
@@ -223,6 +237,7 @@ export class HubServer {
|
|
|
223
237
|
});
|
|
224
238
|
try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
|
|
225
239
|
this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
|
|
240
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
226
241
|
return this.json(res, 200, { status: "pending", userId: user.id });
|
|
227
242
|
}
|
|
228
243
|
|
|
@@ -260,6 +275,20 @@ export class HubServer {
|
|
|
260
275
|
return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
|
|
261
276
|
}
|
|
262
277
|
|
|
278
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
|
|
279
|
+
return this.json(res, 200, { ok: true });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
|
|
283
|
+
try {
|
|
284
|
+
this.opts.store.updateHubUserActivity(auth.userId, "", 0);
|
|
285
|
+
} catch { /* best-effort */ }
|
|
286
|
+
this.knownOnlineUsers.delete(auth.userId);
|
|
287
|
+
this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
|
|
288
|
+
this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
|
|
289
|
+
return this.json(res, 200, { ok: true });
|
|
290
|
+
}
|
|
291
|
+
|
|
263
292
|
if (req.method === "GET" && routePath === "/api/v1/hub/me") {
|
|
264
293
|
const user = this.opts.store.getHubUser(auth.userId);
|
|
265
294
|
if (!user) return this.json(res, 401, { error: "unauthorized" });
|
|
@@ -300,6 +329,7 @@ export class HubServer {
|
|
|
300
329
|
const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
|
|
301
330
|
const approved = this.userManager.approveUser(String(body.userId), token);
|
|
302
331
|
if (!approved) return this.json(res, 404, { error: "not_found" });
|
|
332
|
+
try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
|
|
303
333
|
return this.json(res, 200, { status: "active", token });
|
|
304
334
|
}
|
|
305
335
|
|
|
@@ -315,12 +345,16 @@ export class HubServer {
|
|
|
315
345
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
316
346
|
const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
|
|
317
347
|
const contribs = this.opts.store.getHubUserContributions();
|
|
348
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
349
|
+
const now = Date.now();
|
|
318
350
|
return this.json(res, 200, { users: users.map(u => {
|
|
319
351
|
const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
352
|
+
const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
|
|
320
353
|
return {
|
|
321
354
|
id: u.id, username: u.username, role: u.role, status: u.status,
|
|
322
355
|
deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
|
|
323
356
|
lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
|
|
357
|
+
isOwner: u.id === ownerId, isOnline,
|
|
324
358
|
memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
|
|
325
359
|
};
|
|
326
360
|
}) });
|
|
@@ -332,6 +366,9 @@ export class HubServer {
|
|
|
332
366
|
const userId = String(body?.userId || "");
|
|
333
367
|
const newRole = String(body?.role || "");
|
|
334
368
|
if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
|
|
369
|
+
if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
|
|
370
|
+
return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
|
|
371
|
+
}
|
|
335
372
|
const user = this.opts.store.getHubUser(userId);
|
|
336
373
|
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
337
374
|
const updatedUser = { ...user, role: newRole as "admin" | "member" };
|
|
@@ -353,8 +390,16 @@ export class HubServer {
|
|
|
353
390
|
}
|
|
354
391
|
const user = this.opts.store.getHubUser(userId);
|
|
355
392
|
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
356
|
-
const
|
|
357
|
-
|
|
393
|
+
const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
394
|
+
const newToken = issueUserToken(
|
|
395
|
+
{ userId: user.id, username: newUsername, role: user.role, status: user.status },
|
|
396
|
+
this.authSecret,
|
|
397
|
+
ttlMs,
|
|
398
|
+
);
|
|
399
|
+
this.userManager.approveUser(user.id, newToken);
|
|
400
|
+
const updated = this.opts.store.getHubUser(userId)!;
|
|
401
|
+
const finalUser = { ...updated, username: newUsername };
|
|
402
|
+
this.opts.store.upsertHubUser(finalUser);
|
|
358
403
|
this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
|
|
359
404
|
return this.json(res, 200, { ok: true, username: newUsername });
|
|
360
405
|
}
|
|
@@ -365,6 +410,7 @@ export class HubServer {
|
|
|
365
410
|
const userId = String(body?.userId || "");
|
|
366
411
|
if (!userId) return this.json(res, 400, { error: "missing_user_id" });
|
|
367
412
|
if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
|
|
413
|
+
if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
|
|
368
414
|
const cleanResources = body?.cleanResources === true;
|
|
369
415
|
const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
|
|
370
416
|
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
@@ -376,6 +422,7 @@ export class HubServer {
|
|
|
376
422
|
const body = await this.readJson(req);
|
|
377
423
|
if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
|
|
378
424
|
const task = { ...body.task, sourceUserId: auth.userId };
|
|
425
|
+
const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
|
|
379
426
|
this.opts.store.upsertHubTask(task);
|
|
380
427
|
const chunks = Array.isArray(body.chunks) ? body.chunks : [];
|
|
381
428
|
const chunkIds: string[] = [];
|
|
@@ -383,16 +430,23 @@ export class HubServer {
|
|
|
383
430
|
this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
|
|
384
431
|
chunkIds.push(chunk.id);
|
|
385
432
|
}
|
|
386
|
-
// Async embedding: don't block the response
|
|
387
433
|
if (this.opts.embedder && chunkIds.length > 0) {
|
|
388
434
|
this.embedChunksAsync(chunkIds, chunks);
|
|
389
435
|
}
|
|
436
|
+
if (!existingTask) {
|
|
437
|
+
this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
|
|
438
|
+
}
|
|
390
439
|
return this.json(res, 200, { ok: true, chunks: chunkIds.length });
|
|
391
440
|
}
|
|
392
441
|
|
|
393
442
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
|
|
394
443
|
const body = await this.readJson(req);
|
|
395
|
-
|
|
444
|
+
const srcTaskId = String(body.sourceTaskId);
|
|
445
|
+
const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
|
|
446
|
+
this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
|
|
447
|
+
if (existing) {
|
|
448
|
+
this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
|
|
449
|
+
}
|
|
396
450
|
return this.json(res, 200, { ok: true });
|
|
397
451
|
}
|
|
398
452
|
|
|
@@ -423,6 +477,9 @@ export class HubServer {
|
|
|
423
477
|
if (this.opts.embedder) {
|
|
424
478
|
this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
|
|
425
479
|
}
|
|
480
|
+
if (!existing) {
|
|
481
|
+
this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
|
|
482
|
+
}
|
|
426
483
|
return this.json(res, 200, { ok: true, memoryId, visibility });
|
|
427
484
|
}
|
|
428
485
|
|
|
@@ -430,7 +487,11 @@ export class HubServer {
|
|
|
430
487
|
const body = await this.readJson(req);
|
|
431
488
|
const sourceChunkId = String(body?.sourceChunkId || "");
|
|
432
489
|
if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
|
|
490
|
+
const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
|
|
433
491
|
this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
|
|
492
|
+
if (existing) {
|
|
493
|
+
this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
|
|
494
|
+
}
|
|
434
495
|
return this.json(res, 200, { ok: true });
|
|
435
496
|
}
|
|
436
497
|
|
|
@@ -580,6 +641,9 @@ export class HubServer {
|
|
|
580
641
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
581
642
|
updatedAt: Date.now(),
|
|
582
643
|
});
|
|
644
|
+
if (!existing) {
|
|
645
|
+
this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
|
|
646
|
+
}
|
|
583
647
|
return this.json(res, 200, { ok: true, skillId, visibility });
|
|
584
648
|
}
|
|
585
649
|
|
|
@@ -602,7 +666,12 @@ export class HubServer {
|
|
|
602
666
|
|
|
603
667
|
if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
|
|
604
668
|
const body = await this.readJson(req);
|
|
605
|
-
|
|
669
|
+
const srcSkillId = String(body?.sourceSkillId || "");
|
|
670
|
+
const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
|
|
671
|
+
this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
|
|
672
|
+
if (existing) {
|
|
673
|
+
this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
|
|
674
|
+
}
|
|
606
675
|
return this.json(res, 200, { ok: true });
|
|
607
676
|
}
|
|
608
677
|
|
|
@@ -614,6 +683,38 @@ export class HubServer {
|
|
|
614
683
|
return this.json(res, 200, { tasks });
|
|
615
684
|
}
|
|
616
685
|
|
|
686
|
+
const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
|
|
687
|
+
if (hubTaskDetailMatch) {
|
|
688
|
+
const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
|
|
689
|
+
const task = this.opts.store.getHubTaskById(taskId);
|
|
690
|
+
if (!task) return this.json(res, 404, { error: "not_found" });
|
|
691
|
+
const chunks = this.opts.store.listHubChunksByTaskId(taskId);
|
|
692
|
+
return this.json(res, 200, {
|
|
693
|
+
id: task.id, title: task.title, summary: task.summary,
|
|
694
|
+
startedAt: task.createdAt, endedAt: task.updatedAt,
|
|
695
|
+
chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
|
|
700
|
+
if (hubSkillDetailMatch) {
|
|
701
|
+
const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
|
|
702
|
+
const skill = this.opts.store.getHubSkillById(skillId);
|
|
703
|
+
if (!skill) return this.json(res, 404, { error: "not_found" });
|
|
704
|
+
let files: Array<{ path: string; type: string; size: number }> = [];
|
|
705
|
+
try {
|
|
706
|
+
const bundle = JSON.parse(skill.bundle || "{}");
|
|
707
|
+
if (Array.isArray(bundle.files)) {
|
|
708
|
+
files = bundle.files.map((f: any) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
|
|
709
|
+
}
|
|
710
|
+
} catch { /* ignore parse error */ }
|
|
711
|
+
return this.json(res, 200, {
|
|
712
|
+
skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
|
|
713
|
+
files,
|
|
714
|
+
versions: [],
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
617
718
|
const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
|
|
618
719
|
if (adminTaskDeleteMatch) {
|
|
619
720
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
@@ -706,6 +807,65 @@ export class HubServer {
|
|
|
706
807
|
return this.json(res, 404, { error: "not_found" });
|
|
707
808
|
}
|
|
708
809
|
|
|
810
|
+
private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
|
|
811
|
+
try {
|
|
812
|
+
const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
|
|
813
|
+
for (const admin of admins) {
|
|
814
|
+
if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
|
|
818
|
+
}
|
|
819
|
+
} catch { /* best-effort */ }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private initOnlineTracking(): void {
|
|
823
|
+
try {
|
|
824
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
825
|
+
const users = this.opts.store.listHubUsers("active");
|
|
826
|
+
const now = Date.now();
|
|
827
|
+
for (const u of users) {
|
|
828
|
+
if (u.id === ownerId) continue;
|
|
829
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
830
|
+
this.knownOnlineUsers.add(u.id);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} catch { /* best-effort */ }
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private checkOfflineUsers(): void {
|
|
837
|
+
try {
|
|
838
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
839
|
+
const users = this.opts.store.listHubUsers("active");
|
|
840
|
+
const now = Date.now();
|
|
841
|
+
const currentlyOnline = new Set<string>();
|
|
842
|
+
for (const u of users) {
|
|
843
|
+
if (u.id === ownerId) continue;
|
|
844
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
845
|
+
currentlyOnline.add(u.id);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
for (const uid of this.knownOnlineUsers) {
|
|
849
|
+
if (!currentlyOnline.has(uid)) {
|
|
850
|
+
const user = users.find(u => u.id === uid);
|
|
851
|
+
if (user) {
|
|
852
|
+
this.notifyAdmins("user_offline", "user", user.username, uid);
|
|
853
|
+
this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
for (const uid of currentlyOnline) {
|
|
858
|
+
if (!this.knownOnlineUsers.has(uid)) {
|
|
859
|
+
const user = users.find(u => u.id === uid);
|
|
860
|
+
if (user) {
|
|
861
|
+
this.notifyAdmins("user_online", "user", user.username, uid);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
this.knownOnlineUsers = currentlyOnline;
|
|
866
|
+
} catch { /* best-effort */ }
|
|
867
|
+
}
|
|
868
|
+
|
|
709
869
|
private authenticate(req: http.IncomingMessage) {
|
|
710
870
|
const header = req.headers.authorization;
|
|
711
871
|
if (!header || !header.startsWith("Bearer ")) return null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import type { SummarizerConfig, Logger, OpenClawAPI } from "../../types";
|
|
3
|
+
import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types";
|
|
4
4
|
import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai";
|
|
5
5
|
import type { FilterResult, DedupResult } from "./openai";
|
|
6
6
|
export type { FilterResult, DedupResult } from "./openai";
|
|
@@ -8,6 +8,40 @@ import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic,
|
|
|
8
8
|
import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini";
|
|
9
9
|
import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Detect provider type from provider key name or base URL.
|
|
13
|
+
*/
|
|
14
|
+
function detectProvider(
|
|
15
|
+
providerKey: string | undefined,
|
|
16
|
+
baseUrl: string,
|
|
17
|
+
): SummaryProvider {
|
|
18
|
+
const key = providerKey?.toLowerCase() ?? "";
|
|
19
|
+
const url = baseUrl.toLowerCase();
|
|
20
|
+
if (key.includes("anthropic") || url.includes("anthropic")) return "anthropic";
|
|
21
|
+
if (key.includes("gemini") || url.includes("generativelanguage.googleapis.com")) {
|
|
22
|
+
return "gemini";
|
|
23
|
+
}
|
|
24
|
+
if (key.includes("bedrock") || url.includes("bedrock")) return "bedrock";
|
|
25
|
+
return "openai_compatible";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return the correct endpoint for a given provider and base URL.
|
|
30
|
+
*/
|
|
31
|
+
function normalizeEndpointForProvider(
|
|
32
|
+
provider: SummaryProvider,
|
|
33
|
+
baseUrl: string,
|
|
34
|
+
): string {
|
|
35
|
+
const stripped = baseUrl.replace(/\/+$/, "");
|
|
36
|
+
if (provider === "anthropic") {
|
|
37
|
+
if (stripped.endsWith("/v1/messages")) return stripped;
|
|
38
|
+
return `${stripped}/v1/messages`;
|
|
39
|
+
}
|
|
40
|
+
if (stripped.endsWith("/chat/completions")) return stripped;
|
|
41
|
+
if (stripped.endsWith("/completions")) return stripped;
|
|
42
|
+
return `${stripped}/chat/completions`;
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
/**
|
|
12
46
|
* Build a SummarizerConfig from OpenClaw's native model configuration (openclaw.json).
|
|
13
47
|
* This serves as the final fallback when both strongCfg and plugin summarizer fail or are absent.
|
|
@@ -15,7 +49,8 @@ import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judge
|
|
|
15
49
|
function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
|
|
16
50
|
try {
|
|
17
51
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
18
|
-
const
|
|
52
|
+
const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
|
|
53
|
+
const cfgPath = path.join(ocHome, "openclaw.json");
|
|
19
54
|
if (!fs.existsSync(cfgPath)) return undefined;
|
|
20
55
|
|
|
21
56
|
const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
@@ -36,13 +71,12 @@ function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
|
|
|
36
71
|
const apiKey: string | undefined = providerCfg.apiKey;
|
|
37
72
|
if (!baseUrl || !apiKey) return undefined;
|
|
38
73
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
: baseUrl.replace(/\/+$/, "") + "/chat/completions";
|
|
74
|
+
const provider = detectProvider(providerKey, baseUrl);
|
|
75
|
+
const endpoint = normalizeEndpointForProvider(provider, baseUrl);
|
|
42
76
|
|
|
43
|
-
log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl}`);
|
|
77
|
+
log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl} (${provider})`);
|
|
44
78
|
return {
|
|
45
|
-
provider
|
|
79
|
+
provider,
|
|
46
80
|
endpoint,
|
|
47
81
|
apiKey,
|
|
48
82
|
model: modelId,
|