@echomem/echo-memory-cloud-openclaw-plugin 0.1.0 → 0.1.1
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/clawdbot.plugin.json +1 -1
- package/index.js +254 -216
- package/lib/local-server.js +98 -24
- package/lib/local-ui/src/App.jsx +2 -2
- package/lib/local-ui/src/sync/api.js +10 -9
- package/lib/sync.js +27 -8
- package/moltbot.plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/clawdbot.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "echo-memory-cloud-openclaw-plugin",
|
|
3
3
|
"name": "Echo Memory Cloud OpenClaw Plugin",
|
|
4
4
|
"description": "Sync OpenClaw local markdown memory files to Echo cloud",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.1",
|
|
6
6
|
"kind": "lifecycle",
|
|
7
7
|
"main": "./index.js",
|
|
8
8
|
"configSchema": {
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { buildConfig, getEnvFileStatus } from "./lib/config.js";
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildConfig, getEnvFileStatus } from "./lib/config.js";
|
|
4
4
|
import { createApiClient } from "./lib/api-client.js";
|
|
5
5
|
import { formatSearchResultsText } from "./lib/echo-memory-search.js";
|
|
6
6
|
import {
|
|
@@ -46,13 +46,22 @@ export default {
|
|
|
46
46
|
register(api) {
|
|
47
47
|
const cfg = buildConfig(api.pluginConfig);
|
|
48
48
|
const client = createApiClient(cfg);
|
|
49
|
+
const workspaceDir = path.resolve(path.dirname(cfg.memoryDir), "..");
|
|
50
|
+
const openclawHome = path.resolve(workspaceDir, "..");
|
|
51
|
+
const fallbackStateDir = path.join(openclawHome, "state", "plugins", "echo-memory-cloud-openclaw-plugin");
|
|
49
52
|
const syncRunner = createSyncRunner({
|
|
50
53
|
api,
|
|
51
54
|
cfg,
|
|
52
55
|
client,
|
|
56
|
+
fallbackStateDir,
|
|
53
57
|
});
|
|
54
|
-
const workspaceDir = path.resolve(path.dirname(cfg.memoryDir), "..");
|
|
55
58
|
let startupBrowserOpenAttempted = false;
|
|
59
|
+
let backgroundStarted = false;
|
|
60
|
+
let serviceStartObserved = false;
|
|
61
|
+
|
|
62
|
+
if (!workspaceDir || workspaceDir === "." || workspaceDir === path.sep) {
|
|
63
|
+
api.logger?.warn?.("[echo-memory] workspace resolution looks unusual; compatibility fallback may be limited");
|
|
64
|
+
}
|
|
56
65
|
|
|
57
66
|
async function ensureLocalUi({ openInBrowser = false, trigger = "manual" } = {}) {
|
|
58
67
|
const url = await startLocalServer(workspaceDir, {
|
|
@@ -77,223 +86,252 @@ export default {
|
|
|
77
86
|
return { url, openedInBrowser, openReason };
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
api.registerTool
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
89
|
+
if (typeof api.registerTool === "function") {
|
|
90
|
+
api.registerTool(createEchoMemorySearchTool(client));
|
|
91
|
+
api.registerTool(createEchoMemoryOnboardTool(cfg, resolveCommandLabel("slack")));
|
|
92
|
+
api.registerTool(createEchoMemoryGraphTool(client, cfg));
|
|
93
|
+
api.registerTool(createEchoMemoryLocalUiTool({
|
|
94
|
+
getLocalUiUrl: ensureLocalUi,
|
|
95
|
+
commandLabel: resolveCommandLabel("slack"),
|
|
96
|
+
}));
|
|
97
|
+
api.registerTool(createEchoMemoryStatusTool(client, syncRunner));
|
|
98
|
+
api.registerTool(createEchoMemorySyncTool(client, syncRunner));
|
|
99
|
+
}
|
|
100
|
+
if (typeof api.on === "function") {
|
|
101
|
+
api.on("before_prompt_build", (_event, _ctx) => {
|
|
102
|
+
return {
|
|
103
|
+
appendSystemContext: [
|
|
104
|
+
"EchoMem cloud retrieval is available through the `echo_memory_search` tool.",
|
|
105
|
+
"Echo Memory setup and usage guidance is available through the `echo_memory_onboard` tool.",
|
|
106
|
+
"Echo memory graph links are available through the `echo_memory_graph_link` tool.",
|
|
107
|
+
"Echo memory local workspace UI links are available through the `echo_memory_local_ui` tool.",
|
|
108
|
+
"Echo sync inspection is available through the `echo_memory_status` tool.",
|
|
109
|
+
"Echo markdown-to-cloud sync is available through the `echo_memory_sync` tool.",
|
|
110
|
+
"Use it when the conversation asks about prior facts, plans, decisions, dates, preferences, people, or when memory context would improve accuracy.",
|
|
111
|
+
"Prefer it before answering memory-dependent questions instead of guessing.",
|
|
112
|
+
"Use `echo_memory_onboard` when the user asks how to install, set up, configure, authenticate, or use the plugin, or asks about signup, API keys, commands, graph access, or troubleshooting.",
|
|
113
|
+
"Treat any question about becoming a new EchoMemory user, signing up, creating an account, receiving OTP, referral code, API key creation, or configuring the plugin as an onboarding question for `echo_memory_onboard`.",
|
|
114
|
+
"Do not answer Echo Memory signup, account setup, or plugin setup from generic prior knowledge. Call `echo_memory_onboard` so the OpenClaw-specific signup URL, OTP step, referral code, API key flow, command names, and config details stay exact.",
|
|
115
|
+
"There is only one onboarding path. Do not try to choose between signup/setup onboarding variants; call `echo_memory_onboard` and return the full authoritative guide.",
|
|
116
|
+
"Use `echo_memory_graph_link` when the user explicitly asks for the memory graph, cloud graph, graph view, public memories page, or an iditor.com page.",
|
|
117
|
+
"Use `echo_memory_local_ui` when the user asks to open, view, browse, launch, or get the URL for local memories, markdown files, the workspace viewer, the local UI, or the localhost view.",
|
|
118
|
+
"Treat `/echo-memory view` as the local localhost markdown viewer command.",
|
|
119
|
+
"Do not confuse the localhost local workspace UI with the cloud memory graph. The local UI shows local markdown files directly, while graph links open iditor.com pages.",
|
|
120
|
+
"If the user says 'view my memories' or similar without saying graph/iditor/public, prefer the local workspace UI.",
|
|
121
|
+
"If the user asks to open the local workspace UI, request `openInBrowser: true` and include the returned localhost URL directly in the reply.",
|
|
122
|
+
"Use `visibility: private` for the user's personal memory graph login page and `visibility: public` for the shared public memories page at iditor.com/memories.",
|
|
123
|
+
"Private graph access from OpenClaw intentionally requires a fresh login at iditor.com/login?next=/memory-graph instead of an auto-login bridge link.",
|
|
124
|
+
"When providing a graph link, include the returned URL directly in the Slack reply.",
|
|
125
|
+
"Use `echo_memory_status` when the user asks about sync health, import progress, last sync, recent imports, or whether Echo memory is working.",
|
|
126
|
+
"Use `echo_memory_sync` when the user explicitly asks to sync, refresh, import, upload, or push local markdown memories into Echo cloud.",
|
|
127
|
+
"If the user reports permission, privacy, authorization, or tool-access restrictions while using Echo Memory in OpenClaw, tell them to set `tools.profile` to `full` in `~/.openclaw/openclaw.json` and restart the gateway.",
|
|
128
|
+
].join("\n"),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
119
132
|
|
|
120
133
|
const envStatus = getEnvFileStatus();
|
|
121
|
-
if (!envStatus.found) {
|
|
122
|
-
api.logger?.warn?.(
|
|
123
|
-
`[echo-memory] No .env file found in ${envStatus.searchPaths.join(", ")}. Using plugin config or process env.`,
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
api.registerService({
|
|
128
|
-
id: "echo-memory-cloud-openclaw-sync",
|
|
129
|
-
start: async (ctx) => {
|
|
130
|
-
await syncRunner.initialize(ctx.stateDir);
|
|
134
|
+
if (!envStatus.found) {
|
|
135
|
+
api.logger?.warn?.(
|
|
136
|
+
`[echo-memory] No .env file found in ${envStatus.searchPaths.join(", ")}. Using plugin config or process env.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
140
|
+
async function startBackgroundFeatures({ stateDir = null, trigger = "service" } = {}) {
|
|
141
|
+
if (backgroundStarted) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
backgroundStarted = true;
|
|
145
|
+
await syncRunner.initialize(stateDir || fallbackStateDir);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const shouldOpenBrowser = cfg.localUiAutoOpenOnGatewayStart && !startupBrowserOpenAttempted;
|
|
149
|
+
const { url, openedInBrowser, openReason } = await ensureLocalUi({
|
|
150
|
+
openInBrowser: shouldOpenBrowser,
|
|
151
|
+
trigger: trigger === "service" ? "gateway-start" : trigger,
|
|
152
|
+
});
|
|
153
|
+
api.logger?.info?.(`[echo-memory] Local workspace viewer: ${url}`);
|
|
154
|
+
if (shouldOpenBrowser) {
|
|
155
|
+
startupBrowserOpenAttempted = true;
|
|
156
|
+
if (openedInBrowser) {
|
|
157
|
+
api.logger?.info?.("[echo-memory] Opened local workspace viewer in the default browser");
|
|
158
|
+
} else {
|
|
159
|
+
api.logger?.info?.(`[echo-memory] Skipped browser auto-open (${openReason})`);
|
|
146
160
|
}
|
|
147
|
-
} catch (error) {
|
|
148
|
-
api.logger?.warn?.(`[echo-memory] local server failed: ${String(error?.message ?? error)}`);
|
|
149
161
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
162
|
+
} catch (error) {
|
|
163
|
+
api.logger?.warn?.(`[echo-memory] local server failed: ${String(error?.message ?? error)}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!cfg.autoSync) {
|
|
167
|
+
api.logger?.info?.("[echo-memory] autoSync disabled");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await syncRunner.runSync(trigger === "service" ? "startup" : trigger).catch((error) => {
|
|
172
|
+
api.logger?.warn?.(`[echo-memory] startup sync failed: ${String(error?.message ?? error)}`);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
syncRunner.startInterval();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (typeof api.registerService === "function") {
|
|
179
|
+
api.registerService({
|
|
180
|
+
id: "echo-memory-cloud-openclaw-sync",
|
|
181
|
+
start: async (ctx) => {
|
|
182
|
+
serviceStartObserved = true;
|
|
183
|
+
await startBackgroundFeatures({ stateDir: ctx?.stateDir || null, trigger: "service" });
|
|
184
|
+
},
|
|
185
|
+
stop: async () => {
|
|
186
|
+
syncRunner.stopInterval();
|
|
187
|
+
stopLocalServer();
|
|
188
|
+
backgroundStarted = false;
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Compatibility fallback for older hosts that discover the plugin but do not
|
|
194
|
+
// reliably auto-start registered background services.
|
|
195
|
+
queueMicrotask(() => {
|
|
196
|
+
if (serviceStartObserved) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
startBackgroundFeatures({ stateDir: fallbackStateDir, trigger: "compat-startup" }).catch((error) => {
|
|
200
|
+
api.logger?.warn?.(`[echo-memory] compatibility startup failed: ${String(error?.message ?? error)}`);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (typeof api.registerCommand === "function") {
|
|
205
|
+
api.registerCommand({
|
|
206
|
+
name: "echo-memory",
|
|
207
|
+
description: "Sync and search OpenClaw markdown memories through Echo cloud.",
|
|
208
|
+
acceptsArgs: true,
|
|
209
|
+
handler: async (ctx) => {
|
|
210
|
+
const { action, actionArgs } = parseCommandArgs(ctx.args);
|
|
211
|
+
const commandLabel = resolveCommandLabel(ctx.channel);
|
|
212
|
+
|
|
213
|
+
if (action === "view") {
|
|
214
|
+
const { url, openedInBrowser } = await ensureLocalUi({
|
|
215
|
+
openInBrowser: true,
|
|
216
|
+
trigger: "command",
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
text: [
|
|
220
|
+
`Open your workspace: ${url}`,
|
|
221
|
+
openedInBrowser
|
|
222
|
+
? "The default browser was opened on this machine."
|
|
223
|
+
: "Open the URL manually if the browser did not launch automatically.",
|
|
224
|
+
"",
|
|
225
|
+
"This local UI reads your markdown files directly on localhost. All files stay local until you choose to sync.",
|
|
226
|
+
`For EchoMemory account signup or plugin onboarding, run ${commandLabel} onboard.`,
|
|
227
|
+
].join("\n"),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (action === "help") {
|
|
232
|
+
return {
|
|
233
|
+
text: [
|
|
234
|
+
"Echo Memory commands:",
|
|
235
|
+
"",
|
|
236
|
+
`${commandLabel} onboard`,
|
|
237
|
+
`${commandLabel} view`,
|
|
238
|
+
`${commandLabel} status`,
|
|
239
|
+
`${commandLabel} search <query>`,
|
|
240
|
+
`${commandLabel} graph`,
|
|
241
|
+
`${commandLabel} graph public`,
|
|
242
|
+
`${commandLabel} sync`,
|
|
243
|
+
`${commandLabel} whoami`,
|
|
244
|
+
`${commandLabel} help`,
|
|
245
|
+
].join("\n"),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (action === "whoami") {
|
|
250
|
+
const whoami = await client.whoami();
|
|
251
|
+
return {
|
|
252
|
+
text: [
|
|
253
|
+
"Echo identity:",
|
|
254
|
+
`- user_id: ${whoami.user_id}`,
|
|
255
|
+
`- token_type: ${whoami.token_type}`,
|
|
256
|
+
`- scopes: ${Array.isArray(whoami.scopes) ? whoami.scopes.join(", ") : "(none)"}`,
|
|
257
|
+
].join("\n"),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (action === "onboard") {
|
|
262
|
+
const guide = buildOnboardingText({
|
|
263
|
+
commandLabel,
|
|
264
|
+
cfg,
|
|
265
|
+
});
|
|
266
|
+
return { text: guide.text };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (action === "sync") {
|
|
270
|
+
const result = await syncRunner.runSync("manual");
|
|
271
|
+
return { text: formatStatusText(result) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (action === "search") {
|
|
275
|
+
if (!actionArgs) {
|
|
276
|
+
return {
|
|
277
|
+
text: [
|
|
278
|
+
"Missing search query.",
|
|
279
|
+
`Usage: ${commandLabel} search <query>`,
|
|
280
|
+
].join("\n"),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const payload = await client.searchMemories({
|
|
285
|
+
query: actionArgs,
|
|
286
|
+
similarityThreshold: 0.3,
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
text: formatSearchResultsText(actionArgs, payload),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (action === "graph") {
|
|
294
|
+
const graphMode = String(actionArgs || "").trim().toLowerCase();
|
|
295
|
+
|
|
296
|
+
if (!graphMode || graphMode === "private") {
|
|
297
|
+
const url = buildPrivateGraphLoginUrl(cfg);
|
|
298
|
+
return {
|
|
299
|
+
text: [
|
|
300
|
+
"Log in again to open your private memory graph:",
|
|
301
|
+
url,
|
|
302
|
+
"This intentionally requires a fresh web login for security.",
|
|
303
|
+
].filter(Boolean).join("\n"),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (graphMode === "public") {
|
|
308
|
+
return {
|
|
309
|
+
text: [
|
|
310
|
+
"Open the public memory page:",
|
|
311
|
+
`${cfg.webBaseUrl}/memories`,
|
|
312
|
+
].join("\n"),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
text: [
|
|
318
|
+
"Unknown graph mode.",
|
|
319
|
+
`Usage: ${commandLabel} graph`,
|
|
320
|
+
`Usage: ${commandLabel} graph public`,
|
|
321
|
+
].join("\n"),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const [localState, remoteStatus] = await Promise.all([
|
|
326
|
+
readLastSyncState(syncRunner.getStatePath()),
|
|
327
|
+
client.getImportStatus().catch(() => null),
|
|
328
|
+
]);
|
|
175
329
|
|
|
176
|
-
if (action === "view") {
|
|
177
|
-
const { url, openedInBrowser } = await ensureLocalUi({
|
|
178
|
-
openInBrowser: true,
|
|
179
|
-
trigger: "command",
|
|
180
|
-
});
|
|
181
330
|
return {
|
|
182
|
-
text:
|
|
183
|
-
`Open your workspace: ${url}`,
|
|
184
|
-
openedInBrowser
|
|
185
|
-
? "The default browser was opened on this machine."
|
|
186
|
-
: "Open the URL manually if the browser did not launch automatically.",
|
|
187
|
-
"",
|
|
188
|
-
"This local UI reads your markdown files directly on localhost. All files stay local until you choose to sync.",
|
|
189
|
-
`For EchoMemory account signup or plugin onboarding, run ${commandLabel} onboard.`,
|
|
190
|
-
].join("\n"),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (action === "help") {
|
|
195
|
-
return {
|
|
196
|
-
text: [
|
|
197
|
-
"Echo Memory commands:",
|
|
198
|
-
"",
|
|
199
|
-
`${commandLabel} onboard`,
|
|
200
|
-
`${commandLabel} view`,
|
|
201
|
-
`${commandLabel} status`,
|
|
202
|
-
`${commandLabel} search <query>`,
|
|
203
|
-
`${commandLabel} graph`,
|
|
204
|
-
`${commandLabel} graph public`,
|
|
205
|
-
`${commandLabel} sync`,
|
|
206
|
-
`${commandLabel} whoami`,
|
|
207
|
-
`${commandLabel} help`,
|
|
208
|
-
].join("\n"),
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (action === "whoami") {
|
|
213
|
-
const whoami = await client.whoami();
|
|
214
|
-
return {
|
|
215
|
-
text: [
|
|
216
|
-
"Echo identity:",
|
|
217
|
-
`- user_id: ${whoami.user_id}`,
|
|
218
|
-
`- token_type: ${whoami.token_type}`,
|
|
219
|
-
`- scopes: ${Array.isArray(whoami.scopes) ? whoami.scopes.join(", ") : "(none)"}`,
|
|
220
|
-
].join("\n"),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (action === "onboard") {
|
|
225
|
-
const guide = buildOnboardingText({
|
|
226
|
-
commandLabel,
|
|
227
|
-
cfg,
|
|
228
|
-
});
|
|
229
|
-
return { text: guide.text };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (action === "sync") {
|
|
233
|
-
const result = await syncRunner.runSync("manual");
|
|
234
|
-
return { text: formatStatusText(result) };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (action === "search") {
|
|
238
|
-
if (!actionArgs) {
|
|
239
|
-
return {
|
|
240
|
-
text: [
|
|
241
|
-
"Missing search query.",
|
|
242
|
-
`Usage: ${commandLabel} search <query>`,
|
|
243
|
-
].join("\n"),
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const payload = await client.searchMemories({
|
|
248
|
-
query: actionArgs,
|
|
249
|
-
similarityThreshold: 0.3,
|
|
250
|
-
});
|
|
251
|
-
return {
|
|
252
|
-
text: formatSearchResultsText(actionArgs, payload),
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (action === "graph") {
|
|
257
|
-
const graphMode = String(actionArgs || "").trim().toLowerCase();
|
|
258
|
-
|
|
259
|
-
if (!graphMode || graphMode === "private") {
|
|
260
|
-
const url = buildPrivateGraphLoginUrl(cfg);
|
|
261
|
-
return {
|
|
262
|
-
text: [
|
|
263
|
-
"Log in again to open your private memory graph:",
|
|
264
|
-
url,
|
|
265
|
-
"This intentionally requires a fresh web login for security.",
|
|
266
|
-
].filter(Boolean).join("\n"),
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (graphMode === "public") {
|
|
271
|
-
return {
|
|
272
|
-
text: [
|
|
273
|
-
"Open the public memory page:",
|
|
274
|
-
`${cfg.webBaseUrl}/memories`,
|
|
275
|
-
].join("\n"),
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return {
|
|
280
|
-
text: [
|
|
281
|
-
"Unknown graph mode.",
|
|
282
|
-
`Usage: ${commandLabel} graph`,
|
|
283
|
-
`Usage: ${commandLabel} graph public`,
|
|
284
|
-
].join("\n"),
|
|
331
|
+
text: formatStatusText(localState, remoteStatus),
|
|
285
332
|
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
]);
|
|
292
|
-
|
|
293
|
-
return {
|
|
294
|
-
text: formatStatusText(localState, remoteStatus),
|
|
295
|
-
};
|
|
296
|
-
},
|
|
297
|
-
});
|
|
298
|
-
},
|
|
299
|
-
};
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
};
|
package/lib/local-server.js
CHANGED
|
@@ -255,21 +255,44 @@ export async function openUrlInDefaultBrowser(url, opts = {}) {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
function readBody(req) {
|
|
259
|
-
return new Promise((resolve, reject) => {
|
|
260
|
-
const chunks = [];
|
|
261
|
-
req.on("data", (c) => chunks.push(c));
|
|
262
|
-
req.on("end", () => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
function readBody(req) {
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
const chunks = [];
|
|
261
|
+
req.on("data", (c) => chunks.push(c));
|
|
262
|
+
req.on("end", () => {
|
|
263
|
+
const rawText = Buffer.concat(chunks).toString("utf8");
|
|
264
|
+
if (!rawText.trim()) {
|
|
265
|
+
resolve({
|
|
266
|
+
ok: true,
|
|
267
|
+
body: {},
|
|
268
|
+
rawText,
|
|
269
|
+
parseError: null,
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
resolve({
|
|
275
|
+
ok: true,
|
|
276
|
+
body: JSON.parse(rawText),
|
|
277
|
+
rawText,
|
|
278
|
+
parseError: null,
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
resolve({
|
|
282
|
+
ok: false,
|
|
283
|
+
body: null,
|
|
284
|
+
rawText,
|
|
285
|
+
parseError: String(error?.message ?? error),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
req.on("error", reject);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
270
293
|
function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
271
294
|
const normalizedBase = path.resolve(workspaceDir) + path.sep;
|
|
272
|
-
const { apiClient, syncRunner, cfg, fileWatcher } = opts;
|
|
295
|
+
const { apiClient, syncRunner, cfg, fileWatcher, logger } = opts;
|
|
273
296
|
const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
|
|
274
297
|
|
|
275
298
|
return async function handler(req, res) {
|
|
@@ -420,7 +443,20 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
420
443
|
|
|
421
444
|
if (url.pathname === "/api/setup-config" && req.method === "POST") {
|
|
422
445
|
try {
|
|
423
|
-
const
|
|
446
|
+
const bodyResult = await readBody(req);
|
|
447
|
+
if (!bodyResult.ok) {
|
|
448
|
+
logger?.warn?.(
|
|
449
|
+
`[echo-memory] invalid JSON for ${url.pathname}: ${bodyResult.parseError}; body=${JSON.stringify(bodyResult.rawText.slice(0, 400))}`,
|
|
450
|
+
);
|
|
451
|
+
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
|
452
|
+
res.end(JSON.stringify({
|
|
453
|
+
ok: false,
|
|
454
|
+
error: "Invalid JSON body",
|
|
455
|
+
details: bodyResult.parseError,
|
|
456
|
+
}));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const body = bodyResult.body;
|
|
424
460
|
const payload = {
|
|
425
461
|
ECHOMEM_API_KEY: typeof body.apiKey === "string" ? body.apiKey : "",
|
|
426
462
|
ECHOMEM_MEMORY_DIR: typeof body.memoryDir === "string" ? body.memoryDir : "",
|
|
@@ -591,30 +627,68 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
591
627
|
return;
|
|
592
628
|
}
|
|
593
629
|
|
|
594
|
-
if (url.pathname === "/api/sync-selected" && req.method === "POST") {
|
|
630
|
+
if (url.pathname === "/api/sync-selected" && req.method === "POST") {
|
|
595
631
|
if (!syncRunner) {
|
|
596
632
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
597
633
|
res.end(JSON.stringify({ error: "Sync not available" }));
|
|
598
634
|
return;
|
|
599
635
|
}
|
|
600
636
|
try {
|
|
601
|
-
const
|
|
637
|
+
const bodyResult = await readBody(req);
|
|
638
|
+
if (!bodyResult.ok) {
|
|
639
|
+
logger?.warn?.(
|
|
640
|
+
`[echo-memory] invalid JSON for ${url.pathname}: ${bodyResult.parseError}; body=${JSON.stringify(bodyResult.rawText.slice(0, 400))}`,
|
|
641
|
+
);
|
|
642
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
643
|
+
res.end(JSON.stringify({
|
|
644
|
+
error: "Invalid JSON body for sync-selected",
|
|
645
|
+
details: bodyResult.parseError,
|
|
646
|
+
receivedBodyPreview: bodyResult.rawText.slice(0, 400),
|
|
647
|
+
}));
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const body = bodyResult.body;
|
|
602
652
|
const relativePaths = body.paths;
|
|
603
|
-
if (!Array.isArray(relativePaths) || relativePaths.length === 0) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
653
|
+
if (!Array.isArray(relativePaths) || relativePaths.length === 0) {
|
|
654
|
+
logger?.warn?.(
|
|
655
|
+
`[echo-memory] invalid sync-selected payload: expected non-empty paths array; body=${JSON.stringify(body).slice(0, 400)}`,
|
|
656
|
+
);
|
|
657
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
658
|
+
res.end(JSON.stringify({
|
|
659
|
+
error: "paths array required",
|
|
660
|
+
details: "Expected request body like {\"paths\":[\"memory/2026-03-17.md\"]}",
|
|
661
|
+
receivedBody: body,
|
|
662
|
+
}));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
609
666
|
const filterPaths = new Set();
|
|
667
|
+
const invalidPaths = [];
|
|
610
668
|
for (const rp of relativePaths) {
|
|
669
|
+
if (typeof rp !== "string" || !rp.trim()) {
|
|
670
|
+
invalidPaths.push(rp);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
611
673
|
const absPath = path.resolve(workspaceDir, rp);
|
|
612
|
-
if (!absPath.startsWith(path.resolve(workspaceDir) + path.sep) || !absPath.endsWith(".md"))
|
|
674
|
+
if (!absPath.startsWith(path.resolve(workspaceDir) + path.sep) || !absPath.endsWith(".md")) {
|
|
675
|
+
invalidPaths.push(rp);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
613
678
|
filterPaths.add(absPath);
|
|
614
679
|
}
|
|
615
680
|
|
|
616
681
|
if (filterPaths.size === 0) {
|
|
617
|
-
|
|
682
|
+
logger?.warn?.(
|
|
683
|
+
`[echo-memory] sync-selected contained no valid markdown paths; requested=${JSON.stringify(relativePaths).slice(0, 400)}`,
|
|
684
|
+
);
|
|
685
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
686
|
+
res.end(JSON.stringify({
|
|
687
|
+
error: "No valid markdown paths to sync",
|
|
688
|
+
details: "All provided paths were outside the workspace or did not end with .md",
|
|
689
|
+
invalidPaths,
|
|
690
|
+
requestedPaths: relativePaths,
|
|
691
|
+
}));
|
|
618
692
|
return;
|
|
619
693
|
}
|
|
620
694
|
const result = await syncRunner.runSync("local-ui-selected", filterPaths);
|
package/lib/local-ui/src/App.jsx
CHANGED
|
@@ -335,8 +335,8 @@ export default function App() {
|
|
|
335
335
|
setSyncResult({ ok: true, msg: parts.join(' | ') || 'Sync complete' });
|
|
336
336
|
loadSyncStatus();
|
|
337
337
|
loadBackendSources();
|
|
338
|
-
} catch {
|
|
339
|
-
setSyncResult({ ok: false, msg: 'Sync failed' });
|
|
338
|
+
} catch (error) {
|
|
339
|
+
setSyncResult({ ok: false, msg: String(error?.message || 'Sync failed') });
|
|
340
340
|
} finally {
|
|
341
341
|
setSyncing(false);
|
|
342
342
|
}
|
|
@@ -68,15 +68,16 @@ export async function fetchBackendSources() {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export async function triggerSyncSelected(paths) {
|
|
72
|
-
const res = await fetch('/api/sync-selected', {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/json' },
|
|
75
|
-
body: JSON.stringify({ paths }),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
export async function triggerSyncSelected(paths) {
|
|
72
|
+
const res = await fetch('/api/sync-selected', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ paths }),
|
|
76
|
+
});
|
|
77
|
+
const data = await res.json().catch(() => ({}));
|
|
78
|
+
if (!res.ok) throw new Error(data?.details || data?.error || `Sync failed: HTTP ${res.status}`);
|
|
79
|
+
return data;
|
|
80
|
+
}
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* Fetch content of a single markdown file.
|
package/lib/sync.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import { scanOpenClawMemoryDir } from "./openclaw-memory-scan.js";
|
|
2
|
-
import { resolveStatePath, readLastSyncState, writeLastSyncState } from "./state.js";
|
|
1
|
+
import { scanOpenClawMemoryDir } from "./openclaw-memory-scan.js";
|
|
2
|
+
import { resolveStatePath, readLastSyncState, writeLastSyncState } from "./state.js";
|
|
3
|
+
|
|
4
|
+
function resolveRuntimeStateDir(api, fallbackStateDir = null) {
|
|
5
|
+
const runtimeStateDir = api?.runtime?.state?.resolveStateDir?.();
|
|
6
|
+
if (runtimeStateDir) {
|
|
7
|
+
return runtimeStateDir;
|
|
8
|
+
}
|
|
9
|
+
return fallbackStateDir;
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
function chunk(items, size) {
|
|
5
13
|
const batches = [];
|
|
@@ -101,18 +109,29 @@ export function formatStatusText(localState, remoteStatus = null) {
|
|
|
101
109
|
return lines.join("\n");
|
|
102
110
|
}
|
|
103
111
|
|
|
104
|
-
export function createSyncRunner({ api, cfg, client }) {
|
|
112
|
+
export function createSyncRunner({ api, cfg, client, fallbackStateDir = null }) {
|
|
105
113
|
let intervalHandle = null;
|
|
106
114
|
let statePath = null;
|
|
107
115
|
let activeRun = null;
|
|
108
116
|
const progressListeners = new Set();
|
|
109
117
|
|
|
110
|
-
async function initialize(stateDir) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
async function initialize(stateDir) {
|
|
119
|
+
const resolvedStateDir = stateDir || resolveRuntimeStateDir(api, fallbackStateDir);
|
|
120
|
+
if (!resolvedStateDir) {
|
|
121
|
+
throw new Error("Echo memory state directory is unavailable");
|
|
122
|
+
}
|
|
123
|
+
statePath = resolveStatePath(resolvedStateDir);
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
function getStatePath() {
|
|
115
|
-
|
|
127
|
+
if (statePath) {
|
|
128
|
+
return statePath;
|
|
129
|
+
}
|
|
130
|
+
const resolvedStateDir = resolveRuntimeStateDir(api, fallbackStateDir);
|
|
131
|
+
if (!resolvedStateDir) {
|
|
132
|
+
throw new Error("Echo memory state directory is unavailable");
|
|
133
|
+
}
|
|
134
|
+
return resolveStatePath(resolvedStateDir);
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
function emitProgress(event) {
|
package/moltbot.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "echo-memory-cloud-openclaw-plugin",
|
|
3
3
|
"name": "Echo Memory Cloud OpenClaw Plugin",
|
|
4
4
|
"description": "Sync OpenClaw local markdown memory files to Echo cloud",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.1",
|
|
6
6
|
"kind": "lifecycle",
|
|
7
7
|
"main": "./index.js",
|
|
8
8
|
"configSchema": {
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "echo-memory-cloud-openclaw-plugin",
|
|
3
3
|
"name": "Echo Memory Cloud OpenClaw Plugin",
|
|
4
4
|
"description": "Sync OpenClaw local markdown memory files to Echo cloud",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.1",
|
|
6
6
|
"kind": "lifecycle",
|
|
7
7
|
"main": "./index.js",
|
|
8
8
|
"configSchema": {
|