@hyperspell/openclaw-hyperspell 0.12.0 → 0.13.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.
Files changed (50) hide show
  1. package/README.md +37 -1
  2. package/dist/client.js +341 -0
  3. package/dist/commands/setup.js +568 -0
  4. package/dist/commands/slash.js +159 -0
  5. package/dist/config.js +349 -0
  6. package/{graph/cron.ts → dist/graph/cron.js} +11 -15
  7. package/dist/graph/index.js +4 -0
  8. package/dist/graph/ops.js +221 -0
  9. package/dist/graph/state.js +68 -0
  10. package/dist/graph/tools.js +87 -0
  11. package/dist/hooks/auto-context.js +199 -0
  12. package/dist/hooks/auto-trace.js +143 -0
  13. package/dist/hooks/emotional-state.js +155 -0
  14. package/dist/hooks/memory-sync.js +54 -0
  15. package/dist/hooks/startup-orientation.js +236 -0
  16. package/dist/index.js +132 -0
  17. package/dist/lib/browser.js +29 -0
  18. package/dist/lib/sender.js +173 -0
  19. package/dist/lib/voice-id.js +22 -0
  20. package/dist/logger.js +32 -0
  21. package/dist/sync/markdown.js +151 -0
  22. package/dist/tools/remember.js +97 -0
  23. package/dist/tools/search.js +87 -0
  24. package/openclaw.plugin.json +25 -1
  25. package/package.json +7 -12
  26. package/client.ts +0 -566
  27. package/commands/setup.ts +0 -673
  28. package/commands/slash.ts +0 -198
  29. package/config.test.ts +0 -202
  30. package/config.ts +0 -497
  31. package/graph/index.ts +0 -5
  32. package/graph/ops.ts +0 -259
  33. package/graph/state.ts +0 -79
  34. package/graph/tools.ts +0 -117
  35. package/hooks/auto-context.ts +0 -272
  36. package/hooks/auto-trace.test.ts +0 -81
  37. package/hooks/auto-trace.ts +0 -197
  38. package/hooks/emotional-state.test.ts +0 -160
  39. package/hooks/emotional-state.ts +0 -179
  40. package/hooks/memory-sync.ts +0 -65
  41. package/index.ts +0 -152
  42. package/lib/browser.ts +0 -31
  43. package/lib/sender.test.ts +0 -234
  44. package/lib/sender.ts +0 -234
  45. package/lib/voice-id.ts +0 -39
  46. package/logger.ts +0 -41
  47. package/sync/markdown.ts +0 -186
  48. package/tools/remember.ts +0 -132
  49. package/tools/search.ts +0 -131
  50. package/types/openclaw.d.ts +0 -76
package/dist/index.js ADDED
@@ -0,0 +1,132 @@
1
+ import { HyperspellClient } from "./client.js";
2
+ import { registerCommands } from "./commands/slash.js";
3
+ import { registerCliCommands } from "./commands/setup.js";
4
+ import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.js";
5
+ import { buildAutoContextHandler } from "./hooks/auto-context.js";
6
+ import { buildAutoTraceHandler } from "./hooks/auto-trace.js";
7
+ import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler, buildEmotionalStateSessionCleanupHandler, buildEmotionalStateStoreHandler, } from "./hooks/emotional-state.js";
8
+ import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
9
+ import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
10
+ import { initLogger } from "./logger.js";
11
+ import { createRememberToolFactory } from "./tools/remember.js";
12
+ import { createSearchToolFactory } from "./tools/search.js";
13
+ import { registerNetworkTools } from "./graph/index.js";
14
+ export default {
15
+ id: "openclaw-hyperspell",
16
+ name: "Hyperspell",
17
+ description: "Hyperspell gives your Molty context and memory from all your existing data",
18
+ kind: "memory",
19
+ configSchema: hyperspellConfigSchema,
20
+ register(api) {
21
+ // Register CLI commands (openclaw openclaw-hyperspell setup|status|connect)
22
+ api.registerCli((ctx) => {
23
+ registerCliCommands(ctx.program, api.pluginConfig);
24
+ }, { commands: ["openclaw-hyperspell"] });
25
+ // Check if configured
26
+ const rawConfig = api.pluginConfig;
27
+ const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY;
28
+ if (!hasConfig) {
29
+ api.logger.info("hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'");
30
+ // Still register slash commands so they show up, but they'll return an error
31
+ api.registerCommand({
32
+ name: "getcontext",
33
+ description: "Search your memories for relevant context",
34
+ acceptsArgs: true,
35
+ requireAuth: false,
36
+ handler: async () => {
37
+ return {
38
+ text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
39
+ };
40
+ },
41
+ });
42
+ api.registerCommand({
43
+ name: "remember",
44
+ description: "Save something to memory",
45
+ acceptsArgs: true,
46
+ requireAuth: false,
47
+ handler: async () => {
48
+ return {
49
+ text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
50
+ };
51
+ },
52
+ });
53
+ api.registerCommand({
54
+ name: "sync",
55
+ description: "Sync memory/*.md files with Hyperspell",
56
+ acceptsArgs: false,
57
+ requireAuth: false,
58
+ handler: async () => {
59
+ return {
60
+ text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
61
+ };
62
+ },
63
+ });
64
+ return;
65
+ }
66
+ const cfg = parseConfig(api.pluginConfig);
67
+ initLogger(api.logger, cfg.debug);
68
+ const client = new HyperspellClient(cfg);
69
+ // Register AI tools (factory pattern for sender context)
70
+ api.registerTool(createSearchToolFactory(client, cfg), {
71
+ name: "hyperspell_search",
72
+ });
73
+ api.registerTool(createRememberToolFactory(client, cfg), {
74
+ name: "hyperspell_remember",
75
+ });
76
+ // Register emotional context hooks.
77
+ // - fetch: inject once per session on first turn (cached thereafter)
78
+ // - compaction: clear cache so the next turn re-injects after trim
79
+ // - session cleanup: drop Set entry on session end
80
+ // - store: extract new emotional state from the finished session
81
+ if (cfg.emotionalContext) {
82
+ api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
83
+ api.on("after_compaction", buildEmotionalStateCompactionHandler());
84
+ api.on("session_end", buildEmotionalStateSessionCleanupHandler());
85
+ api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
86
+ }
87
+ // Register auto-context hook
88
+ if (cfg.autoContext) {
89
+ const autoContextHandler = buildAutoContextHandler(client, cfg);
90
+ api.on("before_agent_start", autoContextHandler);
91
+ }
92
+ // Register startup-orientation hooks: recent-interactions + unfinished-loops
93
+ // injected once per session on first turn. Lifecycle mirrors emotional-context.
94
+ if (cfg.startupOrientation.enabled) {
95
+ api.on("before_agent_start", buildStartupOrientationHandler(client, cfg));
96
+ api.on("after_compaction", buildStartupOrientationCompactionHandler());
97
+ api.on("session_end", buildStartupOrientationSessionCleanupHandler());
98
+ }
99
+ // Register auto-trace hook (send conversations to Hyperspell on session end)
100
+ if (cfg.autoTrace.enabled) {
101
+ api.on("agent_end", buildAutoTraceHandler(client, cfg));
102
+ }
103
+ // Register memory sync hook
104
+ if (cfg.syncMemories) {
105
+ const fileSyncHandler = buildFileSyncHandler(client, cfg);
106
+ api.on("file_changed", fileSyncHandler);
107
+ }
108
+ // Register memory network tools
109
+ if (cfg.knowledgeGraph.enabled) {
110
+ registerNetworkTools(api, client, cfg);
111
+ }
112
+ // Register slash commands
113
+ registerCommands(api, client, cfg);
114
+ // Register service for lifecycle management
115
+ api.registerService({
116
+ id: "openclaw-hyperspell",
117
+ start: async () => {
118
+ api.logger.info("hyperspell: connected");
119
+ // Sync memories on startup if enabled
120
+ if (cfg.syncMemories) {
121
+ const workspaceDir = getWorkspaceDir();
122
+ await syncMemoriesOnStartup(client, workspaceDir, {
123
+ userId: cfg.multiUser?.sharedUserId,
124
+ });
125
+ }
126
+ },
127
+ stop: () => {
128
+ api.logger.info("hyperspell: stopped");
129
+ },
130
+ });
131
+ },
132
+ };
@@ -0,0 +1,29 @@
1
+ import { execFile } from "node:child_process";
2
+ import { platform } from "node:os";
3
+ export function openInBrowser(url) {
4
+ return new Promise((resolve, reject) => {
5
+ let file;
6
+ let args;
7
+ switch (platform()) {
8
+ case "darwin":
9
+ file = "open";
10
+ args = [url];
11
+ break;
12
+ case "win32":
13
+ file = "cmd";
14
+ args = ["/c", "start", "", url];
15
+ break;
16
+ default:
17
+ file = "xdg-open";
18
+ args = [url];
19
+ }
20
+ execFile(file, args, (error) => {
21
+ if (error) {
22
+ reject(error);
23
+ }
24
+ else {
25
+ resolve();
26
+ }
27
+ });
28
+ });
29
+ }
@@ -0,0 +1,173 @@
1
+ import { normalizeScope } from "../config.js";
2
+ import { log } from "../logger.js";
3
+ import { getVoiceIdentifier } from "./voice-id.js";
4
+ function matchFromSenderMap(ctx, cfg) {
5
+ const multiUser = cfg.multiUser;
6
+ if (!multiUser) {
7
+ return cfg.userId
8
+ ? { userId: cfg.userId, name: cfg.userId, resolved: true }
9
+ : undefined;
10
+ }
11
+ // Try direct senderId lookup (slash command contexts)
12
+ const senderId = ctx?.senderId ??
13
+ ctx?.requesterSenderId ??
14
+ undefined;
15
+ if (senderId && multiUser.senderMap[senderId]) {
16
+ const profile = multiUser.senderMap[senderId];
17
+ log.debug(`sender resolved via senderId: ${senderId} -> ${profile.userId}`);
18
+ return { ...profile, resolved: true };
19
+ }
20
+ // Try sessionKey substring matching (longest-first to avoid partial matches)
21
+ const sessionKey = ctx?.sessionKey;
22
+ if (sessionKey) {
23
+ const sortedEntries = Object.entries(multiUser.senderMap).sort(([a], [b]) => b.length - a.length);
24
+ for (const [handle, profile] of sortedEntries) {
25
+ if (sessionKey.includes(handle)) {
26
+ log.debug(`sender resolved via sessionKey: ${handle} -> ${profile.userId}`);
27
+ return { ...profile, resolved: true };
28
+ }
29
+ }
30
+ }
31
+ // Fallback: use sharedUserId for unknown senders
32
+ log.debug("sender unresolved, falling back to sharedUserId");
33
+ return {
34
+ userId: multiUser.sharedUserId,
35
+ name: multiUser.sharedUserId,
36
+ resolved: false,
37
+ };
38
+ }
39
+ /**
40
+ * Synchronous sender resolution from sessionKey + senderMap. Use this for
41
+ * hooks and tools that don't receive audio — the voice-ID path is skipped.
42
+ */
43
+ export function resolveUser(ctx, cfg) {
44
+ return matchFromSenderMap(ctx, cfg);
45
+ }
46
+ /**
47
+ * Asynchronous sender resolution. Checks voice-ID first (if enabled and
48
+ * audio is in ctx), then falls back to the synchronous path. Callers that
49
+ * never receive audio should use `resolveUser` to avoid unnecessary async
50
+ * overhead.
51
+ */
52
+ export async function resolveUserAsync(ctx, cfg) {
53
+ const voiceCfg = cfg.multiUser?.scoping?.voiceId;
54
+ const audio = ctx?.audio;
55
+ if (voiceCfg?.enabled && audio && cfg.multiUser) {
56
+ try {
57
+ const identifier = getVoiceIdentifier(cfg);
58
+ const result = await identifier.identify(audio);
59
+ const threshold = voiceCfg.confidenceThreshold ?? 0.7;
60
+ if (result && result.confidence >= threshold) {
61
+ // Find profile for the voice-identified userId
62
+ for (const profile of Object.values(cfg.multiUser.senderMap)) {
63
+ if (profile.userId === result.userId) {
64
+ log.debug(`sender resolved via voice: ${result.userId} (conf=${result.confidence.toFixed(2)})`);
65
+ return { ...profile, resolved: true };
66
+ }
67
+ }
68
+ }
69
+ }
70
+ catch (err) {
71
+ log.error("voice-id identification failed", err);
72
+ }
73
+ }
74
+ return matchFromSenderMap(ctx, cfg);
75
+ }
76
+ /**
77
+ * Get all unique userIds from the multiUser config (for knowledge graph scanning).
78
+ */
79
+ export function getAllUserIds(cfg) {
80
+ if (!cfg.multiUser) {
81
+ return cfg.userId ? [cfg.userId] : [];
82
+ }
83
+ const userIds = new Set();
84
+ for (const profile of Object.values(cfg.multiUser.senderMap)) {
85
+ userIds.add(profile.userId);
86
+ }
87
+ userIds.add(cfg.multiUser.sharedUserId);
88
+ return [...userIds];
89
+ }
90
+ /**
91
+ * Resolve the Role for a user. Looks first at the profile-level `role`
92
+ * override, then at `scoping.users[userId].role`. Returns undefined if
93
+ * scoping is disabled or the user has no role assignment.
94
+ */
95
+ export function resolveRole(user, cfg) {
96
+ const scoping = cfg.multiUser?.scoping;
97
+ if (!scoping || !user)
98
+ return undefined;
99
+ const roleName = user.role ?? scoping.users[user.userId]?.role;
100
+ if (!roleName)
101
+ return undefined;
102
+ return scoping.roles[roleName];
103
+ }
104
+ /**
105
+ * Readable scopes for this user. If scoping is disabled, returns ["*"] so
106
+ * `buildScopeFilter` produces no filter and PR #6 behavior is preserved.
107
+ */
108
+ export function getCanReadScopes(user, cfg) {
109
+ if (!cfg.multiUser?.scoping)
110
+ return ["*"];
111
+ const role = resolveRole(user, cfg);
112
+ return role?.canRead ?? [];
113
+ }
114
+ /**
115
+ * Default write scope for this user — explicit param > role default > global default.
116
+ */
117
+ export function getDefaultWriteScope(user, cfg) {
118
+ const role = resolveRole(user, cfg);
119
+ return (role?.defaultWriteScope ??
120
+ cfg.multiUser?.scoping?.defaultScope ??
121
+ "private");
122
+ }
123
+ /**
124
+ * Build a MongoDB-style metadata filter for Hyperspell's `options.filter`.
125
+ *
126
+ * Contract:
127
+ * - `["*"]` (wildcard) → returns `undefined` (no filter).
128
+ * - `[]` (empty) → returns a **match-nothing** filter, NOT `undefined`. An
129
+ * empty `canRead` is a deliberate "no access" signal and must not silently
130
+ * return all results.
131
+ * - Otherwise: `$or` of named-scope clause and (if "self" present) an
132
+ * own-user clause keyed by `openclaw_user`.
133
+ */
134
+ export function buildScopeFilter(canRead, userId) {
135
+ if (canRead.includes("*"))
136
+ return undefined;
137
+ if (canRead.length === 0) {
138
+ // Deliberate "no access" — match nothing.
139
+ return { openclaw_scope: "__never__" };
140
+ }
141
+ const namedScopes = canRead.filter((s) => s !== "self" && s !== "*");
142
+ const includeSelf = canRead.includes("self");
143
+ const clauses = [];
144
+ if (namedScopes.length > 0) {
145
+ clauses.push({
146
+ openclaw_scope: { $in: namedScopes.map((s) => normalizeScope(s)) },
147
+ });
148
+ }
149
+ if (includeSelf && userId) {
150
+ clauses.push({ openclaw_user: userId });
151
+ }
152
+ if (clauses.length === 0) {
153
+ return { openclaw_scope: "__never__" };
154
+ }
155
+ if (clauses.length === 1) {
156
+ return clauses[0];
157
+ }
158
+ return { $or: clauses };
159
+ }
160
+ /**
161
+ * Route a write: private scope stays in the user's own Hyperspell space
162
+ * (defense-in-depth); shared scopes go to `sharedUserId` with optional
163
+ * per-scope collection.
164
+ */
165
+ export function routeWrite(user, scope, cfg) {
166
+ const scoping = cfg.multiUser?.scoping;
167
+ if (scope === "private") {
168
+ return { userId: user?.userId, collection: undefined };
169
+ }
170
+ const sharedUserId = cfg.multiUser?.sharedUserId ?? user?.userId;
171
+ const collection = scoping?.collections?.[scope];
172
+ return { userId: sharedUserId, collection };
173
+ }
@@ -0,0 +1,22 @@
1
+ const NO_OP = {
2
+ async identify() {
3
+ return null;
4
+ },
5
+ };
6
+ const registry = new Map();
7
+ /**
8
+ * Register a voice-ID adapter implementation under a name that can be
9
+ * referenced from `cfg.multiUser.scoping.voiceId.adapter`. Call this from
10
+ * downstream code that wants to plug in a real speaker diarization model;
11
+ * Phase 1 ships with no adapters registered — the no-op is used.
12
+ */
13
+ export function registerVoiceIdentifier(name, impl) {
14
+ registry.set(name, impl);
15
+ }
16
+ export function getVoiceIdentifier(cfg) {
17
+ const name = cfg.multiUser?.scoping?.voiceId?.adapter;
18
+ if (name && registry.has(name)) {
19
+ return registry.get(name);
20
+ }
21
+ return NO_OP;
22
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,32 @@
1
+ let _logger = console;
2
+ let _debug = false;
3
+ export function initLogger(logger, debug) {
4
+ _logger = logger;
5
+ _debug = debug;
6
+ }
7
+ export const log = {
8
+ info: (message, ...args) => {
9
+ _logger.info(`hyperspell: ${message}`, ...args);
10
+ },
11
+ warn: (message, ...args) => {
12
+ _logger.warn(`hyperspell: ${message}`, ...args);
13
+ },
14
+ error: (message, ...args) => {
15
+ _logger.error(`hyperspell: ${message}`, ...args);
16
+ },
17
+ debug: (message, ...args) => {
18
+ if (_debug) {
19
+ _logger.debug(`hyperspell: ${message}`, ...args);
20
+ }
21
+ },
22
+ debugRequest: (method, params) => {
23
+ if (_debug) {
24
+ _logger.debug(`hyperspell: [${method}] request`, params);
25
+ }
26
+ },
27
+ debugResponse: (method, result) => {
28
+ if (_debug) {
29
+ _logger.debug(`hyperspell: [${method}] response`, result);
30
+ }
31
+ },
32
+ };
@@ -0,0 +1,151 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { log } from "../logger.js";
4
+ const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---\n?/;
5
+ /**
6
+ * Parse frontmatter from markdown content
7
+ */
8
+ function parseFrontmatter(content) {
9
+ const match = content.match(FRONTMATTER_REGEX);
10
+ if (!match) {
11
+ return { frontmatter: {}, body: content };
12
+ }
13
+ const frontmatterText = match[1];
14
+ const body = content.slice(match[0].length);
15
+ const frontmatter = {};
16
+ for (const line of frontmatterText.split("\n")) {
17
+ const colonIndex = line.indexOf(":");
18
+ if (colonIndex > 0) {
19
+ const key = line.slice(0, colonIndex).trim();
20
+ const value = line.slice(colonIndex + 1).trim();
21
+ frontmatter[key] = value;
22
+ }
23
+ }
24
+ return { frontmatter, body };
25
+ }
26
+ /**
27
+ * Serialize frontmatter back to string
28
+ */
29
+ function serializeFrontmatter(frontmatter) {
30
+ const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${value}`);
31
+ return `---\n${lines.join("\n")}\n---\n`;
32
+ }
33
+ /**
34
+ * Read a markdown file and parse its content
35
+ */
36
+ function readMarkdownFile(filePath) {
37
+ try {
38
+ const content = fs.readFileSync(filePath, "utf-8");
39
+ const { frontmatter, body } = parseFrontmatter(content);
40
+ const title = frontmatter.title || path.basename(filePath, ".md");
41
+ return {
42
+ filePath,
43
+ title,
44
+ content: body.trim(),
45
+ hyperspellId: frontmatter.hyperspell_id || null,
46
+ };
47
+ }
48
+ catch (err) {
49
+ log.error(`Failed to read markdown file: ${filePath}`, err);
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Update the hyperspell_id in the frontmatter of a markdown file
55
+ */
56
+ function updateFrontmatterId(filePath, hyperspellId) {
57
+ try {
58
+ const content = fs.readFileSync(filePath, "utf-8");
59
+ const { frontmatter, body } = parseFrontmatter(content);
60
+ frontmatter.hyperspell_id = hyperspellId;
61
+ const newContent = serializeFrontmatter(frontmatter) + body;
62
+ fs.writeFileSync(filePath, newContent);
63
+ log.debug(`Updated frontmatter in ${filePath} with hyperspell_id: ${hyperspellId}`);
64
+ }
65
+ catch (err) {
66
+ log.error(`Failed to update frontmatter in ${filePath}`, err);
67
+ }
68
+ }
69
+ /**
70
+ * Get all markdown files from the memory directory, including subdirectories
71
+ */
72
+ export function getMemoryFiles(workspaceDir) {
73
+ const memoryDir = path.join(workspaceDir, "memory");
74
+ if (!fs.existsSync(memoryDir)) {
75
+ return [];
76
+ }
77
+ const results = [];
78
+ function walk(dir) {
79
+ try {
80
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
81
+ const fullPath = path.join(dir, entry.name);
82
+ if (entry.isDirectory()) {
83
+ walk(fullPath);
84
+ }
85
+ else if (entry.name.endsWith(".md")) {
86
+ results.push(fullPath);
87
+ }
88
+ }
89
+ }
90
+ catch (err) {
91
+ log.error(`Failed to read directory: ${dir}`, err);
92
+ }
93
+ }
94
+ walk(memoryDir);
95
+ return results;
96
+ }
97
+ /**
98
+ * Sync a single markdown file to Hyperspell
99
+ */
100
+ export async function syncMarkdownFile(client, filePath, options) {
101
+ const file = readMarkdownFile(filePath);
102
+ if (!file) {
103
+ return { success: false, error: "Failed to read file" };
104
+ }
105
+ if (!file.content) {
106
+ return { success: false, error: "File has no content" };
107
+ }
108
+ try {
109
+ const result = await client.addMemory(file.content, {
110
+ title: file.title,
111
+ resourceId: file.hyperspellId || undefined,
112
+ collection: "openclaw",
113
+ metadata: {
114
+ openclaw_source: "memory_sync",
115
+ file_path: filePath,
116
+ },
117
+ userId: options?.userId,
118
+ });
119
+ // Update frontmatter with new resource ID if it changed or was newly created
120
+ if (result.resourceId !== file.hyperspellId) {
121
+ updateFrontmatterId(filePath, result.resourceId);
122
+ }
123
+ return { success: true, resourceId: result.resourceId };
124
+ }
125
+ catch (err) {
126
+ const errorMsg = err instanceof Error ? err.message : String(err);
127
+ log.error(`Failed to sync ${filePath}`, err);
128
+ return { success: false, error: errorMsg };
129
+ }
130
+ }
131
+ /**
132
+ * Sync all markdown files in the memory directory
133
+ */
134
+ export async function syncAllMemoryFiles(client, workspaceDir, options) {
135
+ const files = getMemoryFiles(workspaceDir);
136
+ let synced = 0;
137
+ let failed = 0;
138
+ const errors = [];
139
+ for (const filePath of files) {
140
+ const result = await syncMarkdownFile(client, filePath, { userId: options?.userId });
141
+ if (result.success) {
142
+ synced++;
143
+ log.info(`Synced: ${path.basename(filePath)} -> ${result.resourceId}`);
144
+ }
145
+ else {
146
+ failed++;
147
+ errors.push(`${path.basename(filePath)}: ${result.error}`);
148
+ }
149
+ }
150
+ return { synced, failed, errors };
151
+ }
@@ -0,0 +1,97 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
3
+ import { log } from "../logger.js";
4
+ export function createRememberToolFactory(client, cfg) {
5
+ const scopingEnabled = !!cfg.multiUser?.scoping;
6
+ const availableScopes = cfg.multiUser?.scoping?.scopes ?? [];
7
+ const scopeDescription = scopingEnabled
8
+ ? `Privacy scope for this memory. Available: ${availableScopes.join(", ")}. Defaults to the user's role default.`
9
+ : "Privacy scope (only used when scoping is enabled in config).";
10
+ return (ctx) => ({
11
+ name: "hyperspell_remember",
12
+ label: "Memory Store",
13
+ description: "Save important information to the user's memory.",
14
+ parameters: Type.Object({
15
+ text: Type.String({ description: "Information to remember" }),
16
+ title: Type.Optional(Type.String({ description: "Optional title for the memory" })),
17
+ date: Type.Optional(Type.String({ description: "Date of the memory (ISO 8601 or YYYY-MM-DD). Helps ranking and enables date-range filtering. Defaults to now if omitted." })),
18
+ userId: Type.Optional(Type.String({
19
+ description: "Store for a specific user or 'shared' for everyone. Omit to store for current sender.",
20
+ })),
21
+ scope: Type.Optional(Type.String({ description: scopeDescription })),
22
+ }),
23
+ async execute(_toolCallId, params) {
24
+ const resolved = resolveUser(ctx, cfg);
25
+ // Scope resolution: explicit param > role default > global default > "private"
26
+ const scope = params.scope ??
27
+ (scopingEnabled ? getDefaultWriteScope(resolved, cfg) : "private");
28
+ // Validate scope is in declared vocabulary (if scoping is enabled)
29
+ if (scopingEnabled &&
30
+ availableScopes.length > 0 &&
31
+ !availableScopes.includes(scope)) {
32
+ return {
33
+ content: [
34
+ {
35
+ type: "text",
36
+ text: `Unknown scope "${scope}". Available: ${availableScopes.join(", ")}.`,
37
+ },
38
+ ],
39
+ };
40
+ }
41
+ // canWriteScopes enforcement (role-level deny list)
42
+ if (scopingEnabled) {
43
+ const role = resolveRole(resolved, cfg);
44
+ if (role?.canWriteScopes && !role.canWriteScopes.includes(scope)) {
45
+ return {
46
+ content: [
47
+ {
48
+ type: "text",
49
+ text: `You cannot write to scope "${scope}".`,
50
+ },
51
+ ],
52
+ };
53
+ }
54
+ }
55
+ // Route: explicit userId override takes precedence; otherwise derive from scope
56
+ let userId;
57
+ let collection;
58
+ if (params.userId) {
59
+ userId = params.userId;
60
+ }
61
+ else if (scopingEnabled) {
62
+ const routed = routeWrite(resolved, scope, cfg);
63
+ userId = routed.userId;
64
+ collection = routed.collection;
65
+ }
66
+ else {
67
+ userId = resolved?.userId;
68
+ }
69
+ log.debug(`remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"} userId=${userId} scope=${scope}`);
70
+ try {
71
+ await client.addMemory(params.text, {
72
+ title: params.title,
73
+ date: params.date,
74
+ collection,
75
+ metadata: { source: "openclaw_tool" },
76
+ userId,
77
+ scope: scopingEnabled ? scope : undefined,
78
+ });
79
+ const preview = params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text;
80
+ return {
81
+ content: [{ type: "text", text: `Stored: "${preview}"` }],
82
+ };
83
+ }
84
+ catch (err) {
85
+ log.error("remember tool failed", err);
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text",
90
+ text: `Failed to store memory: ${err instanceof Error ? err.message : String(err)}`,
91
+ },
92
+ ],
93
+ };
94
+ }
95
+ },
96
+ });
97
+ }