@bamdra/bamdra-user-bind 0.1.1 → 0.1.2

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 CHANGED
@@ -30,10 +30,24 @@ The file count is intentionally small because this first public version is shipp
30
30
 
31
31
  - runtime primary store:
32
32
  `~/.openclaw/data/bamdra-user-bind/profiles.sqlite`
33
+ - editable per-user Markdown mirror:
34
+ `~/.openclaw/data/bamdra-user-bind/profiles/private/{userId}.md`
33
35
  - export directory:
34
36
  `~/.openclaw/data/bamdra-user-bind/exports/`
35
37
 
36
- The runtime only queries the SQLite store. Export files exist for backup and manual inspection.
38
+ The runtime queries the SQLite store as the controlled source of truth. The Markdown mirror exists so humans can edit a per-user profile the way they would edit a `USER.md`-style file, without turning the whole directory into an unrestricted agent-readable knowledge base.
39
+
40
+ The Markdown mirror root is configurable through `profileMarkdownRoot`.
41
+
42
+ ## Default Profile Starter
43
+
44
+ New profile mirrors start with a practical template, including example defaults such as:
45
+
46
+ - preferred address: `老板`
47
+ - timezone: `Asia/Shanghai`
48
+ - preference: `幽默诙谐的对话风格,但是不过分`
49
+
50
+ Users can edit that Markdown directly and the plugin will sync the changes back into the controlled store.
37
51
 
38
52
  ## Security Boundary
39
53
 
@@ -51,6 +65,15 @@ The runtime only queries the SQLite store. Export files exist for backup and man
51
65
  - optional companion:
52
66
  `bamdra-memory-vector`
53
67
 
68
+ ## Bundled Skills
69
+
70
+ This package now ships standalone skills under `skills/`:
71
+
72
+ - `bamdra-user-bind-profile`
73
+ - `bamdra-user-bind-admin`
74
+
75
+ When installed into OpenClaw, bootstrap can materialize these into `~/.openclaw/skills/` and attach them automatically.
76
+
54
77
  ## Build
55
78
 
56
79
  ```bash
package/README.zh-CN.md CHANGED
@@ -28,10 +28,24 @@
28
28
 
29
29
  - 运行时主存储:
30
30
  `~/.openclaw/data/bamdra-user-bind/profiles.sqlite`
31
+ - 可编辑的用户画像 Markdown 镜像:
32
+ `~/.openclaw/data/bamdra-user-bind/profiles/private/{userId}.md`
31
33
  - 导出目录:
32
34
  `~/.openclaw/data/bamdra-user-bind/exports/`
33
35
 
34
- 运行时只查询 SQLite 主库,导出文件用于备份和人工查看。
36
+ 运行时只查询 SQLite 主库,Markdown 镜像是给人维护的用户画像层,导出文件则用于备份和人工查看。
37
+
38
+ `profileMarkdownRoot` 可以改成你自己的目录,例如 Obsidian 仓库中的私有画像目录。
39
+
40
+ ## 默认画像模板
41
+
42
+ 新画像文件会带一个起步模板,默认示例包括:
43
+
44
+ - 建议称呼:`老板`
45
+ - 时区:`Asia/Shanghai`
46
+ - 偏好:`幽默诙谐的对话风格,但是不过分`
47
+
48
+ 用户可以直接编辑这份 Markdown,插件会把改动同步回受控存储。
35
49
 
36
50
  ## 安全边界
37
51
 
@@ -49,6 +63,15 @@
49
63
  - 可选配套:
50
64
  `bamdra-memory-vector`
51
65
 
66
+ ## 随包 Skill
67
+
68
+ 这个包会附带独立 skill:
69
+
70
+ - `bamdra-user-bind-profile`
71
+ - `bamdra-user-bind-admin`
72
+
73
+ 安装到 OpenClaw 后,bootstrap 可以把它们复制到 `~/.openclaw/skills/` 并自动挂到合适的 agent。
74
+
52
75
  ## 构建
53
76
 
54
77
  ```bash
package/dist/index.js CHANGED
@@ -25,12 +25,34 @@ __export(index_exports, {
25
25
  register: () => register
26
26
  });
27
27
  module.exports = __toCommonJS(index_exports);
28
+
29
+ // ../node_modules/.pnpm/tsup@8.5.1_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
30
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
31
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
32
+
33
+ // src/index.ts
28
34
  var import_node_crypto = require("crypto");
29
35
  var import_node_fs = require("fs");
30
36
  var import_node_os = require("os");
31
37
  var import_node_path = require("path");
32
38
  var import_node_sqlite = require("node:sqlite");
39
+ var import_node_url = require("url");
40
+ var PLUGIN_ID = "bamdra-user-bind";
33
41
  var GLOBAL_API_KEY = "__OPENCLAW_BAMDRA_USER_BIND__";
42
+ var PROFILE_SKILL_ID = "bamdra-user-bind-profile";
43
+ var ADMIN_SKILL_ID = "bamdra-user-bind-admin";
44
+ var SELF_TOOL_NAMES = [
45
+ "user_bind_get_my_profile",
46
+ "user_bind_update_my_profile",
47
+ "user_bind_refresh_my_binding"
48
+ ];
49
+ var ADMIN_TOOL_NAMES = [
50
+ "user_bind_admin_query",
51
+ "user_bind_admin_edit",
52
+ "user_bind_admin_merge",
53
+ "user_bind_admin_list_issues",
54
+ "user_bind_admin_sync"
55
+ ];
34
56
  var TABLES = {
35
57
  profiles: "bamdra_user_bind_profiles",
36
58
  bindings: "bamdra_user_bind_bindings",
@@ -38,11 +60,13 @@ var TABLES = {
38
60
  audits: "bamdra_user_bind_audits"
39
61
  };
40
62
  var UserBindStore = class {
41
- constructor(dbPath, exportPath) {
63
+ constructor(dbPath, exportPath, profileMarkdownRoot) {
42
64
  this.dbPath = dbPath;
43
65
  this.exportPath = exportPath;
66
+ this.profileMarkdownRoot = profileMarkdownRoot;
44
67
  (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(dbPath), { recursive: true });
45
68
  (0, import_node_fs.mkdirSync)(exportPath, { recursive: true });
69
+ (0, import_node_fs.mkdirSync)(profileMarkdownRoot, { recursive: true });
46
70
  this.db = new import_node_sqlite.DatabaseSync(dbPath);
47
71
  this.db.exec(`
48
72
  CREATE TABLE IF NOT EXISTS ${TABLES.profiles} (
@@ -55,6 +79,8 @@ var UserBindStore = class {
55
79
  preferences TEXT,
56
80
  personality TEXT,
57
81
  role TEXT,
82
+ timezone TEXT,
83
+ notes TEXT,
58
84
  visibility TEXT NOT NULL DEFAULT 'private',
59
85
  source TEXT NOT NULL,
60
86
  updated_at TEXT NOT NULL
@@ -91,17 +117,17 @@ var UserBindStore = class {
91
117
  changed_fields TEXT NOT NULL
92
118
  );
93
119
  `);
120
+ this.ensureColumn(TABLES.profiles, "timezone", "TEXT");
121
+ this.ensureColumn(TABLES.profiles, "notes", "TEXT");
94
122
  }
95
123
  db;
124
+ markdownSyncing = /* @__PURE__ */ new Set();
96
125
  close() {
97
126
  this.db.close();
98
127
  }
99
128
  getProfile(userId) {
100
- const row = this.db.prepare(`
101
- SELECT user_id, name, gender, email, avatar, nickname, preferences, personality, role, visibility, source, updated_at
102
- FROM ${TABLES.profiles} WHERE user_id = ?
103
- `).get(userId);
104
- return row ? mapProfileRow(row) : null;
129
+ this.syncMarkdownToStore(userId);
130
+ return this.getProfileFromDatabase(userId);
105
131
  }
106
132
  findBinding(channelType, openId) {
107
133
  if (!openId) {
@@ -153,25 +179,30 @@ var UserBindStore = class {
153
179
  );
154
180
  }
155
181
  upsertIdentity(args) {
182
+ if (!this.markdownSyncing.has(args.userId)) {
183
+ this.syncMarkdownToStore(args.userId);
184
+ }
156
185
  const now = (/* @__PURE__ */ new Date()).toISOString();
157
- const current = this.getProfile(args.userId);
186
+ const current = this.getProfileFromDatabase(args.userId);
158
187
  const next = {
159
188
  userId: args.userId,
160
189
  name: args.profilePatch.name ?? current?.name ?? null,
161
190
  gender: args.profilePatch.gender ?? current?.gender ?? null,
162
191
  email: args.profilePatch.email ?? current?.email ?? null,
163
192
  avatar: args.profilePatch.avatar ?? current?.avatar ?? null,
164
- nickname: args.profilePatch.nickname ?? current?.nickname ?? null,
165
- preferences: args.profilePatch.preferences ?? current?.preferences ?? null,
193
+ nickname: args.profilePatch.nickname ?? current?.nickname ?? "\u8001\u677F",
194
+ preferences: args.profilePatch.preferences ?? current?.preferences ?? "\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206",
166
195
  personality: args.profilePatch.personality ?? current?.personality ?? null,
167
196
  role: args.profilePatch.role ?? current?.role ?? null,
197
+ timezone: args.profilePatch.timezone ?? current?.timezone ?? "Asia/Shanghai",
198
+ notes: args.profilePatch.notes ?? current?.notes ?? defaultProfileNotes(),
168
199
  visibility: args.profilePatch.visibility ?? current?.visibility ?? "private",
169
200
  source: args.source,
170
201
  updatedAt: now
171
202
  };
172
203
  this.db.prepare(`
173
- INSERT INTO ${TABLES.profiles} (user_id, name, gender, email, avatar, nickname, preferences, personality, role, visibility, source, updated_at)
174
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
204
+ INSERT INTO ${TABLES.profiles} (user_id, name, gender, email, avatar, nickname, preferences, personality, role, timezone, notes, visibility, source, updated_at)
205
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
175
206
  ON CONFLICT(user_id) DO UPDATE SET
176
207
  name = excluded.name,
177
208
  gender = excluded.gender,
@@ -181,6 +212,8 @@ var UserBindStore = class {
181
212
  preferences = excluded.preferences,
182
213
  personality = excluded.personality,
183
214
  role = excluded.role,
215
+ timezone = excluded.timezone,
216
+ notes = excluded.notes,
184
217
  visibility = excluded.visibility,
185
218
  source = excluded.source,
186
219
  updated_at = excluded.updated_at
@@ -194,28 +227,35 @@ var UserBindStore = class {
194
227
  next.preferences,
195
228
  next.personality,
196
229
  next.role,
230
+ next.timezone,
231
+ next.notes,
197
232
  next.visibility,
198
233
  next.source,
199
234
  next.updatedAt
200
235
  );
201
- const bindingId = hashId(`${args.channelType}:${args.openId ?? args.userId}`);
202
- this.db.prepare(`
203
- INSERT INTO ${TABLES.bindings} (binding_id, user_id, channel_type, open_id, external_user_id, union_id, source, updated_at)
204
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
205
- ON CONFLICT(channel_type, open_id) DO UPDATE SET
206
- user_id = excluded.user_id,
207
- source = excluded.source,
208
- updated_at = excluded.updated_at
209
- `).run(
210
- bindingId,
211
- args.userId,
212
- args.channelType,
213
- args.openId,
214
- args.userId,
215
- null,
216
- args.source,
217
- now
218
- );
236
+ if (args.channelType !== "manual" || args.openId) {
237
+ const bindingId = hashId(`${args.channelType}:${args.openId ?? args.userId}`);
238
+ this.db.prepare(`
239
+ INSERT INTO ${TABLES.bindings} (binding_id, user_id, channel_type, open_id, external_user_id, union_id, source, updated_at)
240
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
241
+ ON CONFLICT(binding_id) DO UPDATE SET
242
+ user_id = excluded.user_id,
243
+ channel_type = excluded.channel_type,
244
+ open_id = excluded.open_id,
245
+ source = excluded.source,
246
+ updated_at = excluded.updated_at
247
+ `).run(
248
+ bindingId,
249
+ args.userId,
250
+ args.channelType,
251
+ args.openId,
252
+ args.userId,
253
+ null,
254
+ args.source,
255
+ now
256
+ );
257
+ }
258
+ this.writeProfileMarkdown(next);
219
259
  this.writeExports();
220
260
  return next;
221
261
  }
@@ -255,6 +295,8 @@ var UserBindStore = class {
255
295
  preferences: into.preferences ?? from.preferences,
256
296
  personality: into.personality ?? from.personality,
257
297
  role: into.role ?? from.role,
298
+ timezone: into.timezone ?? from.timezone,
299
+ notes: joinNotes(into.notes, from.notes),
258
300
  visibility: into.visibility
259
301
  }
260
302
  });
@@ -267,9 +309,76 @@ var UserBindStore = class {
267
309
  this.writeExports();
268
310
  return merged;
269
311
  }
312
+ getProfileFromDatabase(userId) {
313
+ const row = this.db.prepare(`
314
+ SELECT user_id, name, gender, email, avatar, nickname, preferences, personality, role, timezone, notes, visibility, source, updated_at
315
+ FROM ${TABLES.profiles} WHERE user_id = ?
316
+ `).get(userId);
317
+ return row ? mapProfileRow(row) : null;
318
+ }
319
+ profileMarkdownPath(userId) {
320
+ return (0, import_node_path.join)(this.profileMarkdownRoot, `${sanitizeFilename(userId)}.md`);
321
+ }
322
+ syncMarkdownToStore(userId) {
323
+ if (this.markdownSyncing.has(userId)) {
324
+ return;
325
+ }
326
+ this.markdownSyncing.add(userId);
327
+ try {
328
+ const markdownPath = this.profileMarkdownPath(userId);
329
+ if (!(0, import_node_fs.existsSync)(markdownPath)) {
330
+ const current2 = this.getProfileFromDatabase(userId);
331
+ if (current2) {
332
+ this.writeProfileMarkdown(current2);
333
+ }
334
+ return;
335
+ }
336
+ const current = this.getProfileFromDatabase(userId);
337
+ const parsed = parseProfileMarkdown((0, import_node_fs.readFileSync)(markdownPath, "utf8"));
338
+ const markdownMtime = (0, import_node_fs.statSync)(markdownPath).mtime.toISOString();
339
+ const dbTime = current?.updatedAt ?? (/* @__PURE__ */ new Date(0)).toISOString();
340
+ const patch = {
341
+ ...parsed.profilePatch,
342
+ notes: parsed.notes
343
+ };
344
+ if (!current) {
345
+ this.upsertIdentity({
346
+ userId,
347
+ channelType: "manual",
348
+ openId: null,
349
+ source: "markdown-profile",
350
+ profilePatch: {
351
+ ...patch,
352
+ visibility: patch.visibility ?? "private"
353
+ }
354
+ });
355
+ return;
356
+ }
357
+ if (markdownMtime <= dbTime && !hasProfileDifference(current, patch)) {
358
+ return;
359
+ }
360
+ this.upsertIdentity({
361
+ userId,
362
+ channelType: "manual",
363
+ openId: null,
364
+ source: "markdown-profile",
365
+ profilePatch: {
366
+ ...current,
367
+ ...patch
368
+ }
369
+ });
370
+ } finally {
371
+ this.markdownSyncing.delete(userId);
372
+ }
373
+ }
374
+ writeProfileMarkdown(profile) {
375
+ const markdownPath = this.profileMarkdownPath(profile.userId);
376
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(markdownPath), { recursive: true });
377
+ (0, import_node_fs.writeFileSync)(markdownPath, renderProfileMarkdown(profile), "utf8");
378
+ }
270
379
  writeExports() {
271
380
  const profiles = this.db.prepare(`
272
- SELECT user_id, name, gender, email, avatar, nickname, preferences, personality, role, visibility, source, updated_at
381
+ SELECT user_id, name, gender, email, avatar, nickname, preferences, personality, role, timezone, notes, visibility, source, updated_at
273
382
  FROM ${TABLES.profiles}
274
383
  ORDER BY updated_at DESC
275
384
  `).all();
@@ -281,6 +390,13 @@ var UserBindStore = class {
281
390
  (0, import_node_fs.writeFileSync)((0, import_node_path.join)(this.exportPath, "users.yaml"), renderYamlList(profiles), "utf8");
282
391
  (0, import_node_fs.writeFileSync)((0, import_node_path.join)(this.exportPath, "bindings.yaml"), renderYamlList(bindings), "utf8");
283
392
  }
393
+ ensureColumn(tableName, columnName, definition) {
394
+ const rows = this.db.prepare(`PRAGMA table_info(${tableName})`).all();
395
+ if (rows.some((row) => String(row.name) === columnName)) {
396
+ return;
397
+ }
398
+ this.db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
399
+ }
284
400
  };
285
401
  var UserBindRuntime = class {
286
402
  constructor(host, inputConfig) {
@@ -288,7 +404,8 @@ var UserBindRuntime = class {
288
404
  this.config = normalizeConfig(inputConfig);
289
405
  this.store = new UserBindStore(
290
406
  (0, import_node_path.join)(this.config.localStorePath, "profiles.sqlite"),
291
- this.config.exportPath
407
+ this.config.exportPath,
408
+ this.config.profileMarkdownRoot
292
409
  );
293
410
  }
294
411
  store;
@@ -299,6 +416,12 @@ var UserBindRuntime = class {
299
416
  this.store.close();
300
417
  }
301
418
  register() {
419
+ queueMicrotask(() => {
420
+ try {
421
+ bootstrapOpenClawHost(this.config);
422
+ } catch {
423
+ }
424
+ });
302
425
  this.registerHooks();
303
426
  this.registerTools();
304
427
  exposeGlobalApi(this);
@@ -509,7 +632,9 @@ var UserBindRuntime = class {
509
632
  nickname: { type: "string" },
510
633
  preferences: { type: "string" },
511
634
  personality: { type: "string" },
512
- role: { type: "string" }
635
+ role: { type: "string" },
636
+ timezone: { type: "string" },
637
+ notes: { type: "string" }
513
638
  }
514
639
  },
515
640
  execute: async (_id, params) => asTextResult(await this.updateMyProfile(params, sanitizeProfilePatch(params)))
@@ -526,13 +651,7 @@ var UserBindRuntime = class {
526
651
  },
527
652
  execute: async (_id, params) => asTextResult(await this.refreshMyBinding(params))
528
653
  });
529
- for (const toolName of [
530
- "user_bind_admin_query",
531
- "user_bind_admin_edit",
532
- "user_bind_admin_merge",
533
- "user_bind_admin_list_issues",
534
- "user_bind_admin_sync"
535
- ]) {
654
+ for (const toolName of ADMIN_TOOL_NAMES) {
536
655
  registerTool({
537
656
  name: toolName,
538
657
  description: `Administrative natural-language tool for ${toolName.replace("user_bind_admin_", "")}`,
@@ -590,12 +709,14 @@ async function activate(api) {
590
709
  }
591
710
  function normalizeConfig(input) {
592
711
  const root = (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "data", "bamdra-user-bind");
712
+ const storeRoot = input?.localStorePath ?? root;
593
713
  return {
594
714
  enabled: input?.enabled ?? true,
595
- localStorePath: input?.localStorePath ?? root,
596
- exportPath: input?.exportPath ?? (0, import_node_path.join)(root, "exports"),
715
+ localStorePath: storeRoot,
716
+ exportPath: input?.exportPath ?? (0, import_node_path.join)(storeRoot, "exports"),
717
+ profileMarkdownRoot: input?.profileMarkdownRoot ?? (0, import_node_path.join)(storeRoot, "profiles", "private"),
597
718
  cacheTtlMs: input?.cacheTtlMs ?? 30 * 60 * 1e3,
598
- adminAgents: input?.adminAgents ?? []
719
+ adminAgents: input?.adminAgents?.length ? input.adminAgents : ["main"]
599
720
  };
600
721
  }
601
722
  function exposeGlobalApi(runtime) {
@@ -608,6 +729,142 @@ function exposeGlobalApi(runtime) {
608
729
  }
609
730
  };
610
731
  }
732
+ function bootstrapOpenClawHost(config) {
733
+ const currentFile = (0, import_node_url.fileURLToPath)(importMetaUrl);
734
+ const runtimeDir = (0, import_node_path.dirname)(currentFile);
735
+ const packageRoot = (0, import_node_path.resolve)(runtimeDir, "..");
736
+ const openclawHome = (0, import_node_path.resolve)((0, import_node_os.homedir)(), ".openclaw");
737
+ const configPath = (0, import_node_path.join)(openclawHome, "openclaw.json");
738
+ const extensionRoot = (0, import_node_path.join)(openclawHome, "extensions");
739
+ const globalSkillsDir = (0, import_node_path.join)(openclawHome, "skills");
740
+ const profileSkillSource = (0, import_node_path.join)(packageRoot, "skills", PROFILE_SKILL_ID);
741
+ const adminSkillSource = (0, import_node_path.join)(packageRoot, "skills", ADMIN_SKILL_ID);
742
+ const profileSkillTarget = (0, import_node_path.join)(globalSkillsDir, PROFILE_SKILL_ID);
743
+ const adminSkillTarget = (0, import_node_path.join)(globalSkillsDir, ADMIN_SKILL_ID);
744
+ if (!runtimeDir.startsWith(extensionRoot) || !(0, import_node_fs.existsSync)(configPath)) {
745
+ return;
746
+ }
747
+ (0, import_node_fs.mkdirSync)(globalSkillsDir, { recursive: true });
748
+ materializeBundledSkill(profileSkillSource, profileSkillTarget);
749
+ materializeBundledSkill(adminSkillSource, adminSkillTarget);
750
+ const original = (0, import_node_fs.readFileSync)(configPath, "utf8");
751
+ const parsed = JSON.parse(original);
752
+ const changed = ensureHostConfig(parsed, config, profileSkillTarget, adminSkillTarget);
753
+ if (!changed) {
754
+ return;
755
+ }
756
+ (0, import_node_fs.writeFileSync)(configPath, `${JSON.stringify(parsed, null, 2)}
757
+ `, "utf8");
758
+ }
759
+ function ensureHostConfig(config, pluginConfig, profileSkillTarget, adminSkillTarget) {
760
+ let changed = false;
761
+ const plugins = ensureObject(config, "plugins");
762
+ const entries = ensureObject(plugins, "entries");
763
+ const load = ensureObject(plugins, "load");
764
+ const tools = ensureObject(config, "tools");
765
+ const skills = ensureObject(config, "skills");
766
+ const skillsLoad = ensureObject(skills, "load");
767
+ const agents = ensureObject(config, "agents");
768
+ const entry = ensureObject(entries, PLUGIN_ID);
769
+ const entryConfig = ensureObject(entry, "config");
770
+ changed = ensureArrayIncludes(plugins, "allow", PLUGIN_ID) || changed;
771
+ changed = ensureArrayIncludes(load, "paths", (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "extensions")) || changed;
772
+ changed = ensureArrayIncludes(skillsLoad, "extraDirs", (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "skills")) || changed;
773
+ if (entry.enabled !== true) {
774
+ entry.enabled = true;
775
+ changed = true;
776
+ }
777
+ changed = ensureToolNames(tools, [...SELF_TOOL_NAMES, ...ADMIN_TOOL_NAMES]) || changed;
778
+ if (entryConfig.enabled !== true) {
779
+ entryConfig.enabled = true;
780
+ changed = true;
781
+ }
782
+ if (typeof entryConfig.localStorePath !== "string" || entryConfig.localStorePath.length === 0) {
783
+ entryConfig.localStorePath = pluginConfig.localStorePath;
784
+ changed = true;
785
+ }
786
+ if (typeof entryConfig.exportPath !== "string" || entryConfig.exportPath.length === 0) {
787
+ entryConfig.exportPath = pluginConfig.exportPath;
788
+ changed = true;
789
+ }
790
+ if (typeof entryConfig.profileMarkdownRoot !== "string" || entryConfig.profileMarkdownRoot.length === 0) {
791
+ entryConfig.profileMarkdownRoot = pluginConfig.profileMarkdownRoot;
792
+ changed = true;
793
+ }
794
+ if (!Array.isArray(entryConfig.adminAgents) || entryConfig.adminAgents.length === 0) {
795
+ entryConfig.adminAgents = [...pluginConfig.adminAgents];
796
+ changed = true;
797
+ }
798
+ changed = ensureAgentSkills(agents, PROFILE_SKILL_ID) || changed;
799
+ changed = ensureAdminSkill(agents, ADMIN_SKILL_ID, pluginConfig.adminAgents) || changed;
800
+ if (!(0, import_node_fs.existsSync)(profileSkillTarget) || !(0, import_node_fs.existsSync)(adminSkillTarget)) {
801
+ changed = true;
802
+ }
803
+ return changed;
804
+ }
805
+ function materializeBundledSkill(sourceDir, targetDir) {
806
+ if (!(0, import_node_fs.existsSync)(sourceDir) || (0, import_node_fs.existsSync)(targetDir)) {
807
+ return;
808
+ }
809
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(targetDir), { recursive: true });
810
+ (0, import_node_fs.cpSync)(sourceDir, targetDir, { recursive: true });
811
+ }
812
+ function ensureToolNames(tools, values) {
813
+ let changed = false;
814
+ for (const value of values) {
815
+ changed = ensureArrayIncludes(tools, "allow", value) || changed;
816
+ }
817
+ return changed;
818
+ }
819
+ function ensureAgentSkills(agents, skillId) {
820
+ const list = Array.isArray(agents.list) ? agents.list : [];
821
+ let changed = false;
822
+ for (const item of list) {
823
+ if (!item || typeof item !== "object") {
824
+ continue;
825
+ }
826
+ const agent = item;
827
+ const current = Array.isArray(agent.skills) ? [...agent.skills] : [];
828
+ if (!current.includes(skillId)) {
829
+ current.push(skillId);
830
+ agent.skills = current;
831
+ changed = true;
832
+ }
833
+ }
834
+ return changed;
835
+ }
836
+ function ensureAdminSkill(agents, skillId, adminAgents) {
837
+ const list = Array.isArray(agents.list) ? agents.list : [];
838
+ let changed = false;
839
+ let attached = false;
840
+ for (const item of list) {
841
+ if (!item || typeof item !== "object") {
842
+ continue;
843
+ }
844
+ const agent = item;
845
+ const agentId = getConfiguredAgentId(agent);
846
+ if (!agentId || !adminAgents.includes(agentId)) {
847
+ continue;
848
+ }
849
+ const current = Array.isArray(agent.skills) ? [...agent.skills] : [];
850
+ if (!current.includes(skillId)) {
851
+ current.push(skillId);
852
+ agent.skills = current;
853
+ changed = true;
854
+ }
855
+ attached = true;
856
+ }
857
+ if (!attached && list.length > 0 && list[0] && typeof list[0] === "object") {
858
+ const agent = list[0];
859
+ const current = Array.isArray(agent.skills) ? [...agent.skills] : [];
860
+ if (!current.includes(skillId)) {
861
+ current.push(skillId);
862
+ agent.skills = current;
863
+ changed = true;
864
+ }
865
+ }
866
+ return changed;
867
+ }
611
868
  function mapProfileRow(row) {
612
869
  return {
613
870
  userId: String(row.user_id),
@@ -619,6 +876,8 @@ function mapProfileRow(row) {
619
876
  preferences: asNullableString(row.preferences),
620
877
  personality: asNullableString(row.personality),
621
878
  role: asNullableString(row.role),
879
+ timezone: asNullableString(row.timezone),
880
+ notes: asNullableString(row.notes),
622
881
  visibility: row.visibility === "shared" ? "shared" : "private",
623
882
  source: String(row.source ?? "local"),
624
883
  updatedAt: String(row.updated_at ?? (/* @__PURE__ */ new Date(0)).toISOString())
@@ -641,14 +900,16 @@ function parseIdentityContext(context) {
641
900
  }
642
901
  function getAgentIdFromContext(context) {
643
902
  const record = context && typeof context === "object" ? context : {};
644
- return asNullableString(record.agentId) ?? asNullableString(record.agent?.id);
903
+ return asNullableString(record.agentId) ?? asNullableString(record.agent?.id) ?? asNullableString(record.agent?.name);
645
904
  }
646
905
  function sanitizeProfilePatch(params) {
647
906
  return {
648
907
  nickname: asNullableString(params.nickname),
649
908
  preferences: asNullableString(params.preferences),
650
909
  personality: asNullableString(params.personality),
651
- role: asNullableString(params.role)
910
+ role: asNullableString(params.role),
911
+ timezone: asNullableString(params.timezone),
912
+ notes: asNullableString(params.notes)
652
913
  };
653
914
  }
654
915
  function parseAdminInstruction(instruction) {
@@ -685,6 +946,7 @@ function extractProfilePatch(input) {
685
946
  const role = input.match(/(?:role|职责|角色)[=:: ]([^,,]+)/i);
686
947
  const preferences = input.match(/(?:preferences|偏好)[=:: ]([^,,]+)/i);
687
948
  const personality = input.match(/(?:personality|性格)[=:: ]([^,,]+)/i);
949
+ const timezone = input.match(/(?:timezone|时区)[=:: ]([^,,]+)/i);
688
950
  if (nickname) {
689
951
  patch.nickname = nickname[1].trim();
690
952
  }
@@ -697,6 +959,9 @@ function extractProfilePatch(input) {
697
959
  if (personality) {
698
960
  patch.personality = personality[1].trim();
699
961
  }
962
+ if (timezone) {
963
+ patch.timezone = timezone[1].trim();
964
+ }
700
965
  return patch;
701
966
  }
702
967
  function renderIdentityContext(identity) {
@@ -710,6 +975,9 @@ function renderIdentityContext(identity) {
710
975
  if (identity.profile.nickname) {
711
976
  lines.push(`Preferred address: ${identity.profile.nickname}`);
712
977
  }
978
+ if (identity.profile.timezone) {
979
+ lines.push(`Timezone: ${identity.profile.timezone}`);
980
+ }
713
981
  if (identity.profile.preferences) {
714
982
  lines.push(`Preferences: ${identity.profile.preferences}`);
715
983
  }
@@ -719,8 +987,95 @@ function renderIdentityContext(identity) {
719
987
  if (identity.profile.role) {
720
988
  lines.push(`Role: ${identity.profile.role}`);
721
989
  }
990
+ if (identity.profile.notes) {
991
+ lines.push(`Profile notes: ${identity.profile.notes}`);
992
+ }
722
993
  return lines.join("\n");
723
994
  }
995
+ function renderProfileMarkdown(profile) {
996
+ const frontmatter = [
997
+ "---",
998
+ `userId: ${escapeFrontmatter(profile.userId)}`,
999
+ `name: ${escapeFrontmatter(profile.name)}`,
1000
+ `nickname: ${escapeFrontmatter(profile.nickname)}`,
1001
+ `timezone: ${escapeFrontmatter(profile.timezone ?? "Asia/Shanghai")}`,
1002
+ `preferences: ${escapeFrontmatter(profile.preferences ?? "\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206")}`,
1003
+ `personality: ${escapeFrontmatter(profile.personality)}`,
1004
+ `role: ${escapeFrontmatter(profile.role)}`,
1005
+ `visibility: ${escapeFrontmatter(profile.visibility)}`,
1006
+ `source: ${escapeFrontmatter(profile.source)}`,
1007
+ `updatedAt: ${escapeFrontmatter(profile.updatedAt)}`,
1008
+ "---"
1009
+ ].join("\n");
1010
+ const notes = profile.notes ?? defaultProfileNotes();
1011
+ return `${frontmatter}
1012
+
1013
+ # \u7528\u6237\u753B\u50CF
1014
+
1015
+ \u8FD9\u4E2A\u6587\u4EF6\u662F\u5F53\u524D\u7528\u6237\u7684\u53EF\u7F16\u8F91\u753B\u50CF\u955C\u50CF\u3002\u4F60\u53EF\u4EE5\u76F4\u63A5\u4FEE\u6539\u4E0A\u9762\u7684\u5B57\u6BB5\u548C\u4E0B\u9762\u7684\u8BF4\u660E\u5185\u5BB9\u3002
1016
+
1017
+ ## \u4F7F\u7528\u5EFA\u8BAE
1018
+
1019
+ - \u5E38\u7528\u79F0\u547C\uFF1A\u4F8B\u5982\u201C\u8001\u677F\u201D
1020
+ - \u65F6\u533A\uFF1A\u4F8B\u5982 Asia/Shanghai
1021
+ - \u98CE\u683C\u504F\u597D\uFF1A\u4F8B\u5982\u201C\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206\u201D
1022
+ - \u89D2\u8272\u4FE1\u606F\uFF1A\u4F8B\u5982\u5DE5\u4F5C\u804C\u8D23\u3001\u534F\u4F5C\u8FB9\u754C\u3001\u5E38\u89C1\u4EFB\u52A1
1023
+ - \u5176\u4ED6\u5907\u6CE8\uFF1A\u4F8B\u5982\u7981\u5FCC\u3001\u4E60\u60EF\u3001\u8F93\u51FA\u504F\u597D
1024
+
1025
+ ## \u5907\u6CE8
1026
+
1027
+ ${notes}
1028
+ `;
1029
+ }
1030
+ function parseProfileMarkdown(markdown) {
1031
+ const lines = markdown.split(/\r?\n/);
1032
+ const patch = {};
1033
+ let notes = "";
1034
+ let index = 0;
1035
+ if (lines[index] === "---") {
1036
+ index += 1;
1037
+ while (index < lines.length && lines[index] !== "---") {
1038
+ const line = lines[index];
1039
+ const separatorIndex = line.indexOf(":");
1040
+ if (separatorIndex > 0) {
1041
+ const key = line.slice(0, separatorIndex).trim();
1042
+ const value = line.slice(separatorIndex + 1).trim();
1043
+ applyFrontmatterField(patch, key, value);
1044
+ }
1045
+ index += 1;
1046
+ }
1047
+ if (lines[index] === "---") {
1048
+ index += 1;
1049
+ }
1050
+ }
1051
+ notes = lines.slice(index).join("\n").trim() || null;
1052
+ return {
1053
+ profilePatch: patch,
1054
+ notes
1055
+ };
1056
+ }
1057
+ function applyFrontmatterField(patch, key, value) {
1058
+ const normalized = value === "null" ? null : value;
1059
+ if (key === "name") {
1060
+ patch.name = normalized;
1061
+ } else if (key === "nickname") {
1062
+ patch.nickname = normalized;
1063
+ } else if (key === "timezone") {
1064
+ patch.timezone = normalized;
1065
+ } else if (key === "preferences") {
1066
+ patch.preferences = normalized;
1067
+ } else if (key === "personality") {
1068
+ patch.personality = normalized;
1069
+ } else if (key === "role") {
1070
+ patch.role = normalized;
1071
+ } else if (key === "visibility") {
1072
+ patch.visibility = normalized === "shared" ? "shared" : "private";
1073
+ }
1074
+ }
1075
+ function hasProfileDifference(current, patch) {
1076
+ const entries = Object.entries(patch);
1077
+ return entries.some(([key, value]) => value != null && current[key] !== value);
1078
+ }
724
1079
  function renderYamlList(rows) {
725
1080
  if (rows.length === 0) {
726
1081
  return "[]\n";
@@ -765,6 +1120,55 @@ function asTextResult(value) {
765
1120
  function asNullableString(value) {
766
1121
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
767
1122
  }
1123
+ function ensureObject(parent, key) {
1124
+ const current = parent[key];
1125
+ if (current && typeof current === "object" && !Array.isArray(current)) {
1126
+ return current;
1127
+ }
1128
+ const next = {};
1129
+ parent[key] = next;
1130
+ return next;
1131
+ }
1132
+ function ensureArrayIncludes(parent, key, value) {
1133
+ const current = Array.isArray(parent[key]) ? [...parent[key]] : [];
1134
+ if (current.includes(value)) {
1135
+ if (!Array.isArray(parent[key])) {
1136
+ parent[key] = current;
1137
+ }
1138
+ return false;
1139
+ }
1140
+ current.push(value);
1141
+ parent[key] = current;
1142
+ return true;
1143
+ }
1144
+ function getConfiguredAgentId(agent) {
1145
+ return asNullableString(agent.id) ?? asNullableString(agent.name) ?? asNullableString(agent.agentId);
1146
+ }
1147
+ function defaultProfileNotes() {
1148
+ return [
1149
+ "- \u5EFA\u8BAE\u79F0\u547C\uFF1A\u8001\u677F",
1150
+ "- \u65F6\u533A\uFF1AAsia/Shanghai",
1151
+ "- \u504F\u597D\uFF1A\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206",
1152
+ "- \u4F60\u53EF\u4EE5\u5728\u8FD9\u91CC\u7EE7\u7EED\u8865\u5145\u5DE5\u4F5C\u80CC\u666F\u3001\u8868\u8FBE\u4E60\u60EF\u3001\u7981\u5FCC\u548C\u957F\u671F\u504F\u597D\u3002"
1153
+ ].join("\n");
1154
+ }
1155
+ function escapeFrontmatter(value) {
1156
+ if (!value) {
1157
+ return "null";
1158
+ }
1159
+ return value.replace(/\n/g, "\\n");
1160
+ }
1161
+ function joinNotes(primary, secondary) {
1162
+ if (primary && secondary && primary !== secondary) {
1163
+ return `${primary}
1164
+
1165
+ ${secondary}`;
1166
+ }
1167
+ return primary ?? secondary ?? null;
1168
+ }
1169
+ function sanitizeFilename(value) {
1170
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
1171
+ }
768
1172
  function hashId(value) {
769
1173
  return (0, import_node_crypto.createHash)("sha1").update(value).digest("hex").slice(0, 24);
770
1174
  }
@@ -5,6 +5,7 @@
5
5
  "description": "Identity resolution, user profile binding, and admin profile tools for OpenClaw channels.",
6
6
  "version": "0.1.0",
7
7
  "main": "./dist/index.js",
8
+ "skills": ["./skills"],
8
9
  "configSchema": {
9
10
  "type": "object",
10
11
  "additionalProperties": true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamdra/bamdra-user-bind",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Identity resolution, user profile binding, and admin-safe profile tools for OpenClaw channels.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.bamdra.com",
@@ -16,6 +16,7 @@
16
16
  "types": "./dist/index.d.ts",
17
17
  "files": [
18
18
  "dist",
19
+ "skills",
19
20
  "openclaw.plugin.json",
20
21
  "README.md",
21
22
  "LICENSE"
@@ -31,6 +32,10 @@
31
32
  "engines": {
32
33
  "node": ">=22"
33
34
  },
35
+ "scripts": {
36
+ "bundle": "node ../bamdra-openclaw-memory/scripts/run-local-bin.mjs tsup && node -e \"const fs=require('node:fs');const path='./dist/index.js';const text=fs.readFileSync(path,'utf8').replace(/require\\\\(\\\"sqlite\\\"\\\\)/g,'require(\\\"node:sqlite\\\")');fs.writeFileSync(path,text);\"",
37
+ "prepublishOnly": "pnpm run bundle"
38
+ },
34
39
  "openclaw": {
35
40
  "id": "bamdra-user-bind",
36
41
  "type": "tool",
@@ -51,8 +56,5 @@
51
56
  "dependencies": {},
52
57
  "devDependencies": {
53
58
  "@types/node": "^24.5.2"
54
- },
55
- "scripts": {
56
- "bundle": "node ../bamdra-openclaw-memory/scripts/run-local-bin.mjs tsup && node -e \"const fs=require('node:fs');const path='./dist/index.js';const text=fs.readFileSync(path,'utf8').replace(/require\\\\(\\\"sqlite\\\"\\\\)/g,'require(\\\"node:sqlite\\\")');fs.writeFileSync(path,text);\""
57
59
  }
58
- }
60
+ }
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: bamdra-user-bind-admin
3
+ description: Use the admin-safe natural-language tools to inspect, fix, merge, and sync user bindings and profiles.
4
+ ---
5
+
6
+ # Bamdra User Bind Admin
7
+
8
+ Use this skill only on an authorized admin agent.
9
+
10
+ Its purpose is operational: inspect user bindings, repair incorrect profile fields, merge duplicate users, and check sync issues without exposing unrestricted bulk access.
11
+
12
+ ## Allowed Jobs
13
+
14
+ - query a specific user profile or binding by `userId`
15
+ - correct nickname, role, timezone, preferences, or personality fields
16
+ - merge duplicate user records
17
+ - inspect sync failures and identity resolution issues
18
+ - request a resync for a known user
19
+
20
+ ## Tooling
21
+
22
+ Use the admin tools in natural language:
23
+
24
+ - `user_bind_admin_query`
25
+ - `user_bind_admin_edit`
26
+ - `user_bind_admin_merge`
27
+ - `user_bind_admin_list_issues`
28
+ - `user_bind_admin_sync`
29
+
30
+ ## Good Requests
31
+
32
+ - “查询 user:u_123 的画像和绑定关系”
33
+ - “把 user:u_123 的称呼改成老板,时区改成 Asia/Shanghai”
34
+ - “合并 user:u_old 到 user:u_new”
35
+ - “列出最近的绑定失败问题”
36
+
37
+ ## Safety Rules
38
+
39
+ - do not perform blind bulk edits
40
+ - prefer specific target users over fuzzy descriptions
41
+ - if a request is ambiguous, narrow it before making changes
42
+ - remember that every admin action is auditable
43
+ - do not expose unrelated users when answering a narrow admin query
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: bamdra-user-bind-profile
3
+ description: Use the current bound user profile as the primary personalization layer for tone, address, timezone, and stable preferences.
4
+ ---
5
+
6
+ # Bamdra User Bind Profile
7
+
8
+ Treat `bamdra-user-bind` as the current user's editable personalization layer.
9
+
10
+ This skill is about the current bound user only. It should cover most of what a per-user `USER.md` would normally do, without leaking across users.
11
+
12
+ ## What To Use It For
13
+
14
+ - preferred form of address
15
+ - timezone-aware responses
16
+ - stable tone and formatting preferences
17
+ - role-aware defaults
18
+ - long-lived collaboration preferences
19
+ - private notes the user intentionally keeps in their profile
20
+
21
+ ## Profile Source Of Truth
22
+
23
+ The runtime profile comes from `bamdra-user-bind`.
24
+
25
+ Humans can edit the Markdown mirror for the current user, and the plugin will sync that into the controlled store. Treat the bound profile as more authoritative than guesswork.
26
+
27
+ ## Behavior Rules
28
+
29
+ - personalize naturally when the stored profile clearly helps
30
+ - use the stored nickname if the user has not asked for a different form of address in the current turn
31
+ - respect the stored timezone for scheduling, reminders, dates, and time-sensitive explanations
32
+ - prefer the stored tone/style preferences when shaping responses
33
+ - if the current turn conflicts with the stored profile, follow the current turn
34
+ - do not invent profile traits that are not present
35
+
36
+ ## Privacy Rules
37
+
38
+ - do not try to infer or mention other users' profiles
39
+ - do not describe the private profile store unless the user asks
40
+ - do not treat profile storage as a global contact directory
41
+ - do not ask for profile data that already exists in the bound profile unless it is stale or clearly insufficient
42
+
43
+ ## Updating Profile Information
44
+
45
+ When the user clearly provides a stable preference or asks to remember how to work with them, it is appropriate to update their own profile.
46
+
47
+ Good examples:
48
+
49
+ - “以后叫我老板”
50
+ - “我在 Asia/Shanghai 时区”
51
+ - “我偏好幽默一点,但别太浮夸”
52
+ - “我更喜欢先给结论,再展开”
53
+
54
+ Do not update the profile for transient moods, one-off formatting requests, or unstable short-term details.