@bamdra/bamdra-user-bind 0.1.12 → 0.1.14

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/dist/index.js CHANGED
@@ -39,6 +39,9 @@ var import_node_sqlite = require("node:sqlite");
39
39
  var import_node_url = require("url");
40
40
  var PLUGIN_ID = "bamdra-user-bind";
41
41
  var GLOBAL_API_KEY = "__OPENCLAW_BAMDRA_USER_BIND__";
42
+ var GLOBAL_RUNTIME_KEY = "__OPENCLAW_BAMDRA_USER_BIND_RUNTIME__";
43
+ var GLOBAL_RUNTIME_BRAND_KEY = "__OPENCLAW_BAMDRA_USER_BIND_RUNTIME_BRAND__";
44
+ var GLOBAL_PENDING_REFINE_KEY = "__OPENCLAW_BAMDRA_USER_BIND_PENDING_REFINE__";
42
45
  var PROFILE_SKILL_ID = "bamdra-user-bind-profile";
43
46
  var ADMIN_SKILL_ID = "bamdra-user-bind-admin";
44
47
  var SELF_TOOL_NAMES = [
@@ -46,6 +49,11 @@ var SELF_TOOL_NAMES = [
46
49
  "bamdra_user_bind_update_my_profile",
47
50
  "bamdra_user_bind_refresh_my_binding"
48
51
  ];
52
+ var SELF_TOOL_ALIASES = [
53
+ "user_bind_get_my_profile",
54
+ "user_bind_update_my_profile",
55
+ "user_bind_refresh_my_binding"
56
+ ];
49
57
  var ADMIN_TOOL_NAMES = [
50
58
  "bamdra_user_bind_admin_query",
51
59
  "bamdra_user_bind_admin_edit",
@@ -59,10 +67,22 @@ var TABLES = {
59
67
  issues: "bamdra_user_bind_issues",
60
68
  audits: "bamdra_user_bind_audits"
61
69
  };
62
- var REQUIRED_FEISHU_IDENTITY_SCOPES = [
63
- "contact:user.employee_id:readonly",
64
- "contact:user.base:readonly"
65
- ];
70
+ var SEMANTIC_PROFILE_CAPTURE_TIMEOUT_MS = readSemanticProfileCaptureTimeoutMs();
71
+ var SEMANTIC_PROFILE_BATCH_WINDOW_MS = 240;
72
+ var SEMANTIC_PROFILE_BATCH_MAX_FRAGMENTS = 4;
73
+ var SEMANTIC_PROFILE_BATCH_MAX_CHARS = 360;
74
+ var SEMANTIC_PROFILE_RETRY_MAX_ATTEMPTS = 3;
75
+ var CHANNELS_WITH_NATIVE_STABLE_IDS = /* @__PURE__ */ new Set([
76
+ "telegram",
77
+ "whatsapp",
78
+ "discord",
79
+ "googlechat",
80
+ "slack",
81
+ "mattermost",
82
+ "signal",
83
+ "imessage",
84
+ "msteams"
85
+ ]);
66
86
  function logUserBindEvent(event, details = {}) {
67
87
  try {
68
88
  console.info("[bamdra-user-bind]", event, JSON.stringify(details));
@@ -84,11 +104,15 @@ var UserBindStore = class {
84
104
  user_id TEXT PRIMARY KEY,
85
105
  name TEXT,
86
106
  gender TEXT,
107
+ birth_date TEXT,
108
+ birth_year TEXT,
109
+ age TEXT,
87
110
  email TEXT,
88
111
  avatar TEXT,
89
112
  nickname TEXT,
90
113
  preferences TEXT,
91
114
  personality TEXT,
115
+ interests TEXT,
92
116
  role TEXT,
93
117
  timezone TEXT,
94
118
  notes TEXT,
@@ -130,6 +154,11 @@ var UserBindStore = class {
130
154
  `);
131
155
  this.ensureColumn(TABLES.profiles, "timezone", "TEXT");
132
156
  this.ensureColumn(TABLES.profiles, "notes", "TEXT");
157
+ this.ensureColumn(TABLES.profiles, "birth_date", "TEXT");
158
+ this.ensureColumn(TABLES.profiles, "birth_year", "TEXT");
159
+ this.ensureColumn(TABLES.profiles, "age", "TEXT");
160
+ this.ensureColumn(TABLES.profiles, "interests", "TEXT");
161
+ this.migrateChannelScopedUserIds();
133
162
  }
134
163
  db;
135
164
  markdownSyncing = /* @__PURE__ */ new Set();
@@ -140,6 +169,16 @@ var UserBindStore = class {
140
169
  this.syncMarkdownToStore(userId);
141
170
  return this.getProfileFromDatabase(userId);
142
171
  }
172
+ listProfilesWithPendingSemanticRefine(limit = 20) {
173
+ const rows = this.db.prepare(`
174
+ SELECT user_id, name, gender, birth_date, birth_year, age, email, avatar, nickname, preferences, personality, interests, role, timezone, notes, visibility, source, updated_at
175
+ FROM ${TABLES.profiles}
176
+ WHERE notes LIKE '%[pending-profile-refine:%'
177
+ ORDER BY updated_at DESC
178
+ LIMIT ?
179
+ `).all(limit);
180
+ return rows.map(mapProfileRow);
181
+ }
143
182
  findBinding(channelType, openId) {
144
183
  if (!openId) {
145
184
  return null;
@@ -156,6 +195,22 @@ var UserBindStore = class {
156
195
  source: row.source
157
196
  };
158
197
  }
198
+ reconcileProvisionalIdentity(channelType, openId, stableUserId) {
199
+ if (!openId) {
200
+ return null;
201
+ }
202
+ const provisionalUserId = createProvisionalUserId(channelType, openId);
203
+ const scopedStableUserId = scopeUserId(channelType, stableUserId);
204
+ if (provisionalUserId === scopedStableUserId) {
205
+ return this.getProfile(scopedStableUserId);
206
+ }
207
+ const provisional = this.getProfile(provisionalUserId);
208
+ const stable = this.getProfile(scopedStableUserId);
209
+ if (!provisional || !stable) {
210
+ return stable;
211
+ }
212
+ return this.mergeUsers(provisionalUserId, scopedStableUserId);
213
+ }
159
214
  listIssues() {
160
215
  return this.db.prepare(`
161
216
  SELECT id, kind, user_id, details, status, updated_at
@@ -190,38 +245,45 @@ var UserBindStore = class {
190
245
  );
191
246
  }
192
247
  upsertIdentity(args) {
193
- if (!this.markdownSyncing.has(args.userId)) {
194
- this.syncMarkdownToStore(args.userId);
195
- }
248
+ const scopedUserId = scopeUserId(args.channelType, args.userId);
196
249
  const now = (/* @__PURE__ */ new Date()).toISOString();
197
- const current = this.getProfileFromDatabase(args.userId);
250
+ const current = this.getProfileFromDatabase(scopedUserId);
251
+ const externalUserId = args.openId ?? getExternalUserId(args.channelType, args.userId);
198
252
  const next = {
199
- userId: args.userId,
253
+ userId: scopedUserId,
200
254
  name: args.profilePatch.name ?? current?.name ?? null,
201
255
  gender: args.profilePatch.gender ?? current?.gender ?? null,
256
+ birthDate: args.profilePatch.birthDate ?? current?.birthDate ?? null,
257
+ birthYear: args.profilePatch.birthYear ?? current?.birthYear ?? null,
258
+ age: args.profilePatch.age ?? current?.age ?? null,
202
259
  email: args.profilePatch.email ?? current?.email ?? null,
203
260
  avatar: args.profilePatch.avatar ?? current?.avatar ?? null,
204
- nickname: args.profilePatch.nickname ?? current?.nickname ?? "\u8001\u677F",
205
- preferences: args.profilePatch.preferences ?? current?.preferences ?? "\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206",
261
+ nickname: args.profilePatch.nickname ?? current?.nickname ?? null,
262
+ preferences: args.profilePatch.preferences ?? current?.preferences ?? null,
206
263
  personality: args.profilePatch.personality ?? current?.personality ?? null,
264
+ interests: args.profilePatch.interests ?? current?.interests ?? null,
207
265
  role: args.profilePatch.role ?? current?.role ?? null,
208
- timezone: args.profilePatch.timezone ?? current?.timezone ?? "Asia/Shanghai",
266
+ timezone: args.profilePatch.timezone ?? current?.timezone ?? getServerTimezone(),
209
267
  notes: args.profilePatch.notes ?? current?.notes ?? defaultProfileNotes(),
210
268
  visibility: args.profilePatch.visibility ?? current?.visibility ?? "private",
211
269
  source: args.source,
212
270
  updatedAt: now
213
271
  };
214
272
  this.db.prepare(`
215
- INSERT INTO ${TABLES.profiles} (user_id, name, gender, email, avatar, nickname, preferences, personality, role, timezone, notes, visibility, source, updated_at)
216
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
273
+ INSERT INTO ${TABLES.profiles} (user_id, name, gender, birth_date, birth_year, age, email, avatar, nickname, preferences, personality, interests, role, timezone, notes, visibility, source, updated_at)
274
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
217
275
  ON CONFLICT(user_id) DO UPDATE SET
218
276
  name = excluded.name,
219
277
  gender = excluded.gender,
278
+ birth_date = excluded.birth_date,
279
+ birth_year = excluded.birth_year,
280
+ age = excluded.age,
220
281
  email = excluded.email,
221
282
  avatar = excluded.avatar,
222
283
  nickname = excluded.nickname,
223
284
  preferences = excluded.preferences,
224
285
  personality = excluded.personality,
286
+ interests = excluded.interests,
225
287
  role = excluded.role,
226
288
  timezone = excluded.timezone,
227
289
  notes = excluded.notes,
@@ -232,11 +294,15 @@ var UserBindStore = class {
232
294
  next.userId,
233
295
  next.name,
234
296
  next.gender,
297
+ next.birthDate,
298
+ next.birthYear,
299
+ next.age,
235
300
  next.email,
236
301
  next.avatar,
237
302
  next.nickname,
238
303
  next.preferences,
239
304
  next.personality,
305
+ next.interests,
240
306
  next.role,
241
307
  next.timezone,
242
308
  next.notes,
@@ -257,10 +323,10 @@ var UserBindStore = class {
257
323
  updated_at = excluded.updated_at
258
324
  `).run(
259
325
  bindingId,
260
- args.userId,
326
+ scopedUserId,
261
327
  args.channelType,
262
328
  args.openId,
263
- args.userId,
329
+ externalUserId,
264
330
  null,
265
331
  args.source,
266
332
  now
@@ -286,6 +352,26 @@ var UserBindStore = class {
286
352
  }
287
353
  });
288
354
  }
355
+ replaceProfileNotes(userId, notes, source) {
356
+ const current = this.getProfile(userId);
357
+ if (!current) {
358
+ throw new Error(`Unknown user ${userId}`);
359
+ }
360
+ const next = {
361
+ ...current,
362
+ notes,
363
+ source,
364
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
365
+ };
366
+ this.db.prepare(`
367
+ UPDATE ${TABLES.profiles}
368
+ SET notes = ?, source = ?, updated_at = ?
369
+ WHERE user_id = ?
370
+ `).run(next.notes, next.source, next.updatedAt, current.userId);
371
+ this.writeProfileMarkdown(next);
372
+ this.writeExports();
373
+ return next;
374
+ }
289
375
  mergeUsers(fromUserId, intoUserId) {
290
376
  const from = this.getProfile(fromUserId);
291
377
  const into = this.getProfile(intoUserId);
@@ -300,11 +386,15 @@ var UserBindStore = class {
300
386
  profilePatch: {
301
387
  name: into.name ?? from.name,
302
388
  gender: into.gender ?? from.gender,
389
+ birthDate: into.birthDate ?? from.birthDate,
390
+ birthYear: into.birthYear ?? from.birthYear,
391
+ age: into.age ?? from.age,
303
392
  email: into.email ?? from.email,
304
393
  avatar: into.avatar ?? from.avatar,
305
394
  nickname: into.nickname ?? from.nickname,
306
395
  preferences: into.preferences ?? from.preferences,
307
396
  personality: into.personality ?? from.personality,
397
+ interests: into.interests ?? from.interests,
308
398
  role: into.role ?? from.role,
309
399
  timezone: into.timezone ?? from.timezone,
310
400
  notes: joinNotes(into.notes, from.notes),
@@ -322,7 +412,7 @@ var UserBindStore = class {
322
412
  }
323
413
  getProfileFromDatabase(userId) {
324
414
  const row = this.db.prepare(`
325
- SELECT user_id, name, gender, email, avatar, nickname, preferences, personality, role, timezone, notes, visibility, source, updated_at
415
+ SELECT user_id, name, gender, birth_date, birth_year, age, email, avatar, nickname, preferences, personality, interests, role, timezone, notes, visibility, source, updated_at
326
416
  FROM ${TABLES.profiles} WHERE user_id = ?
327
417
  `).get(userId);
328
418
  return row ? mapProfileRow(row) : null;
@@ -346,8 +436,6 @@ var UserBindStore = class {
346
436
  }
347
437
  const current = this.getProfileFromDatabase(userId);
348
438
  const parsed = parseProfileMarkdown((0, import_node_fs.readFileSync)(markdownPath, "utf8"));
349
- const markdownMtime = (0, import_node_fs.statSync)(markdownPath).mtime.toISOString();
350
- const dbTime = current?.updatedAt ?? (/* @__PURE__ */ new Date(0)).toISOString();
351
439
  const patch = {
352
440
  ...parsed.profilePatch,
353
441
  notes: parsed.notes
@@ -357,7 +445,7 @@ var UserBindStore = class {
357
445
  userId,
358
446
  channelType: "manual",
359
447
  openId: null,
360
- source: "markdown-profile",
448
+ source: parsed.source ?? "markdown-profile",
361
449
  profilePatch: {
362
450
  ...patch,
363
451
  visibility: patch.visibility ?? "private"
@@ -365,14 +453,53 @@ var UserBindStore = class {
365
453
  });
366
454
  return;
367
455
  }
368
- if (markdownMtime <= dbTime && !hasProfileDifference(current, patch)) {
456
+ const markdownMtime = (0, import_node_fs.statSync)(markdownPath).mtime.toISOString();
457
+ const markdownHash = computeProfilePayloadHash({
458
+ name: patch.name ?? null,
459
+ gender: patch.gender ?? null,
460
+ birthDate: patch.birthDate ?? null,
461
+ birthYear: patch.birthYear ?? null,
462
+ age: patch.age ?? null,
463
+ nickname: patch.nickname ?? null,
464
+ timezone: patch.timezone ?? null,
465
+ preferences: patch.preferences ?? null,
466
+ personality: patch.personality ?? null,
467
+ interests: patch.interests ?? null,
468
+ role: patch.role ?? null,
469
+ visibility: patch.visibility ?? current.visibility
470
+ }, patch.notes ?? null);
471
+ const currentHash = computeProfilePayloadHash({
472
+ name: current.name,
473
+ gender: current.gender,
474
+ birthDate: current.birthDate,
475
+ birthYear: current.birthYear,
476
+ age: current.age,
477
+ nickname: current.nickname,
478
+ timezone: current.timezone,
479
+ preferences: current.preferences,
480
+ personality: current.personality,
481
+ interests: current.interests,
482
+ role: current.role,
483
+ visibility: current.visibility
484
+ }, current.notes);
485
+ if (markdownHash === currentHash) {
486
+ return;
487
+ }
488
+ if (parsed.syncHash && parsed.syncHash === markdownHash) {
489
+ return;
490
+ }
491
+ const dbTime = current.updatedAt;
492
+ if (parsed.updatedAt && parsed.updatedAt <= dbTime && markdownMtime <= dbTime) {
493
+ return;
494
+ }
495
+ if (!parsed.syncHash && markdownMtime <= dbTime) {
369
496
  return;
370
497
  }
371
498
  this.upsertIdentity({
372
499
  userId,
373
500
  channelType: "manual",
374
501
  openId: null,
375
- source: "markdown-profile",
502
+ source: parsed.source ?? "markdown-profile",
376
503
  profilePatch: {
377
504
  ...current,
378
505
  ...patch
@@ -389,7 +516,7 @@ var UserBindStore = class {
389
516
  }
390
517
  writeExports() {
391
518
  const profiles = this.db.prepare(`
392
- SELECT user_id, name, gender, email, avatar, nickname, preferences, personality, role, timezone, notes, visibility, source, updated_at
519
+ SELECT user_id, name, gender, birth_date, birth_year, age, email, avatar, nickname, preferences, personality, interests, role, timezone, notes, visibility, source, updated_at
393
520
  FROM ${TABLES.profiles}
394
521
  ORDER BY updated_at DESC
395
522
  `).all();
@@ -408,6 +535,102 @@ var UserBindStore = class {
408
535
  }
409
536
  this.db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
410
537
  }
538
+ migrateChannelScopedUserIds() {
539
+ const rows = this.db.prepare(`
540
+ SELECT DISTINCT user_id, channel_type
541
+ FROM ${TABLES.bindings}
542
+ WHERE user_id IS NOT NULL AND channel_type IS NOT NULL
543
+ `).all();
544
+ const renames = rows.map((row) => {
545
+ const currentUserId = String(row.user_id ?? "");
546
+ const channelType = String(row.channel_type ?? "");
547
+ const scopedUserId = scopeUserId(channelType, currentUserId);
548
+ if (!currentUserId || !channelType || scopedUserId === currentUserId) {
549
+ return null;
550
+ }
551
+ return { currentUserId, scopedUserId };
552
+ }).filter((item) => item != null);
553
+ if (renames.length === 0) {
554
+ return;
555
+ }
556
+ for (const { currentUserId, scopedUserId } of renames) {
557
+ this.renameUserId(currentUserId, scopedUserId);
558
+ }
559
+ this.writeExports();
560
+ }
561
+ renameUserId(fromUserId, toUserId) {
562
+ if (fromUserId === toUserId) {
563
+ return;
564
+ }
565
+ const existingTarget = this.getProfileFromDatabase(toUserId);
566
+ const sourceProfile = this.getProfileFromDatabase(fromUserId);
567
+ if (sourceProfile && !existingTarget) {
568
+ this.db.prepare(`UPDATE ${TABLES.profiles} SET user_id = ? WHERE user_id = ?`).run(toUserId, fromUserId);
569
+ } else if (sourceProfile && existingTarget) {
570
+ const merged = {
571
+ ...sourceProfile,
572
+ ...existingTarget,
573
+ userId: toUserId,
574
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
575
+ source: existingTarget.source || sourceProfile.source,
576
+ notes: joinNotes(existingTarget.notes, sourceProfile.notes)
577
+ };
578
+ this.db.prepare(`DELETE FROM ${TABLES.profiles} WHERE user_id = ?`).run(fromUserId);
579
+ this.db.prepare(`
580
+ INSERT INTO ${TABLES.profiles} (user_id, name, gender, birth_date, birth_year, age, email, avatar, nickname, preferences, personality, interests, role, timezone, notes, visibility, source, updated_at)
581
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
582
+ ON CONFLICT(user_id) DO UPDATE SET
583
+ name = excluded.name,
584
+ gender = excluded.gender,
585
+ birth_date = excluded.birth_date,
586
+ birth_year = excluded.birth_year,
587
+ age = excluded.age,
588
+ email = excluded.email,
589
+ avatar = excluded.avatar,
590
+ nickname = excluded.nickname,
591
+ preferences = excluded.preferences,
592
+ personality = excluded.personality,
593
+ interests = excluded.interests,
594
+ role = excluded.role,
595
+ timezone = excluded.timezone,
596
+ notes = excluded.notes,
597
+ visibility = excluded.visibility,
598
+ source = excluded.source,
599
+ updated_at = excluded.updated_at
600
+ `).run(
601
+ merged.userId,
602
+ merged.name,
603
+ merged.gender,
604
+ merged.birthDate,
605
+ merged.birthYear,
606
+ merged.age,
607
+ merged.email,
608
+ merged.avatar,
609
+ merged.nickname,
610
+ merged.preferences,
611
+ merged.personality,
612
+ merged.interests,
613
+ merged.role,
614
+ merged.timezone,
615
+ merged.notes,
616
+ merged.visibility,
617
+ merged.source,
618
+ merged.updatedAt
619
+ );
620
+ }
621
+ this.db.prepare(`UPDATE ${TABLES.bindings} SET user_id = ?, external_user_id = ?, updated_at = ? WHERE user_id = ?`).run(
622
+ toUserId,
623
+ getExternalUserId(extractChannelFromScopedUserId(toUserId) ?? "manual", toUserId),
624
+ (/* @__PURE__ */ new Date()).toISOString(),
625
+ fromUserId
626
+ );
627
+ const oldMarkdownPath = this.profileMarkdownPath(fromUserId);
628
+ const newMarkdownPath = this.profileMarkdownPath(toUserId);
629
+ if ((0, import_node_fs.existsSync)(oldMarkdownPath) && !(0, import_node_fs.existsSync)(newMarkdownPath)) {
630
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(newMarkdownPath), { recursive: true });
631
+ (0, import_node_fs.cpSync)(oldMarkdownPath, newMarkdownPath);
632
+ }
633
+ }
411
634
  };
412
635
  var UserBindRuntime = class {
413
636
  constructor(host, inputConfig) {
@@ -423,13 +646,51 @@ var UserBindRuntime = class {
423
646
  config;
424
647
  sessionCache = /* @__PURE__ */ new Map();
425
648
  bindingCache = /* @__PURE__ */ new Map();
426
- feishuScopeStatus = null;
427
- bitableMirror = null;
649
+ semanticCaptureCache = /* @__PURE__ */ new Map();
650
+ semanticCaptureInFlight = /* @__PURE__ */ new Set();
651
+ semanticCaptureRetryTimers = /* @__PURE__ */ new Map();
652
+ semanticCaptureRetryAttempts = /* @__PURE__ */ new Map();
653
+ pendingSemanticCaptures = /* @__PURE__ */ new Map();
654
+ pendingBindingResolutions = /* @__PURE__ */ new Map();
428
655
  feishuTokenCache = /* @__PURE__ */ new Map();
656
+ globalPendingSemanticRefines = getGlobalPendingSemanticRefines();
657
+ pendingBindingTimer = null;
658
+ pendingBindingKickTimer = null;
659
+ pendingSemanticSweepTimer = null;
660
+ pendingBindingSweepInFlight = false;
661
+ pendingSemanticSweepInFlight = false;
662
+ registered = false;
429
663
  close() {
664
+ if (this.pendingBindingTimer) {
665
+ clearInterval(this.pendingBindingTimer);
666
+ this.pendingBindingTimer = null;
667
+ }
668
+ if (this.pendingBindingKickTimer) {
669
+ clearTimeout(this.pendingBindingKickTimer);
670
+ this.pendingBindingKickTimer = null;
671
+ }
672
+ if (this.pendingSemanticSweepTimer) {
673
+ clearTimeout(this.pendingSemanticSweepTimer);
674
+ this.pendingSemanticSweepTimer = null;
675
+ }
676
+ for (const pending of this.pendingSemanticCaptures.values()) {
677
+ if (pending.timer) {
678
+ clearTimeout(pending.timer);
679
+ }
680
+ }
681
+ this.pendingSemanticCaptures.clear();
682
+ for (const timer of this.semanticCaptureRetryTimers.values()) {
683
+ clearTimeout(timer);
684
+ }
685
+ this.semanticCaptureRetryTimers.clear();
686
+ this.semanticCaptureRetryAttempts.clear();
430
687
  this.store.close();
431
688
  }
432
689
  register() {
690
+ if (this.registered) {
691
+ return;
692
+ }
693
+ this.registered = true;
433
694
  queueMicrotask(() => {
434
695
  try {
435
696
  bootstrapOpenClawHost(this.config);
@@ -438,15 +699,25 @@ var UserBindRuntime = class {
438
699
  });
439
700
  this.registerHooks();
440
701
  this.registerTools();
702
+ this.startPendingBindingWorker();
703
+ this.schedulePendingSemanticSweep();
441
704
  exposeGlobalApi(this);
442
705
  }
443
706
  getIdentityForSession(sessionId) {
444
707
  return this.sessionCache.get(sessionId) ?? null;
445
708
  }
446
- async resolveFromContext(context) {
447
- const parsed = parseIdentityContext(context);
709
+ async resolveFromContext(context, options = {}) {
710
+ const parsed = parseIdentityContext(enrichIdentityContext(context));
448
711
  if (parsed.sessionId && !parsed.channelType) {
449
- return this.sessionCache.get(parsed.sessionId) ?? null;
712
+ const cached2 = this.sessionCache.get(parsed.sessionId) ?? null;
713
+ if (cached2 && isProvisionalScopedUserId(cached2.userId) && cached2.senderOpenId) {
714
+ return this.resolveFromContext({
715
+ sessionId: parsed.sessionId,
716
+ channel: { type: cached2.channelType },
717
+ sender: { id: cached2.senderOpenId, name: cached2.senderName }
718
+ });
719
+ }
720
+ return cached2;
450
721
  }
451
722
  if (!parsed.sessionId || !parsed.channelType) {
452
723
  return null;
@@ -454,47 +725,90 @@ var UserBindRuntime = class {
454
725
  const cacheKey = `${parsed.channelType}:${parsed.openId ?? parsed.sessionId}`;
455
726
  const cached = this.bindingCache.get(cacheKey);
456
727
  if (cached && cached.expiresAt > Date.now()) {
457
- this.sessionCache.set(parsed.sessionId, cached.identity);
458
- return cached.identity;
728
+ if (parsed.openId && (isProvisionalScopedUserId(cached.identity.userId) || cached.identity.source === "synthetic-fallback")) {
729
+ const rebound = this.store.findBinding(parsed.channelType, parsed.openId);
730
+ if (!rebound || rebound.userId === cached.identity.userId) {
731
+ this.sessionCache.set(parsed.sessionId, cached.identity);
732
+ return cached.identity;
733
+ }
734
+ this.bindingCache.delete(cacheKey);
735
+ } else {
736
+ this.sessionCache.set(parsed.sessionId, cached.identity);
737
+ return cached.identity;
738
+ }
459
739
  }
460
740
  const binding = this.store.findBinding(parsed.channelType, parsed.openId);
461
741
  let userId = binding?.userId ?? null;
462
742
  let source = binding?.source ?? "local";
743
+ const provisionalUserId = parsed.openId ? createProvisionalUserId(parsed.channelType, parsed.openId) : null;
463
744
  let remoteProfilePatch = {};
464
745
  if (parsed.channelType === "feishu" && parsed.openId) {
465
- const scopeStatus = await this.ensureFeishuScopeStatus();
466
- if (scopeStatus.missingIdentityScopes.length > 0) {
467
- const details = `Missing Feishu scopes: ${scopeStatus.missingIdentityScopes.join(", ")}`;
468
- logUserBindEvent("feishu-scope-missing", {
469
- openId: parsed.openId,
470
- missingScopes: scopeStatus.missingIdentityScopes
471
- });
472
- this.store.recordIssue("feishu-scope-missing", details);
473
- }
474
746
  if (!userId) {
475
- const remote = await this.tryResolveFeishuUser(parsed.openId);
476
- if (remote?.userId) {
477
- userId = remote.userId;
478
- source = remote.source;
479
- remoteProfilePatch = remote.profilePatch;
747
+ if (options.allowRemoteLookup) {
748
+ const remote = await this.tryResolveFeishuUser(parsed.openId);
749
+ if (remote?.userId) {
750
+ userId = remote.userId;
751
+ source = remote.source;
752
+ remoteProfilePatch = remote.profilePatch;
753
+ } else if (remote?.source === "feishu-contact-scope-missing") {
754
+ source = remote.source;
755
+ remoteProfilePatch = remote.profilePatch;
756
+ this.enqueuePendingBindingResolution(parsed.channelType, parsed.openId, remote.source);
757
+ } else {
758
+ this.store.recordIssue("feishu-resolution", `Failed to resolve real user id for ${parsed.openId}`);
759
+ this.enqueuePendingBindingResolution(parsed.channelType, parsed.openId, "feishu-resolution-miss");
760
+ }
480
761
  } else {
481
- this.store.recordIssue("feishu-resolution", `Failed to resolve real user id for ${parsed.openId}`);
762
+ this.enqueuePendingBindingResolution(parsed.channelType, parsed.openId, "feishu-deferred-resolution");
482
763
  }
483
764
  }
484
765
  }
766
+ const profilePatch = {
767
+ name: parsed.senderName,
768
+ ...remoteProfilePatch
769
+ };
770
+ if (!userId && parsed.openId && parsed.channelType && CHANNELS_WITH_NATIVE_STABLE_IDS.has(parsed.channelType)) {
771
+ userId = `${parsed.channelType}:${parsed.openId}`;
772
+ source = "channel-native";
773
+ }
774
+ if (!userId && provisionalUserId) {
775
+ userId = provisionalUserId;
776
+ source = "provisional-openid";
777
+ this.enqueuePendingBindingResolution(parsed.channelType, parsed.openId, source);
778
+ }
779
+ const shouldPersistIdentity = Boolean(userId);
485
780
  if (!userId) {
486
- userId = `${parsed.channelType}:${parsed.openId ?? parsed.sessionId}`;
781
+ userId = createEphemeralUserId(parsed.channelType, parsed.openId, parsed.sessionId);
487
782
  source = "synthetic-fallback";
488
783
  }
489
- const profile = this.store.upsertIdentity({
784
+ if (shouldPersistIdentity) {
785
+ this.store.getProfile(userId);
786
+ }
787
+ const profile = shouldPersistIdentity ? (() => {
788
+ let persisted = this.store.upsertIdentity({
789
+ userId,
790
+ channelType: parsed.channelType,
791
+ openId: parsed.openId,
792
+ source,
793
+ profilePatch
794
+ });
795
+ if (provisionalUserId && persisted.userId !== provisionalUserId && source !== "provisional-openid") {
796
+ const reconciled = this.store.reconcileProvisionalIdentity(parsed.channelType, parsed.openId, persisted.userId);
797
+ if (reconciled) {
798
+ persisted = reconciled;
799
+ logUserBindEvent("provisional-profile-merged", {
800
+ provisionalUserId,
801
+ stableUserId: persisted.userId,
802
+ sessionId: parsed.sessionId
803
+ });
804
+ }
805
+ }
806
+ return persisted;
807
+ })() : buildLightweightProfile({
490
808
  userId,
491
- channelType: parsed.channelType,
492
- openId: parsed.openId,
493
809
  source,
494
- profilePatch: {
495
- name: parsed.senderName,
496
- ...remoteProfilePatch
497
- }
810
+ current: null,
811
+ profilePatch
498
812
  });
499
813
  const identity = {
500
814
  sessionId: parsed.sessionId,
@@ -511,26 +825,151 @@ var UserBindRuntime = class {
511
825
  expiresAt: Date.now() + this.config.cacheTtlMs,
512
826
  identity
513
827
  });
514
- if (parsed.channelType === "feishu") {
515
- await this.syncFeishuMirror(identity);
516
- }
517
828
  return identity;
518
829
  }
830
+ startPendingBindingWorker() {
831
+ if (this.pendingBindingTimer) {
832
+ return;
833
+ }
834
+ this.pendingBindingTimer = setInterval(() => {
835
+ void this.runPendingBindingSweep();
836
+ }, 60 * 1e3);
837
+ this.pendingBindingTimer.unref?.();
838
+ }
839
+ schedulePendingBindingSweep(delayMs) {
840
+ if (this.pendingBindingKickTimer) {
841
+ return;
842
+ }
843
+ this.pendingBindingKickTimer = setTimeout(() => {
844
+ this.pendingBindingKickTimer = null;
845
+ void this.runPendingBindingSweep();
846
+ }, delayMs);
847
+ this.pendingBindingKickTimer.unref?.();
848
+ }
849
+ enqueuePendingBindingResolution(channelType, openId, reason) {
850
+ if (!openId) {
851
+ return;
852
+ }
853
+ const key = `${channelType}:${openId}`;
854
+ const current = this.pendingBindingResolutions.get(key);
855
+ if (current) {
856
+ current.reason = reason;
857
+ current.nextAttemptAt = Math.min(current.nextAttemptAt, Date.now() + 5e3);
858
+ this.pendingBindingResolutions.set(key, current);
859
+ } else {
860
+ this.pendingBindingResolutions.set(key, {
861
+ channelType,
862
+ openId,
863
+ reason,
864
+ attempts: 0,
865
+ nextAttemptAt: Date.now() + 5e3
866
+ });
867
+ }
868
+ this.schedulePendingBindingSweep(5e3);
869
+ }
870
+ async runPendingBindingSweep() {
871
+ if (this.pendingBindingSweepInFlight || this.pendingBindingResolutions.size === 0) {
872
+ return;
873
+ }
874
+ this.pendingBindingSweepInFlight = true;
875
+ try {
876
+ const now = Date.now();
877
+ for (const [key, pending] of this.pendingBindingResolutions.entries()) {
878
+ if (pending.nextAttemptAt > now) {
879
+ continue;
880
+ }
881
+ const existing = this.store.findBinding(pending.channelType, pending.openId);
882
+ if (existing && !isProvisionalScopedUserId(existing.userId)) {
883
+ this.store.upsertIdentity({
884
+ userId: existing.userId,
885
+ channelType: pending.channelType,
886
+ openId: pending.openId,
887
+ source: existing.source,
888
+ profilePatch: {}
889
+ });
890
+ this.store.reconcileProvisionalIdentity(pending.channelType, pending.openId, existing.userId);
891
+ this.dropCachedOpenIdIdentity(pending.channelType, pending.openId);
892
+ this.pendingBindingResolutions.delete(key);
893
+ logUserBindEvent("pending-binding-reconciled", {
894
+ channelType: pending.channelType,
895
+ openId: pending.openId,
896
+ userId: existing.userId
897
+ });
898
+ continue;
899
+ }
900
+ if (pending.channelType !== "feishu") {
901
+ this.pendingBindingResolutions.delete(key);
902
+ continue;
903
+ }
904
+ const remote = await this.tryResolveFeishuUser(pending.openId);
905
+ if (remote?.userId) {
906
+ this.store.upsertIdentity({
907
+ userId: remote.userId,
908
+ channelType: pending.channelType,
909
+ openId: pending.openId,
910
+ source: remote.source,
911
+ profilePatch: remote.profilePatch
912
+ });
913
+ this.store.reconcileProvisionalIdentity(pending.channelType, pending.openId, remote.userId);
914
+ this.dropCachedOpenIdIdentity(pending.channelType, pending.openId);
915
+ this.pendingBindingResolutions.delete(key);
916
+ logUserBindEvent("pending-binding-resolved", {
917
+ channelType: pending.channelType,
918
+ openId: pending.openId,
919
+ userId: remote.userId,
920
+ source: remote.source
921
+ });
922
+ continue;
923
+ }
924
+ pending.attempts += 1;
925
+ pending.nextAttemptAt = Date.now() + getPendingBindingRetryDelayMs(remote?.source ?? pending.reason, pending.attempts);
926
+ this.pendingBindingResolutions.set(key, pending);
927
+ }
928
+ } finally {
929
+ this.pendingBindingSweepInFlight = false;
930
+ }
931
+ }
932
+ dropCachedOpenIdIdentity(channelType, openId) {
933
+ const cacheKey = `${channelType}:${openId}`;
934
+ this.bindingCache.delete(cacheKey);
935
+ for (const [sessionId, identity] of this.sessionCache.entries()) {
936
+ if (identity.channelType === channelType && identity.senderOpenId === openId) {
937
+ this.sessionCache.delete(sessionId);
938
+ }
939
+ }
940
+ }
519
941
  async getMyProfile(context) {
520
942
  const identity = await this.requireCurrentIdentity(context);
521
943
  return identity.profile;
522
944
  }
523
- async updateMyProfile(context, patch) {
945
+ async updateMyProfile(context, patch, operations) {
524
946
  const identity = await this.requireCurrentIdentity(context);
525
- const updated = this.store.updateProfile(identity.userId, {
526
- ...patch,
947
+ const fallbackOperations = extractProfilePatchOperations(context && typeof context === "object" ? context : {});
948
+ const nextPatch = applyProfilePatchOperations(identity.profile, patch, operations ?? fallbackOperations);
949
+ const updated = identity.source === "synthetic-fallback" ? this.store.upsertIdentity({
950
+ userId: createStableLocalUserId(identity.channelType, identity.senderOpenId, identity.sessionId),
951
+ channelType: identity.channelType,
952
+ openId: identity.senderOpenId,
953
+ source: "self-bootstrap",
954
+ profilePatch: {
955
+ ...identity.profile,
956
+ ...nextPatch
957
+ }
958
+ }) : this.store.updateProfile(identity.userId, {
959
+ ...nextPatch,
527
960
  source: "self-update"
528
961
  });
529
962
  const nextIdentity = {
530
963
  ...identity,
964
+ userId: updated.userId,
965
+ source: updated.source,
531
966
  profile: updated
532
967
  };
533
968
  this.sessionCache.set(identity.sessionId, nextIdentity);
969
+ this.bindingCache.set(`${identity.channelType}:${identity.senderOpenId ?? identity.sessionId}`, {
970
+ expiresAt: Date.now() + this.config.cacheTtlMs,
971
+ identity: nextIdentity
972
+ });
534
973
  return updated;
535
974
  }
536
975
  async refreshMyBinding(context) {
@@ -540,12 +979,366 @@ var UserBindRuntime = class {
540
979
  sessionId: identity.sessionId,
541
980
  sender: { id: identity.senderOpenId, name: identity.senderName },
542
981
  channel: { type: identity.channelType }
543
- });
982
+ }, { allowRemoteLookup: true });
544
983
  if (!refreshed) {
545
984
  throw new Error("Unable to refresh binding for current session");
546
985
  }
547
986
  return refreshed;
548
987
  }
988
+ async captureProfileFromMessage(context, identity) {
989
+ const utteranceText = extractUserUtterance(context);
990
+ if (!utteranceText) {
991
+ logUserBindEvent("semantic-profile-capture-skipped-no-utterance", {
992
+ userId: identity.userId,
993
+ sessionId: identity.sessionId
994
+ });
995
+ return;
996
+ }
997
+ const messageText = buildSemanticCaptureInput(context, utteranceText);
998
+ if (!messageText) {
999
+ return;
1000
+ }
1001
+ if (shouldIgnoreSemanticProfileCaptureCandidate(utteranceText) && messageText === utteranceText) {
1002
+ logUserBindEvent("semantic-profile-capture-skipped-trivial-utterance", {
1003
+ userId: identity.userId,
1004
+ sessionId: identity.sessionId,
1005
+ messagePreview: utteranceText.slice(0, 120)
1006
+ });
1007
+ return;
1008
+ }
1009
+ this.enqueueSemanticProfileCapture(identity, messageText);
1010
+ }
1011
+ enqueueSemanticProfileCapture(identity, messageText) {
1012
+ const sessionId = identity.sessionId;
1013
+ const pending = this.pendingSemanticCaptures.get(sessionId) ?? {
1014
+ identity,
1015
+ messages: [],
1016
+ timer: null
1017
+ };
1018
+ pending.identity = identity;
1019
+ pending.messages = appendSemanticCaptureCandidate(pending.messages, messageText);
1020
+ if (pending.timer) {
1021
+ clearTimeout(pending.timer);
1022
+ }
1023
+ pending.timer = setTimeout(() => {
1024
+ pending.timer = null;
1025
+ void this.flushSemanticProfileCapture(sessionId).catch((error) => {
1026
+ logUserBindEvent("semantic-profile-capture-flush-failed", {
1027
+ userId: identity.userId,
1028
+ sessionId,
1029
+ message: error instanceof Error ? error.message : String(error)
1030
+ });
1031
+ });
1032
+ }, SEMANTIC_PROFILE_BATCH_WINDOW_MS);
1033
+ this.pendingSemanticCaptures.set(sessionId, pending);
1034
+ }
1035
+ async flushSemanticProfileCapture(sessionId) {
1036
+ const pending = this.pendingSemanticCaptures.get(sessionId);
1037
+ if (!pending) {
1038
+ return;
1039
+ }
1040
+ this.pendingSemanticCaptures.delete(sessionId);
1041
+ const identity = pending.identity;
1042
+ const messageText = buildSemanticProfileBatchText(pending.messages);
1043
+ if (!messageText) {
1044
+ return;
1045
+ }
1046
+ if (shouldSkipSemanticProfileCapture(messageText)) {
1047
+ logUserBindEvent("semantic-profile-capture-skipped-insufficient-signal", {
1048
+ userId: identity.userId,
1049
+ sessionId,
1050
+ messagePreview: messageText.slice(0, 120)
1051
+ });
1052
+ return;
1053
+ }
1054
+ const fingerprint = hashId(`${sessionId}:${messageText}`);
1055
+ this.pruneSemanticCaptureCache();
1056
+ if (this.semanticCaptureInFlight.has(fingerprint)) {
1057
+ return;
1058
+ }
1059
+ const processedAt = this.semanticCaptureCache.get(fingerprint);
1060
+ if (processedAt && processedAt > Date.now() - 12 * 60 * 60 * 1e3) {
1061
+ return;
1062
+ }
1063
+ await this.runSemanticProfileCapture(identity, sessionId, messageText, fingerprint);
1064
+ }
1065
+ async runSemanticProfileCapture(identity, sessionId, messageText, fingerprint) {
1066
+ this.semanticCaptureInFlight.add(fingerprint);
1067
+ try {
1068
+ const extraction = await inferSemanticProfileExtraction(messageText, identity.profile);
1069
+ if (!extraction?.shouldUpdate) {
1070
+ logUserBindEvent("semantic-profile-capture-noop", {
1071
+ userId: identity.userId,
1072
+ sessionId,
1073
+ confidence: extraction?.confidence ?? 0,
1074
+ messagePreview: messageText.slice(0, 120)
1075
+ });
1076
+ this.semanticCaptureCache.set(fingerprint, Date.now());
1077
+ this.clearSemanticProfileRetryState(fingerprint);
1078
+ return;
1079
+ }
1080
+ const { patch, operations } = cleanupSemanticProfilePatch(
1081
+ extraction.patch,
1082
+ identity.profile,
1083
+ extraction.operations
1084
+ );
1085
+ if (Object.keys(patch).length === 0) {
1086
+ this.semanticCaptureCache.set(fingerprint, Date.now());
1087
+ this.clearSemanticProfileRetryState(fingerprint);
1088
+ return;
1089
+ }
1090
+ await this.updateMyProfile(
1091
+ { sessionId },
1092
+ {
1093
+ ...patch,
1094
+ source: "semantic-self-update"
1095
+ },
1096
+ operations
1097
+ );
1098
+ await this.removePendingSemanticRefineNote(sessionId, fingerprint);
1099
+ logUserBindEvent("semantic-profile-capture-success", {
1100
+ userId: identity.userId,
1101
+ sessionId,
1102
+ fields: Object.keys(patch),
1103
+ operations,
1104
+ confidence: extraction.confidence
1105
+ });
1106
+ this.semanticCaptureCache.set(fingerprint, Date.now());
1107
+ this.clearSemanticProfileRetryState(fingerprint);
1108
+ } catch (error) {
1109
+ if (this.shouldRetrySemanticProfileCapture(error)) {
1110
+ await this.ensurePendingSemanticRefineNote(sessionId, messageText, fingerprint);
1111
+ this.scheduleSemanticProfileRetry(identity, sessionId, messageText, fingerprint, error);
1112
+ return;
1113
+ }
1114
+ logUserBindEvent("semantic-profile-capture-failed", {
1115
+ userId: identity.userId,
1116
+ sessionId,
1117
+ message: error instanceof Error ? error.message : String(error)
1118
+ });
1119
+ } finally {
1120
+ this.semanticCaptureInFlight.delete(fingerprint);
1121
+ }
1122
+ }
1123
+ scheduleSemanticProfileRetry(identity, sessionId, messageText, fingerprint, error) {
1124
+ const nextAttempt = (this.semanticCaptureRetryAttempts.get(fingerprint) ?? 0) + 1;
1125
+ if (nextAttempt > SEMANTIC_PROFILE_RETRY_MAX_ATTEMPTS) {
1126
+ logUserBindEvent("semantic-profile-capture-failed", {
1127
+ userId: identity.userId,
1128
+ sessionId,
1129
+ attempt: nextAttempt - 1,
1130
+ message: error instanceof Error ? error.message : String(error)
1131
+ });
1132
+ return;
1133
+ }
1134
+ const existing = this.semanticCaptureRetryTimers.get(fingerprint);
1135
+ if (existing) {
1136
+ clearTimeout(existing);
1137
+ }
1138
+ this.semanticCaptureRetryAttempts.set(fingerprint, nextAttempt);
1139
+ const delayMs = computeSemanticProfileRetryDelayMs(nextAttempt);
1140
+ const timer = setTimeout(() => {
1141
+ this.semanticCaptureRetryTimers.delete(fingerprint);
1142
+ void this.runSemanticProfileCapture(identity, sessionId, messageText, fingerprint);
1143
+ }, delayMs);
1144
+ this.semanticCaptureRetryTimers.set(fingerprint, timer);
1145
+ logUserBindEvent("semantic-profile-capture-retry-scheduled", {
1146
+ userId: identity.userId,
1147
+ sessionId,
1148
+ attempt: nextAttempt,
1149
+ delayMs,
1150
+ message: error instanceof Error ? error.message : String(error)
1151
+ });
1152
+ }
1153
+ schedulePendingSemanticSweep(delayMs = 1200) {
1154
+ if (this.pendingSemanticSweepTimer) {
1155
+ clearTimeout(this.pendingSemanticSweepTimer);
1156
+ }
1157
+ logUserBindEvent("pending-semantic-refine-sweep-scheduled", { delayMs });
1158
+ this.pendingSemanticSweepTimer = setTimeout(() => {
1159
+ this.pendingSemanticSweepTimer = null;
1160
+ void this.runPendingSemanticSweep().catch((error) => {
1161
+ logUserBindEvent("pending-semantic-refine-sweep-failed", {
1162
+ message: error instanceof Error ? error.message : String(error)
1163
+ });
1164
+ });
1165
+ }, delayMs);
1166
+ }
1167
+ async runPendingSemanticSweep() {
1168
+ if (this.pendingSemanticSweepInFlight) {
1169
+ logUserBindEvent("pending-semantic-refine-sweep-skipped", { reason: "already-in-flight" });
1170
+ return;
1171
+ }
1172
+ this.pendingSemanticSweepInFlight = true;
1173
+ try {
1174
+ const profiles = this.store.listProfilesWithPendingSemanticRefine();
1175
+ logUserBindEvent("pending-semantic-refine-sweep-start", {
1176
+ profileCount: profiles.length,
1177
+ userIds: profiles.slice(0, 10).map((profile) => profile.userId)
1178
+ });
1179
+ for (const profile of profiles) {
1180
+ const entries = extractPendingSemanticRefineEntries(profile.notes);
1181
+ logUserBindEvent("pending-semantic-refine-profile-scan", {
1182
+ userId: profile.userId,
1183
+ entryCount: entries.length
1184
+ });
1185
+ for (const entry of entries) {
1186
+ const cachedAt = this.semanticCaptureCache.get(entry.fingerprint);
1187
+ if (cachedAt && cachedAt > Date.now() - 12 * 60 * 60 * 1e3) {
1188
+ logUserBindEvent("pending-semantic-refine-entry-skipped", {
1189
+ userId: profile.userId,
1190
+ fingerprint: entry.fingerprint,
1191
+ reason: "recently-cached"
1192
+ });
1193
+ continue;
1194
+ }
1195
+ if (this.semanticCaptureInFlight.has(entry.fingerprint)) {
1196
+ logUserBindEvent("pending-semantic-refine-entry-skipped", {
1197
+ userId: profile.userId,
1198
+ fingerprint: entry.fingerprint,
1199
+ reason: "already-in-flight"
1200
+ });
1201
+ continue;
1202
+ }
1203
+ if (this.globalPendingSemanticRefines.has(entry.fingerprint)) {
1204
+ logUserBindEvent("pending-semantic-refine-entry-skipped", {
1205
+ userId: profile.userId,
1206
+ fingerprint: entry.fingerprint,
1207
+ reason: "process-global-in-flight"
1208
+ });
1209
+ continue;
1210
+ }
1211
+ await this.runPendingSemanticProfileCapture(profile, entry.messageText, entry.fingerprint);
1212
+ }
1213
+ }
1214
+ } finally {
1215
+ this.pendingSemanticSweepInFlight = false;
1216
+ }
1217
+ }
1218
+ async runPendingSemanticProfileCapture(profile, messageText, fingerprint) {
1219
+ if (this.globalPendingSemanticRefines.has(fingerprint)) {
1220
+ logUserBindEvent("pending-semantic-refine-entry-skipped", {
1221
+ userId: profile.userId,
1222
+ fingerprint,
1223
+ reason: "process-global-in-flight"
1224
+ });
1225
+ return;
1226
+ }
1227
+ this.globalPendingSemanticRefines.add(fingerprint);
1228
+ this.semanticCaptureInFlight.add(fingerprint);
1229
+ try {
1230
+ logUserBindEvent("pending-semantic-refine-entry-start", {
1231
+ userId: profile.userId,
1232
+ fingerprint,
1233
+ messagePreview: messageText.slice(0, 120)
1234
+ });
1235
+ const extraction = await inferSemanticProfileExtraction(messageText, profile);
1236
+ if (!extraction?.shouldUpdate) {
1237
+ logUserBindEvent("pending-semantic-refine-noop", {
1238
+ userId: profile.userId,
1239
+ fingerprint
1240
+ });
1241
+ this.semanticCaptureCache.set(fingerprint, Date.now());
1242
+ this.clearSemanticProfileRetryState(fingerprint);
1243
+ return;
1244
+ }
1245
+ const { patch, operations } = cleanupSemanticProfilePatch(
1246
+ extraction.patch,
1247
+ profile,
1248
+ extraction.operations
1249
+ );
1250
+ const nextPatch = applyProfilePatchOperations(profile, patch, operations);
1251
+ const cleanedNotes = removePendingSemanticRefineEntry(nextPatch.notes ?? profile.notes, fingerprint);
1252
+ const updated = this.store.updateProfile(profile.userId, {
1253
+ ...nextPatch,
1254
+ notes: cleanedNotes,
1255
+ source: "semantic-self-update"
1256
+ });
1257
+ const finalProfile = updated.notes?.includes(`[pending-profile-refine:${fingerprint}]`) ? this.store.replaceProfileNotes(
1258
+ profile.userId,
1259
+ removePendingSemanticRefineEntry(updated.notes, fingerprint),
1260
+ "semantic-self-update"
1261
+ ) : updated;
1262
+ logUserBindEvent("pending-semantic-refine-recovered", {
1263
+ userId: profile.userId,
1264
+ fields: Object.keys(patch),
1265
+ confidence: extraction.confidence,
1266
+ notesCleared: !finalProfile.notes?.includes(`[pending-profile-refine:${fingerprint}]`)
1267
+ });
1268
+ this.semanticCaptureCache.set(fingerprint, Date.now());
1269
+ this.clearSemanticProfileRetryState(fingerprint);
1270
+ } catch (error) {
1271
+ if (this.shouldRetrySemanticProfileCapture(error)) {
1272
+ const nextAttempt = (this.semanticCaptureRetryAttempts.get(fingerprint) ?? 0) + 1;
1273
+ this.semanticCaptureRetryAttempts.set(fingerprint, nextAttempt);
1274
+ if (nextAttempt <= SEMANTIC_PROFILE_RETRY_MAX_ATTEMPTS) {
1275
+ this.schedulePendingSemanticSweep(computeSemanticProfileRetryDelayMs(nextAttempt));
1276
+ return;
1277
+ }
1278
+ }
1279
+ logUserBindEvent("pending-semantic-refine-failed", {
1280
+ userId: profile.userId,
1281
+ message: error instanceof Error ? error.message : String(error)
1282
+ });
1283
+ } finally {
1284
+ this.semanticCaptureInFlight.delete(fingerprint);
1285
+ this.globalPendingSemanticRefines.delete(fingerprint);
1286
+ }
1287
+ }
1288
+ clearSemanticProfileRetryState(fingerprint) {
1289
+ const timer = this.semanticCaptureRetryTimers.get(fingerprint);
1290
+ if (timer) {
1291
+ clearTimeout(timer);
1292
+ this.semanticCaptureRetryTimers.delete(fingerprint);
1293
+ }
1294
+ this.semanticCaptureRetryAttempts.delete(fingerprint);
1295
+ }
1296
+ shouldRetrySemanticProfileCapture(error) {
1297
+ const message = error instanceof Error ? error.message : String(error);
1298
+ const normalized = message.toLowerCase();
1299
+ return normalized.includes("timed out") || normalized.includes("aborted due to timeout") || normalized.includes("network connection error") || normalized.includes("connection error") || normalized.includes("rate limit") || normalized.includes("429") || normalized.includes("503") || normalized.includes("busy");
1300
+ }
1301
+ async ensurePendingSemanticRefineNote(sessionId, messageText, fingerprint) {
1302
+ const note = buildPendingSemanticRefineNote(messageText, fingerprint);
1303
+ const identity = await this.resolveFromContext({ sessionId });
1304
+ if (!identity) {
1305
+ return;
1306
+ }
1307
+ if (identity.profile.notes?.includes(note)) {
1308
+ return;
1309
+ }
1310
+ await this.updateMyProfile(
1311
+ { sessionId },
1312
+ {
1313
+ notes: note,
1314
+ source: "semantic-refine-pending"
1315
+ },
1316
+ { notes: "append" }
1317
+ );
1318
+ }
1319
+ async removePendingSemanticRefineNote(sessionId, fingerprint) {
1320
+ const identity = await this.resolveFromContext({ sessionId });
1321
+ const currentNotes = identity?.profile.notes ?? null;
1322
+ if (!currentNotes || !currentNotes.includes(`[pending-profile-refine:${fingerprint}]`)) {
1323
+ return;
1324
+ }
1325
+ await this.updateMyProfile(
1326
+ { sessionId },
1327
+ {
1328
+ notes: `[pending-profile-refine:${fingerprint}]`,
1329
+ source: "semantic-self-update"
1330
+ },
1331
+ { notes: "remove" }
1332
+ );
1333
+ }
1334
+ pruneSemanticCaptureCache() {
1335
+ const cutoff = Date.now() - 12 * 60 * 60 * 1e3;
1336
+ for (const [fingerprint, ts] of this.semanticCaptureCache.entries()) {
1337
+ if (ts < cutoff) {
1338
+ this.semanticCaptureCache.delete(fingerprint);
1339
+ }
1340
+ }
1341
+ }
549
1342
  async adminInstruction(toolName, instruction, context) {
550
1343
  const requester = await this.resolveFromContext(context);
551
1344
  const agentId = getAgentIdFromContext(context);
@@ -612,14 +1405,17 @@ var UserBindRuntime = class {
612
1405
  this.host.registerHook?.(
613
1406
  ["message:received", "message:preprocessed"],
614
1407
  async (event) => {
615
- await this.resolveFromContext(event);
1408
+ const identity = await this.resolveFromContext(event);
1409
+ if (identity) {
1410
+ await this.captureProfileFromMessage(event, identity);
1411
+ }
616
1412
  },
617
1413
  {
618
1414
  name: "bamdra-user-bind-resolve",
619
1415
  description: "Resolve runtime identity from channel sender metadata"
620
1416
  }
621
1417
  );
622
- this.host.on?.("before_prompt_build", async (_event, context) => {
1418
+ this.host.on?.("before_prompt_build", async (event, context) => {
623
1419
  const identity = await this.resolveFromContext(context);
624
1420
  if (!identity) {
625
1421
  return;
@@ -639,9 +1435,16 @@ var UserBindRuntime = class {
639
1435
  if (!registerTool) {
640
1436
  return;
641
1437
  }
1438
+ const getMyProfileExecute = async (_id, params) => asTextResult(await this.getMyProfile(params));
1439
+ const updateMyProfileExecute = async (_id, params) => asTextResult(await this.updateMyProfile(
1440
+ params,
1441
+ sanitizeProfilePatch(params),
1442
+ extractProfilePatchOperations(params)
1443
+ ));
1444
+ const refreshMyBindingExecute = async (_id, params) => asTextResult(await this.refreshMyBinding(params));
642
1445
  registerTool({
643
1446
  name: "bamdra_user_bind_get_my_profile",
644
- description: "Get the current user's bound profile",
1447
+ description: "Get the current user's bound profile before replying so identity facts, timezone, and stable personal preferences can be used naturally in the response",
645
1448
  parameters: {
646
1449
  type: "object",
647
1450
  additionalProperties: false,
@@ -649,30 +1452,48 @@ var UserBindRuntime = class {
649
1452
  sessionId: { type: "string" }
650
1453
  }
651
1454
  },
652
- execute: async (_id, params) => asTextResult(await this.getMyProfile(params))
1455
+ execute: getMyProfileExecute
653
1456
  });
654
1457
  registerTool({
655
1458
  name: "bamdra_user_bind_update_my_profile",
656
- description: "Update the current user's own profile fields",
1459
+ description: "Immediately write the current user's stable profile information when they clearly provide it, such as name, nickname, gender, age, birthday, timezone, interests, communication style, role, or durable notes",
657
1460
  parameters: {
658
1461
  type: "object",
659
1462
  additionalProperties: false,
660
1463
  required: ["sessionId"],
661
1464
  properties: {
662
1465
  sessionId: { type: "string" },
1466
+ name: { type: "string" },
1467
+ nameOperation: { type: "string", enum: ["replace", "append", "remove"] },
1468
+ gender: { type: "string" },
1469
+ genderOperation: { type: "string", enum: ["replace", "append", "remove"] },
1470
+ birthDate: { type: "string" },
1471
+ birthDateOperation: { type: "string", enum: ["replace", "append", "remove"] },
1472
+ birthYear: { type: "string" },
1473
+ birthYearOperation: { type: "string", enum: ["replace", "append", "remove"] },
1474
+ age: { type: "string" },
1475
+ ageOperation: { type: "string", enum: ["replace", "append", "remove"] },
663
1476
  nickname: { type: "string" },
1477
+ nicknameOperation: { type: "string", enum: ["replace", "append", "remove"] },
664
1478
  preferences: { type: "string" },
1479
+ preferencesOperation: { type: "string", enum: ["replace", "append", "remove"] },
665
1480
  personality: { type: "string" },
1481
+ personalityOperation: { type: "string", enum: ["replace", "append", "remove"] },
1482
+ interests: { type: "string" },
1483
+ interestsOperation: { type: "string", enum: ["replace", "append", "remove"] },
666
1484
  role: { type: "string" },
1485
+ roleOperation: { type: "string", enum: ["replace", "append", "remove"] },
667
1486
  timezone: { type: "string" },
668
- notes: { type: "string" }
1487
+ timezoneOperation: { type: "string", enum: ["replace", "append", "remove"] },
1488
+ notes: { type: "string" },
1489
+ notesOperation: { type: "string", enum: ["replace", "append", "remove"] }
669
1490
  }
670
1491
  },
671
- execute: async (_id, params) => asTextResult(await this.updateMyProfile(params, sanitizeProfilePatch(params)))
1492
+ execute: updateMyProfileExecute
672
1493
  });
673
1494
  registerTool({
674
1495
  name: "bamdra_user_bind_refresh_my_binding",
675
- description: "Refresh the current user's identity binding",
1496
+ description: "Refresh the current user's identity binding when the session looks unresolved, stale, or mapped to the wrong external identity, then fetch the profile again",
676
1497
  parameters: {
677
1498
  type: "object",
678
1499
  additionalProperties: false,
@@ -680,32 +1501,75 @@ var UserBindRuntime = class {
680
1501
  sessionId: { type: "string" }
681
1502
  }
682
1503
  },
683
- execute: async (_id, params) => asTextResult(await this.refreshMyBinding(params))
1504
+ execute: refreshMyBindingExecute
684
1505
  });
685
- for (const toolName of ADMIN_TOOL_NAMES) {
686
- registerTool({
687
- name: toolName,
688
- description: `Administrative natural-language tool for ${toolName.replace("bamdra_user_bind_admin_", "")}`,
689
- parameters: {
690
- type: "object",
691
- additionalProperties: false,
692
- required: ["instruction"],
693
- properties: {
694
- instruction: { type: "string" },
695
- sessionId: { type: "string" },
696
- agentId: { type: "string" }
697
- }
698
- },
699
- execute: async (_id, params) => asTextResult(await this.adminInstruction(toolName, String(params.instruction ?? ""), params))
700
- });
701
- }
702
- }
703
- async requireCurrentIdentity(context) {
704
- const identity = await this.resolveFromContext(context);
705
- if (!identity) {
706
- throw new Error("Unable to resolve current user identity");
707
- }
708
- return identity;
1506
+ registerTool({
1507
+ name: SELF_TOOL_ALIASES[0],
1508
+ description: "Alias of bamdra_user_bind_get_my_profile",
1509
+ parameters: {
1510
+ type: "object",
1511
+ additionalProperties: false,
1512
+ properties: {
1513
+ sessionId: { type: "string" }
1514
+ }
1515
+ },
1516
+ execute: getMyProfileExecute
1517
+ });
1518
+ registerTool({
1519
+ name: SELF_TOOL_ALIASES[1],
1520
+ description: "Alias of bamdra_user_bind_update_my_profile",
1521
+ parameters: {
1522
+ type: "object",
1523
+ additionalProperties: false,
1524
+ required: ["sessionId"],
1525
+ properties: {
1526
+ sessionId: { type: "string" },
1527
+ nickname: { type: "string" },
1528
+ preferences: { type: "string" },
1529
+ personality: { type: "string" },
1530
+ role: { type: "string" },
1531
+ timezone: { type: "string" },
1532
+ notes: { type: "string" }
1533
+ }
1534
+ },
1535
+ execute: updateMyProfileExecute
1536
+ });
1537
+ registerTool({
1538
+ name: SELF_TOOL_ALIASES[2],
1539
+ description: "Alias of bamdra_user_bind_refresh_my_binding",
1540
+ parameters: {
1541
+ type: "object",
1542
+ additionalProperties: false,
1543
+ properties: {
1544
+ sessionId: { type: "string" }
1545
+ }
1546
+ },
1547
+ execute: refreshMyBindingExecute
1548
+ });
1549
+ for (const toolName of ADMIN_TOOL_NAMES) {
1550
+ registerTool({
1551
+ name: toolName,
1552
+ description: `Administrative natural-language tool for ${toolName.replace("bamdra_user_bind_admin_", "")}`,
1553
+ parameters: {
1554
+ type: "object",
1555
+ additionalProperties: false,
1556
+ required: ["instruction"],
1557
+ properties: {
1558
+ instruction: { type: "string" },
1559
+ sessionId: { type: "string" },
1560
+ agentId: { type: "string" }
1561
+ }
1562
+ },
1563
+ execute: async (_id, params) => asTextResult(await this.adminInstruction(toolName, String(params.instruction ?? ""), params))
1564
+ });
1565
+ }
1566
+ }
1567
+ async requireCurrentIdentity(context) {
1568
+ const identity = await this.resolveFromContext(context);
1569
+ if (!identity) {
1570
+ throw new Error("Unable to resolve current user identity");
1571
+ }
1572
+ return identity;
709
1573
  }
710
1574
  async tryResolveFeishuUser(openId) {
711
1575
  logUserBindEvent("feishu-resolution-start", { openId });
@@ -714,6 +1578,7 @@ var UserBindRuntime = class {
714
1578
  logUserBindEvent("feishu-resolution-skipped", { reason: "no-feishu-accounts-configured" });
715
1579
  return null;
716
1580
  }
1581
+ const contactScopeBlockedAccounts = /* @__PURE__ */ new Set();
717
1582
  for (const account of accounts) {
718
1583
  try {
719
1584
  const token = await this.getFeishuAppAccessToken(account);
@@ -746,6 +1611,9 @@ var UserBindRuntime = class {
746
1611
  };
747
1612
  } catch (error) {
748
1613
  const message = error instanceof Error ? error.message : String(error);
1614
+ if (looksLikeFeishuContactScopeError(message)) {
1615
+ contactScopeBlockedAccounts.add(account.accountId);
1616
+ }
749
1617
  logUserBindEvent("feishu-resolution-attempt-failed", {
750
1618
  accountId: account.accountId,
751
1619
  openId,
@@ -777,63 +1645,18 @@ var UserBindRuntime = class {
777
1645
  } catch {
778
1646
  }
779
1647
  }
1648
+ if (contactScopeBlockedAccounts.size > 0) {
1649
+ return {
1650
+ userId: "",
1651
+ source: "feishu-contact-scope-missing",
1652
+ profilePatch: {
1653
+ notes: renderFeishuContactScopeGuidance([...contactScopeBlockedAccounts])
1654
+ }
1655
+ };
1656
+ }
780
1657
  logUserBindEvent("feishu-resolution-empty", { openId });
781
1658
  return null;
782
1659
  }
783
- async ensureFeishuScopeStatus() {
784
- if (this.feishuScopeStatus) {
785
- return this.feishuScopeStatus;
786
- }
787
- const accounts = readFeishuAccountsFromOpenClawConfig();
788
- for (const account of accounts) {
789
- try {
790
- const token = await this.getFeishuAppAccessToken(account);
791
- const result = await feishuJsonRequest(
792
- account,
793
- "/open-apis/application/v6/scopes",
794
- token
795
- );
796
- const scopes = extractScopes(result);
797
- this.feishuScopeStatus = {
798
- scopes,
799
- missingIdentityScopes: REQUIRED_FEISHU_IDENTITY_SCOPES.filter((scope) => !scopes.includes(scope)),
800
- hasDocumentAccess: scopes.some((scope) => scope.startsWith("bitable:") || scope.startsWith("drive:") || scope.startsWith("docx:") || scope.startsWith("docs:"))
801
- };
802
- logUserBindEvent("feishu-scopes-read", {
803
- accountId: account.accountId,
804
- ...this.feishuScopeStatus
805
- });
806
- return this.feishuScopeStatus;
807
- } catch (error) {
808
- const message = error instanceof Error ? error.message : String(error);
809
- logUserBindEvent("feishu-scopes-attempt-failed", { accountId: account.accountId, message });
810
- }
811
- }
812
- const executor = this.host.callTool ?? this.host.invokeTool;
813
- if (typeof executor === "function") {
814
- try {
815
- const result = await executor.call(this.host, "feishu_app_scopes", {});
816
- const scopes = extractScopes(result);
817
- this.feishuScopeStatus = {
818
- scopes,
819
- missingIdentityScopes: REQUIRED_FEISHU_IDENTITY_SCOPES.filter((scope) => !scopes.includes(scope)),
820
- hasDocumentAccess: scopes.some((scope) => scope.startsWith("bitable:") || scope.startsWith("drive:") || scope.startsWith("docx:") || scope.startsWith("docs:"))
821
- };
822
- logUserBindEvent("feishu-scopes-read", this.feishuScopeStatus);
823
- return this.feishuScopeStatus;
824
- } catch (error) {
825
- const message = error instanceof Error ? error.message : String(error);
826
- logUserBindEvent("feishu-scopes-failed", { message });
827
- this.store.recordIssue("feishu-scope-read", message);
828
- }
829
- }
830
- this.feishuScopeStatus = {
831
- scopes: [],
832
- missingIdentityScopes: [...REQUIRED_FEISHU_IDENTITY_SCOPES],
833
- hasDocumentAccess: false
834
- };
835
- return this.feishuScopeStatus;
836
- }
837
1660
  async getFeishuAppAccessToken(account) {
838
1661
  const cached = this.feishuTokenCache.get(account.accountId);
839
1662
  if (cached && cached.expiresAt > Date.now()) {
@@ -865,108 +1688,17 @@ var UserBindRuntime = class {
865
1688
  });
866
1689
  return token;
867
1690
  }
868
- async syncFeishuMirror(identity) {
869
- const scopeStatus = await this.ensureFeishuScopeStatus();
870
- if (!scopeStatus.hasDocumentAccess) {
871
- return;
872
- }
873
- const executor = this.host.callTool ?? this.host.invokeTool;
874
- if (typeof executor !== "function") {
875
- return;
876
- }
877
- try {
878
- const mirror = await this.ensureFeishuBitableMirror(executor.bind(this.host));
879
- if (!mirror.appToken || !mirror.tableId) {
880
- return;
881
- }
882
- const existing = await executor.call(this.host, "feishu_bitable_list_records", {
883
- app_token: mirror.appToken,
884
- table_id: mirror.tableId
885
- });
886
- const recordId = findBitableRecordId(existing, identity.userId);
887
- const fields = {
888
- user_id: identity.userId,
889
- channel_type: identity.channelType,
890
- open_id: identity.senderOpenId,
891
- name: identity.profile.name,
892
- nickname: identity.profile.nickname,
893
- preferences: identity.profile.preferences,
894
- personality: identity.profile.personality,
895
- role: identity.profile.role,
896
- timezone: identity.profile.timezone,
897
- email: identity.profile.email,
898
- avatar: identity.profile.avatar
899
- };
900
- if (recordId) {
901
- await executor.call(this.host, "feishu_bitable_update_record", {
902
- app_token: mirror.appToken,
903
- table_id: mirror.tableId,
904
- record_id: recordId,
905
- fields
906
- });
907
- } else {
908
- await executor.call(this.host, "feishu_bitable_create_record", {
909
- app_token: mirror.appToken,
910
- table_id: mirror.tableId,
911
- fields
912
- });
913
- }
914
- logUserBindEvent("feishu-bitable-sync-success", { userId: identity.userId });
915
- } catch (error) {
916
- const message = error instanceof Error ? error.message : String(error);
917
- logUserBindEvent("feishu-bitable-sync-failed", { userId: identity.userId, message });
918
- this.store.recordIssue("feishu-bitable-sync", message, identity.userId);
919
- }
920
- }
921
- async ensureFeishuBitableMirror(executor) {
922
- if (this.bitableMirror?.appToken && this.bitableMirror?.tableId) {
923
- return this.bitableMirror;
924
- }
925
- try {
926
- const app = await executor("feishu_bitable_create_app", { name: "Bamdra User Bind" });
927
- const appToken = extractDeepString(app, [
928
- ["data", "app", "app_token"],
929
- ["data", "app_token"],
930
- ["app", "app_token"],
931
- ["app_token"]
932
- ]);
933
- if (!appToken) {
934
- return { appToken: null, tableId: null };
935
- }
936
- const meta = await executor("feishu_bitable_get_meta", { app_token: appToken });
937
- const tableId = extractDeepString(meta, [
938
- ["data", "tables", "0", "table_id"],
939
- ["data", "items", "0", "table_id"],
940
- ["tables", "0", "table_id"]
941
- ]);
942
- if (!tableId) {
943
- this.store.recordIssue("feishu-bitable-init", "Unable to determine users table id from Feishu bitable metadata");
944
- return { appToken, tableId: null };
945
- }
946
- for (const fieldName of ["user_id", "channel_type", "open_id", "name", "nickname", "preferences", "personality", "role", "timezone", "email", "avatar"]) {
947
- try {
948
- await executor("feishu_bitable_create_field", {
949
- app_token: appToken,
950
- table_id: tableId,
951
- field_name: fieldName,
952
- type: 1
953
- });
954
- } catch {
955
- }
956
- }
957
- this.bitableMirror = { appToken, tableId };
958
- logUserBindEvent("feishu-bitable-ready", this.bitableMirror);
959
- return this.bitableMirror;
960
- } catch (error) {
961
- const message = error instanceof Error ? error.message : String(error);
962
- logUserBindEvent("feishu-bitable-init-failed", { message });
963
- this.store.recordIssue("feishu-bitable-init", message);
964
- return { appToken: null, tableId: null };
965
- }
966
- }
967
1691
  };
968
1692
  function createUserBindPlugin(api) {
969
- return new UserBindRuntime(api, api.pluginConfig ?? api.config ?? api.plugin?.config);
1693
+ const globalRecord = globalThis;
1694
+ const existing = globalRecord[GLOBAL_RUNTIME_KEY];
1695
+ if (isUserBindRuntimeLike(existing)) {
1696
+ return existing;
1697
+ }
1698
+ const runtime = new UserBindRuntime(api, api.pluginConfig ?? api.config ?? api.plugin?.config);
1699
+ runtime[GLOBAL_RUNTIME_BRAND_KEY] = true;
1700
+ globalRecord[GLOBAL_RUNTIME_KEY] = runtime;
1701
+ return runtime;
970
1702
  }
971
1703
  function register(api) {
972
1704
  createUserBindPlugin(api).register();
@@ -976,12 +1708,12 @@ async function activate(api) {
976
1708
  }
977
1709
  function normalizeConfig(input) {
978
1710
  const root = (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "data", "bamdra-user-bind");
979
- const storeRoot = input?.localStorePath ?? root;
1711
+ const storeRoot = expandHomePath(input?.localStorePath) ?? root;
980
1712
  return {
981
1713
  enabled: input?.enabled ?? true,
982
1714
  localStorePath: storeRoot,
983
- exportPath: input?.exportPath ?? (0, import_node_path.join)(storeRoot, "exports"),
984
- profileMarkdownRoot: input?.profileMarkdownRoot ?? (0, import_node_path.join)(storeRoot, "profiles", "private"),
1715
+ exportPath: expandHomePath(input?.exportPath) ?? (0, import_node_path.join)(storeRoot, "exports"),
1716
+ profileMarkdownRoot: expandHomePath(input?.profileMarkdownRoot) ?? (0, import_node_path.join)(storeRoot, "profiles", "private"),
985
1717
  cacheTtlMs: input?.cacheTtlMs ?? 30 * 60 * 1e3,
986
1718
  adminAgents: input?.adminAgents?.length ? input.adminAgents : ["main"]
987
1719
  };
@@ -993,6 +1725,9 @@ function exposeGlobalApi(runtime) {
993
1725
  },
994
1726
  async resolveIdentity(context) {
995
1727
  return runtime.resolveFromContext(context);
1728
+ },
1729
+ async runPendingBindingSweep() {
1730
+ return runtime.runPendingBindingSweep();
996
1731
  }
997
1732
  };
998
1733
  }
@@ -1016,17 +1751,18 @@ function bootstrapOpenClawHost(config) {
1016
1751
  materializeBundledSkill(adminSkillSource, adminSkillTarget);
1017
1752
  const original = (0, import_node_fs.readFileSync)(configPath, "utf8");
1018
1753
  const parsed = JSON.parse(original);
1019
- const changed = ensureHostConfig(parsed, config, profileSkillTarget, adminSkillTarget);
1754
+ const changed = ensureHostConfig(parsed, config, packageRoot, profileSkillTarget, adminSkillTarget);
1020
1755
  if (!changed) {
1021
1756
  return;
1022
1757
  }
1023
1758
  (0, import_node_fs.writeFileSync)(configPath, `${JSON.stringify(parsed, null, 2)}
1024
1759
  `, "utf8");
1025
1760
  }
1026
- function ensureHostConfig(config, pluginConfig, profileSkillTarget, adminSkillTarget) {
1761
+ function ensureHostConfig(config, pluginConfig, packageRoot, profileSkillTarget, adminSkillTarget) {
1027
1762
  let changed = false;
1028
1763
  const plugins = ensureObject(config, "plugins");
1029
1764
  const entries = ensureObject(plugins, "entries");
1765
+ const installs = ensureObject(plugins, "installs");
1030
1766
  const load = ensureObject(plugins, "load");
1031
1767
  const tools = ensureObject(config, "tools");
1032
1768
  const skills = ensureObject(config, "skills");
@@ -1037,6 +1773,11 @@ function ensureHostConfig(config, pluginConfig, profileSkillTarget, adminSkillTa
1037
1773
  changed = ensureArrayIncludes(plugins, "allow", PLUGIN_ID) || changed;
1038
1774
  changed = ensureArrayIncludes(load, "paths", (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "extensions")) || changed;
1039
1775
  changed = ensureArrayIncludes(skillsLoad, "extraDirs", (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "skills")) || changed;
1776
+ changed = ensureInstallMetadata(
1777
+ installs,
1778
+ PLUGIN_ID,
1779
+ readPluginInstallMetadata(PLUGIN_ID, packageRoot, (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "extensions", PLUGIN_ID))
1780
+ ) || changed;
1040
1781
  if (entry.enabled !== true) {
1041
1782
  entry.enabled = true;
1042
1783
  changed = true;
@@ -1076,6 +1817,46 @@ function materializeBundledSkill(sourceDir, targetDir) {
1076
1817
  (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(targetDir), { recursive: true });
1077
1818
  (0, import_node_fs.cpSync)(sourceDir, targetDir, { recursive: true });
1078
1819
  }
1820
+ function readPluginInstallMetadata(pluginId, packageRoot, installPath) {
1821
+ try {
1822
+ const pkg = JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path.join)(packageRoot, "package.json"), "utf8"));
1823
+ const packageName = typeof pkg.name === "string" ? pkg.name : pluginId;
1824
+ const version = typeof pkg.version === "string" ? pkg.version : "0.0.0";
1825
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1826
+ return {
1827
+ source: "npm",
1828
+ spec: packageName,
1829
+ installPath,
1830
+ version,
1831
+ resolvedName: packageName,
1832
+ resolvedVersion: version,
1833
+ resolvedSpec: `${packageName}@${version}`,
1834
+ resolvedAt: now,
1835
+ installedAt: now
1836
+ };
1837
+ } catch {
1838
+ return null;
1839
+ }
1840
+ }
1841
+ function ensureInstallMetadata(installs, pluginId, metadata) {
1842
+ if (!metadata) {
1843
+ return false;
1844
+ }
1845
+ const current = installs[pluginId];
1846
+ if (current && typeof current === "object" && !Array.isArray(current)) {
1847
+ const install = current;
1848
+ let changed = false;
1849
+ for (const [key, value] of Object.entries(metadata)) {
1850
+ if (typeof install[key] !== "string" || install[key] === "") {
1851
+ install[key] = value;
1852
+ changed = true;
1853
+ }
1854
+ }
1855
+ return changed;
1856
+ }
1857
+ installs[pluginId] = metadata;
1858
+ return true;
1859
+ }
1079
1860
  function ensureToolNames(tools, values) {
1080
1861
  let changed = false;
1081
1862
  for (const value of values) {
@@ -1084,9 +1865,8 @@ function ensureToolNames(tools, values) {
1084
1865
  return changed;
1085
1866
  }
1086
1867
  function ensureAgentSkills(agents, skillId) {
1087
- const list = Array.isArray(agents.list) ? agents.list : [];
1088
1868
  let changed = false;
1089
- for (const item of list) {
1869
+ for (const item of iterAgentConfigs(agents)) {
1090
1870
  if (!item || typeof item !== "object") {
1091
1871
  continue;
1092
1872
  }
@@ -1101,10 +1881,9 @@ function ensureAgentSkills(agents, skillId) {
1101
1881
  return changed;
1102
1882
  }
1103
1883
  function ensureAdminSkill(agents, skillId, adminAgents) {
1104
- const list = Array.isArray(agents.list) ? agents.list : [];
1105
1884
  let changed = false;
1106
1885
  let attached = false;
1107
- for (const item of list) {
1886
+ for (const item of iterAgentConfigs(agents)) {
1108
1887
  if (!item || typeof item !== "object") {
1109
1888
  continue;
1110
1889
  }
@@ -1121,6 +1900,7 @@ function ensureAdminSkill(agents, skillId, adminAgents) {
1121
1900
  }
1122
1901
  attached = true;
1123
1902
  }
1903
+ const list = Array.isArray(agents.list) ? agents.list : [];
1124
1904
  if (!attached && list.length > 0 && list[0] && typeof list[0] === "object") {
1125
1905
  const agent = list[0];
1126
1906
  const current = Array.isArray(agent.skills) ? [...agent.skills] : [];
@@ -1132,16 +1912,48 @@ function ensureAdminSkill(agents, skillId, adminAgents) {
1132
1912
  }
1133
1913
  return changed;
1134
1914
  }
1915
+ function* iterAgentConfigs(agents) {
1916
+ const seen = /* @__PURE__ */ new Set();
1917
+ const list = Array.isArray(agents.list) ? agents.list : [];
1918
+ for (const item of list) {
1919
+ if (item && typeof item === "object") {
1920
+ const agent = item;
1921
+ seen.add(agent);
1922
+ yield agent;
1923
+ }
1924
+ }
1925
+ if (list.length > 0) {
1926
+ return;
1927
+ }
1928
+ for (const [key, value] of Object.entries(agents)) {
1929
+ if (key === "list" || key === "defaults" || !value || typeof value !== "object" || Array.isArray(value)) {
1930
+ continue;
1931
+ }
1932
+ const agent = value;
1933
+ if (seen.has(agent)) {
1934
+ continue;
1935
+ }
1936
+ seen.add(agent);
1937
+ if (!getConfiguredAgentId(agent)) {
1938
+ agent.id = key;
1939
+ }
1940
+ yield agent;
1941
+ }
1942
+ }
1135
1943
  function mapProfileRow(row) {
1136
1944
  return {
1137
1945
  userId: String(row.user_id),
1138
1946
  name: asNullableString(row.name),
1139
1947
  gender: asNullableString(row.gender),
1948
+ birthDate: asNullableString(row.birth_date),
1949
+ birthYear: asNullableString(row.birth_year),
1950
+ age: asNullableString(row.age),
1140
1951
  email: asNullableString(row.email),
1141
1952
  avatar: asNullableString(row.avatar),
1142
1953
  nickname: asNullableString(row.nickname),
1143
1954
  preferences: asNullableString(row.preferences),
1144
1955
  personality: asNullableString(row.personality),
1956
+ interests: asNullableString(row.interests),
1145
1957
  role: asNullableString(row.role),
1146
1958
  timezone: asNullableString(row.timezone),
1147
1959
  notes: asNullableString(row.notes),
@@ -1162,12 +1974,12 @@ function parseIdentityContext(context) {
1162
1974
  const metadataText = asNullableString(record.text) ?? asNullableString(message.text) ?? asNullableString(record.content) ?? asNullableString(metadata.text) ?? asNullableString(input.text) ?? asNullableString(findNestedValue(record, ["message", "content", "text"]));
1163
1975
  const conversationInfo = metadataText ? extractTaggedJsonBlock(metadataText, "Conversation info (untrusted metadata)") : null;
1164
1976
  const senderInfo = metadataText ? extractTaggedJsonBlock(metadataText, "Sender (untrusted metadata)") : null;
1165
- const senderIdFromText = metadataText ? extractRegexValue(metadataText, /"sender_id"\s*:\s*"([^"]+)"/) : null;
1977
+ const senderIdFromText = extractSenderIdFromMetadataText(metadataText);
1166
1978
  const senderNameFromText = metadataText ? extractRegexValue(metadataText, /"sender"\s*:\s*"([^"]+)"/) : null;
1167
1979
  const senderNameFromMessageLine = metadataText ? extractRegexValue(metadataText, /\]\s*([^\n::]{1,40})\s*[::]/) : null;
1168
1980
  const sessionId = asNullableString(record.sessionKey) ?? asNullableString(record.sessionId) ?? asNullableString(session.id) ?? asNullableString(conversation.id) ?? asNullableString(metadata.sessionId) ?? asNullableString(input.sessionId) ?? asNullableString(input.session?.id) ?? asNullableString(record.context?.sessionId) ?? asNullableString(conversationInfo?.session_id) ?? asNullableString(conversationInfo?.message_id);
1169
- const channelType = asNullableString(record.channelType) ?? asNullableString(channel.type) ?? asNullableString(metadata.channelType) ?? asNullableString(conversation?.provider) ?? asNullableString(record.provider) ?? asNullableString(conversationInfo?.provider) ?? inferChannelTypeFromSessionId(sessionId);
1170
- const openId = asNullableString(sender.id) ?? asNullableString(sender.open_id) ?? asNullableString(sender.openId) ?? asNullableString(sender.user_id) ?? asNullableString(senderInfo?.id) ?? asNullableString(conversationInfo?.sender_id) ?? senderIdFromText ?? extractOpenIdFromSessionId(sessionId);
1981
+ const channelType = asNullableString(record.channelType) ?? asNullableString(channel.type) ?? asNullableString(metadata.channelType) ?? asNullableString(conversation?.provider) ?? asNullableString(record.provider) ?? asNullableString(conversationInfo?.provider) ?? inferChannelTypeFromSenderId(extractSenderId(record, sender, senderInfo, conversationInfo, message, senderIdFromText)) ?? inferChannelTypeFromSessionId(sessionId);
1982
+ const openId = extractSenderId(record, sender, senderInfo, conversationInfo, message, senderIdFromText) ?? extractOpenIdFromSessionId(sessionId);
1171
1983
  const senderName = asNullableString(sender.name) ?? asNullableString(sender.display_name) ?? asNullableString(senderInfo?.name) ?? asNullableString(conversationInfo?.sender) ?? senderNameFromText ?? senderNameFromMessageLine;
1172
1984
  return {
1173
1985
  sessionId,
@@ -1176,102 +1988,629 @@ function parseIdentityContext(context) {
1176
1988
  senderName
1177
1989
  };
1178
1990
  }
1179
- function findNestedRecord(root, ...paths) {
1180
- for (const path of paths) {
1181
- const value = findNestedValue(root, path);
1182
- if (value && typeof value === "object") {
1183
- return value;
1184
- }
1991
+ function enrichIdentityContext(context) {
1992
+ const record = context && typeof context === "object" ? { ...context } : {};
1993
+ const sessionManager = record.sessionManager && typeof record.sessionManager === "object" ? record.sessionManager : null;
1994
+ if (!sessionManager) {
1995
+ return record;
1185
1996
  }
1186
- return {};
1997
+ const sessionSnapshot = readSessionManagerSnapshot(sessionManager);
1998
+ if (!sessionSnapshot) {
1999
+ return record;
2000
+ }
2001
+ if (!record.sessionId && sessionSnapshot.sessionId) {
2002
+ record.sessionId = sessionSnapshot.sessionId;
2003
+ }
2004
+ if (!record.text && sessionSnapshot.metadataText) {
2005
+ record.text = sessionSnapshot.metadataText;
2006
+ }
2007
+ const metadata = record.metadata && typeof record.metadata === "object" ? { ...record.metadata } : {};
2008
+ if (!metadata.text && sessionSnapshot.metadataText) {
2009
+ metadata.text = sessionSnapshot.metadataText;
2010
+ }
2011
+ if (!metadata.sessionId && sessionSnapshot.sessionId) {
2012
+ metadata.sessionId = sessionSnapshot.sessionId;
2013
+ }
2014
+ if (Object.keys(metadata).length > 0) {
2015
+ record.metadata = metadata;
2016
+ }
2017
+ if (!record.channelType && sessionSnapshot.channelType) {
2018
+ record.channelType = sessionSnapshot.channelType;
2019
+ }
2020
+ if (!record.openId && sessionSnapshot.openId) {
2021
+ record.openId = sessionSnapshot.openId;
2022
+ }
2023
+ return record;
1187
2024
  }
1188
- function findNestedValue(root, path) {
1189
- let current = root;
1190
- for (const part of path) {
1191
- if (!current || typeof current !== "object") {
1192
- return null;
2025
+ function readSessionManagerSnapshot(sessionManager) {
2026
+ try {
2027
+ const getSessionId = sessionManager.getSessionId;
2028
+ const getBranch = sessionManager.getBranch;
2029
+ const sessionId = typeof getSessionId === "function" ? asNullableString(getSessionId()) : null;
2030
+ const branch = typeof getBranch === "function" ? getBranch() : [];
2031
+ if (!Array.isArray(branch) || branch.length === 0) {
2032
+ return sessionId ? {
2033
+ sessionId,
2034
+ metadataText: null,
2035
+ channelType: inferChannelTypeFromSessionId(sessionId),
2036
+ openId: extractOpenIdFromSessionId(sessionId)
2037
+ } : null;
1193
2038
  }
1194
- current = current[part];
2039
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
2040
+ const entry = branch[i];
2041
+ if (!entry || typeof entry !== "object") {
2042
+ continue;
2043
+ }
2044
+ const item = entry;
2045
+ const message = item.message && typeof item.message === "object" ? item.message : null;
2046
+ if (item.type !== "message" || !message || message.role !== "user") {
2047
+ continue;
2048
+ }
2049
+ const metadataText = extractMessageText(message);
2050
+ if (!metadataText || !looksLikeIdentityMetadata(metadataText)) {
2051
+ continue;
2052
+ }
2053
+ const openId = extractSenderIdFromMetadataText(metadataText);
2054
+ const channelType = inferChannelTypeFromSenderId(openId) ?? inferChannelTypeFromSessionId(sessionId);
2055
+ return {
2056
+ sessionId,
2057
+ metadataText,
2058
+ channelType,
2059
+ openId
2060
+ };
2061
+ }
2062
+ return sessionId ? {
2063
+ sessionId,
2064
+ metadataText: null,
2065
+ channelType: inferChannelTypeFromSessionId(sessionId),
2066
+ openId: extractOpenIdFromSessionId(sessionId)
2067
+ } : null;
2068
+ } catch (error) {
2069
+ logUserBindEvent("session-manager-read-failed", {
2070
+ error: error instanceof Error ? error.message : String(error)
2071
+ });
2072
+ return null;
1195
2073
  }
1196
- return current;
1197
2074
  }
1198
- function extractTaggedJsonBlock(text, label) {
1199
- const start = text.indexOf(label);
1200
- if (start < 0) {
2075
+ function extractMessageText(message) {
2076
+ const content = message.content;
2077
+ if (typeof content === "string") {
2078
+ return content;
2079
+ }
2080
+ if (!Array.isArray(content)) {
1201
2081
  return null;
1202
2082
  }
1203
- const block = text.slice(start).match(/```json\s*([\s\S]*?)\s*```/i);
1204
- if (!block) {
2083
+ const text = content.filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string").map((part) => String(part.text)).join("\n");
2084
+ return text || null;
2085
+ }
2086
+ function looksLikeIdentityMetadata(text) {
2087
+ return text.includes("Conversation info (untrusted metadata)") || text.includes("Sender (untrusted metadata)") || /"sender_id"\s*:\s*"/.test(text);
2088
+ }
2089
+ function extractUserUtterance(context) {
2090
+ const record = context && typeof context === "object" ? context : {};
2091
+ const rawText = extractHookContextText(record) ?? readLatestUserMessageFromSessionManager(record.sessionManager);
2092
+ if (!rawText) {
1205
2093
  return null;
1206
2094
  }
1207
- try {
1208
- const parsed = JSON.parse(block[1]);
1209
- return parsed && typeof parsed === "object" ? parsed : null;
1210
- } catch {
2095
+ const stripped = stripIdentityMetadata(rawText);
2096
+ if (!stripped) {
1211
2097
  return null;
1212
2098
  }
1213
- }
1214
- function inferChannelTypeFromSessionId(sessionId) {
1215
- if (!sessionId) {
2099
+ if (looksLikeIdentityMetadata(stripped)) {
1216
2100
  return null;
1217
2101
  }
1218
- if (sessionId.includes(":feishu:")) {
1219
- return "feishu";
2102
+ return stripped;
2103
+ }
2104
+ function buildSemanticCaptureInput(context, utteranceText) {
2105
+ const record = context && typeof context === "object" ? context : {};
2106
+ const recentDialogue = readRecentProfileCaptureDialogue(record.sessionManager, utteranceText);
2107
+ if (!recentDialogue) {
2108
+ return utteranceText;
1220
2109
  }
1221
- if (sessionId.includes(":telegram:")) {
1222
- return "telegram";
2110
+ return [
2111
+ "Recent profile-collection exchange:",
2112
+ recentDialogue
2113
+ ].join("\n");
2114
+ }
2115
+ function shouldSkipSemanticProfileCapture(text) {
2116
+ const normalized = normalizeSemanticCaptureText(text);
2117
+ if (!normalized) {
2118
+ return true;
1223
2119
  }
1224
- if (sessionId.includes(":whatsapp:")) {
1225
- return "whatsapp";
2120
+ if (isTrivialSemanticCaptureUtterance(normalized)) {
2121
+ return true;
1226
2122
  }
1227
- return null;
2123
+ const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
2124
+ if (normalized.length <= 4) {
2125
+ return true;
2126
+ }
2127
+ return tokenCount <= 1 && !containsCjkCharacters(normalized);
1228
2128
  }
1229
- function extractRegexValue(text, pattern) {
1230
- const match = text.match(pattern);
1231
- return match?.[1]?.trim() || null;
2129
+ function shouldIgnoreSemanticProfileCaptureCandidate(text) {
2130
+ const normalized = normalizeSemanticCaptureText(text);
2131
+ return !normalized || isTrivialSemanticCaptureUtterance(normalized);
1232
2132
  }
1233
- function extractOpenIdFromSessionId(sessionId) {
1234
- if (!sessionId) {
1235
- return null;
1236
- }
1237
- const match = sessionId.match(/:([A-Za-z0-9_-]+)$/);
1238
- return match?.[1] ?? null;
2133
+ function isTrivialSemanticCaptureUtterance(normalized) {
2134
+ const trivialUtterances = /* @__PURE__ */ new Set([
2135
+ "hi",
2136
+ "hello",
2137
+ "hey",
2138
+ "\u4F60\u597D",
2139
+ "\u60A8\u597D",
2140
+ "\u5728\u5417",
2141
+ "\u5728\u4E48",
2142
+ "\u5728\u4E0D\u5728",
2143
+ "\u6709\u4EBA\u5417",
2144
+ "ping",
2145
+ "test",
2146
+ "\u6D4B\u8BD5"
2147
+ ]);
2148
+ return trivialUtterances.has(normalized);
1239
2149
  }
1240
- function getAgentIdFromContext(context) {
1241
- const record = context && typeof context === "object" ? context : {};
1242
- return asNullableString(record.agentId) ?? asNullableString(record.agent?.id) ?? asNullableString(record.agent?.name);
2150
+ function normalizeSemanticCaptureText(text) {
2151
+ return text.trim().toLowerCase().replace(/[!?.,,。!?、;;::"'`~()\[\]{}<>@#%^&*_+=|\\/.-]+/g, " ").replace(/\s+/g, " ").trim();
1243
2152
  }
1244
- function sanitizeProfilePatch(params) {
1245
- return {
1246
- nickname: asNullableString(params.nickname),
1247
- preferences: asNullableString(params.preferences),
1248
- personality: asNullableString(params.personality),
1249
- role: asNullableString(params.role),
1250
- timezone: asNullableString(params.timezone),
1251
- notes: asNullableString(params.notes)
1252
- };
2153
+ function containsCjkCharacters(text) {
2154
+ return /[\u3400-\u9fff\uf900-\ufaff]/.test(text);
1253
2155
  }
1254
- function parseAdminInstruction(instruction) {
1255
- const normalized = instruction.trim();
1256
- if (/issue|问题|失败/i.test(normalized)) {
1257
- return { action: "list_issues", userIds: [], patch: {} };
2156
+ function appendSemanticCaptureCandidate(existing, messageText) {
2157
+ const normalizedCandidate = normalizeSemanticCaptureText(messageText);
2158
+ if (!normalizedCandidate) {
2159
+ return existing;
2160
+ }
2161
+ const deduped = existing.filter((item) => normalizeSemanticCaptureText(item) !== normalizedCandidate);
2162
+ deduped.push(messageText.trim());
2163
+ const recent = deduped.slice(-SEMANTIC_PROFILE_BATCH_MAX_FRAGMENTS);
2164
+ while (recent.join("\n").length > SEMANTIC_PROFILE_BATCH_MAX_CHARS && recent.length > 1) {
2165
+ recent.shift();
2166
+ }
2167
+ return recent;
2168
+ }
2169
+ function buildSemanticProfileBatchText(messages) {
2170
+ if (!Array.isArray(messages) || messages.length === 0) {
2171
+ return null;
1258
2172
  }
1259
- if (/merge|合并/i.test(normalized)) {
1260
- const userIds = normalized.match(/user[:=]([A-Za-z0-9:_-]+)/g)?.map((item) => item.split(/[:=]/)[1]) ?? [];
1261
- return { action: "merge", userIds, patch: {} };
2173
+ const combined = messages.map((item) => item.trim()).filter(Boolean).join("\n");
2174
+ return combined || null;
2175
+ }
2176
+ function extractHookContextText(record) {
2177
+ const directText = normalizeHookText(
2178
+ record.bodyForAgent ?? record.body ?? record.prompt ?? record.text ?? findNestedValue(record, ["context", "bodyForAgent"]) ?? findNestedValue(record, ["context", "body"]) ?? findNestedValue(record, ["context", "text"]) ?? findNestedValue(record, ["context", "content"])
2179
+ );
2180
+ if (directText) {
2181
+ return directText;
1262
2182
  }
1263
- if (/sync|重同步|同步/i.test(normalized)) {
1264
- return { action: "sync", userIds: extractUserIds(normalized), patch: {} };
2183
+ const message = findNestedRecord(record, ["message"], ["event", "message"], ["payload", "message"]);
2184
+ const messageText = extractMessageText(message);
2185
+ if (messageText) {
2186
+ return messageText;
1265
2187
  }
1266
- if (/edit|修改|改成|更新/i.test(normalized)) {
1267
- return {
1268
- action: "edit",
1269
- userIds: extractUserIds(normalized),
1270
- patch: extractProfilePatch(normalized)
1271
- };
2188
+ const inputText = extractTextFromInput(record.input);
2189
+ if (inputText) {
2190
+ return inputText;
1272
2191
  }
1273
- return {
1274
- action: "query",
2192
+ const messages = Array.isArray(record.messages) ? record.messages : [];
2193
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
2194
+ const item = messages[i];
2195
+ if (!item || typeof item !== "object") {
2196
+ continue;
2197
+ }
2198
+ const messageRecord = item;
2199
+ const role = asNullableString(messageRecord.role) ?? "user";
2200
+ if (role !== "user") {
2201
+ continue;
2202
+ }
2203
+ const text = normalizeHookText(messageRecord.text ?? messageRecord.content);
2204
+ if (text) {
2205
+ return text;
2206
+ }
2207
+ }
2208
+ return null;
2209
+ }
2210
+ function extractTextFromInput(input) {
2211
+ if (typeof input === "string") {
2212
+ return normalizeHookText(input);
2213
+ }
2214
+ if (!input || typeof input !== "object") {
2215
+ return null;
2216
+ }
2217
+ const record = input;
2218
+ const directText = normalizeHookText(record.text ?? record.content);
2219
+ if (directText) {
2220
+ return directText;
2221
+ }
2222
+ const message = record.message && typeof record.message === "object" ? record.message : null;
2223
+ const messageText = normalizeHookText(message?.text ?? message?.content);
2224
+ if (messageText) {
2225
+ return messageText;
2226
+ }
2227
+ const messages = Array.isArray(record.messages) ? record.messages : [];
2228
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
2229
+ const item = messages[i];
2230
+ if (!item || typeof item !== "object") {
2231
+ continue;
2232
+ }
2233
+ const messageRecord = item;
2234
+ const role = asNullableString(messageRecord.role) ?? "user";
2235
+ if (role !== "user") {
2236
+ continue;
2237
+ }
2238
+ const text = normalizeHookText(messageRecord.text ?? messageRecord.content);
2239
+ if (text) {
2240
+ return text;
2241
+ }
2242
+ }
2243
+ return null;
2244
+ }
2245
+ function normalizeHookText(value) {
2246
+ if (typeof value === "string") {
2247
+ return value.trim() || null;
2248
+ }
2249
+ if (Array.isArray(value)) {
2250
+ const text = value.map((item) => {
2251
+ if (!item || typeof item !== "object") {
2252
+ return "";
2253
+ }
2254
+ return asNullableString(item.text) ?? "";
2255
+ }).filter(Boolean).join("\n").trim();
2256
+ return text || null;
2257
+ }
2258
+ return null;
2259
+ }
2260
+ function readRecentProfileCaptureDialogue(sessionManager, latestUserText) {
2261
+ if (!sessionManager || typeof sessionManager !== "object") {
2262
+ return null;
2263
+ }
2264
+ try {
2265
+ const getBranch = sessionManager.getBranch;
2266
+ const branch = typeof getBranch === "function" ? getBranch() : [];
2267
+ if (!Array.isArray(branch) || branch.length === 0) {
2268
+ return null;
2269
+ }
2270
+ const conversation = [];
2271
+ for (let i = 0; i < branch.length; i += 1) {
2272
+ const entry = branch[i];
2273
+ if (!entry || typeof entry !== "object") {
2274
+ continue;
2275
+ }
2276
+ const item = entry;
2277
+ const message = item.message && typeof item.message === "object" ? item.message : null;
2278
+ if (item.type !== "message" || !message) {
2279
+ continue;
2280
+ }
2281
+ const role = asNullableString(message.role);
2282
+ if (role !== "assistant" && role !== "user") {
2283
+ continue;
2284
+ }
2285
+ const text = extractMessageText(message);
2286
+ if (!text || looksLikeIdentityMetadata(text)) {
2287
+ continue;
2288
+ }
2289
+ conversation.push({ role, text: text.trim() });
2290
+ }
2291
+ for (let i = conversation.length - 1; i >= 0; i -= 1) {
2292
+ const entry = conversation[i];
2293
+ if (entry.role !== "user") {
2294
+ continue;
2295
+ }
2296
+ const previous = conversation[i - 1];
2297
+ if (!previous || previous.role !== "assistant" || !looksLikeProfileCollectionPrompt(previous.text)) {
2298
+ continue;
2299
+ }
2300
+ const normalizedEntry = normalizeSemanticCaptureText(entry.text);
2301
+ const normalizedLatest = normalizeSemanticCaptureText(latestUserText);
2302
+ if (normalizedLatest && normalizedEntry && normalizedEntry !== normalizedLatest) {
2303
+ continue;
2304
+ }
2305
+ return [
2306
+ `assistant: ${previous.text}`,
2307
+ `user: ${latestUserText}`
2308
+ ].join("\n");
2309
+ }
2310
+ } catch (error) {
2311
+ logUserBindEvent("session-manager-dialogue-read-failed", {
2312
+ error: error instanceof Error ? error.message : String(error)
2313
+ });
2314
+ }
2315
+ return null;
2316
+ }
2317
+ function looksLikeProfileCollectionPrompt(text) {
2318
+ const normalized = normalizeSemanticCaptureText(text);
2319
+ if (!normalized) {
2320
+ return false;
2321
+ }
2322
+ const keywords = [
2323
+ "\u600E\u4E48\u79F0\u547C\u4F60",
2324
+ "\u5982\u4F55\u79F0\u547C\u4F60",
2325
+ "\u600E\u4E48\u53EB\u4F60",
2326
+ "\u53EB\u4F60\u4EC0\u4E48",
2327
+ "\u79F0\u547C",
2328
+ "\u504F\u597D\u7684\u56DE\u7B54\u98CE\u683C",
2329
+ "\u56DE\u590D\u98CE\u683C",
2330
+ "\u56DE\u7B54\u98CE\u683C",
2331
+ "\u6C9F\u901A\u98CE\u683C",
2332
+ "\u4F60\u559C\u6B22\u6211\u600E\u4E48\u56DE\u590D",
2333
+ "\u4F60\u66F4\u559C\u6B22",
2334
+ "what should i call you",
2335
+ "how should i address you",
2336
+ "preferred style",
2337
+ "reply style",
2338
+ "response style"
2339
+ ];
2340
+ return keywords.some((keyword) => normalized.includes(normalizeSemanticCaptureText(keyword)));
2341
+ }
2342
+ function readLatestUserMessageFromSessionManager(sessionManager) {
2343
+ if (!sessionManager || typeof sessionManager !== "object") {
2344
+ return null;
2345
+ }
2346
+ try {
2347
+ const getBranch = sessionManager.getBranch;
2348
+ const branch = typeof getBranch === "function" ? getBranch() : [];
2349
+ if (!Array.isArray(branch) || branch.length === 0) {
2350
+ return null;
2351
+ }
2352
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
2353
+ const entry = branch[i];
2354
+ if (!entry || typeof entry !== "object") {
2355
+ continue;
2356
+ }
2357
+ const item = entry;
2358
+ const message = item.message && typeof item.message === "object" ? item.message : null;
2359
+ if (item.type !== "message" || !message || message.role !== "user") {
2360
+ continue;
2361
+ }
2362
+ const text = extractMessageText(message);
2363
+ if (text && !looksLikeIdentityMetadata(text)) {
2364
+ return text.trim();
2365
+ }
2366
+ }
2367
+ } catch (error) {
2368
+ logUserBindEvent("session-manager-user-message-read-failed", {
2369
+ error: error instanceof Error ? error.message : String(error)
2370
+ });
2371
+ }
2372
+ return null;
2373
+ }
2374
+ function stripIdentityMetadata(text) {
2375
+ const hadMetadata = looksLikeIdentityMetadata(text);
2376
+ let cleaned = text.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```/gi, "").replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```/gi, "").replace(/^\s*\[[^\n]*message_id[^\n]*\]\s*$/gim, "").replace(/^\s*\[[^\n]*sender_id[^\n]*\]\s*$/gim, "");
2377
+ if (hadMetadata) {
2378
+ cleaned = cleaned.replace(/^\s*[^\n::]{1,40}[::]\s*/m, "");
2379
+ }
2380
+ cleaned = cleaned.trim();
2381
+ return cleaned || null;
2382
+ }
2383
+ function findNestedRecord(root, ...paths) {
2384
+ for (const path of paths) {
2385
+ const value = findNestedValue(root, path);
2386
+ if (value && typeof value === "object") {
2387
+ return value;
2388
+ }
2389
+ }
2390
+ return {};
2391
+ }
2392
+ function findNestedValue(root, path) {
2393
+ let current = root;
2394
+ for (const part of path) {
2395
+ if (!current || typeof current !== "object") {
2396
+ return null;
2397
+ }
2398
+ current = current[part];
2399
+ }
2400
+ return current;
2401
+ }
2402
+ function extractTaggedJsonBlock(text, label) {
2403
+ const start = text.indexOf(label);
2404
+ if (start < 0) {
2405
+ return null;
2406
+ }
2407
+ const block = text.slice(start).match(/```json\s*([\s\S]*?)\s*```/i);
2408
+ if (!block) {
2409
+ return null;
2410
+ }
2411
+ try {
2412
+ const parsed = JSON.parse(block[1]);
2413
+ return parsed && typeof parsed === "object" ? parsed : null;
2414
+ } catch {
2415
+ return null;
2416
+ }
2417
+ }
2418
+ function inferChannelTypeFromSessionId(sessionId) {
2419
+ if (!sessionId) {
2420
+ return null;
2421
+ }
2422
+ if (sessionId.includes(":feishu:")) {
2423
+ return "feishu";
2424
+ }
2425
+ if (sessionId.includes(":telegram:")) {
2426
+ return "telegram";
2427
+ }
2428
+ if (sessionId.includes(":whatsapp:")) {
2429
+ return "whatsapp";
2430
+ }
2431
+ if (sessionId.includes(":discord:")) {
2432
+ return "discord";
2433
+ }
2434
+ if (sessionId.includes(":googlechat:")) {
2435
+ return "googlechat";
2436
+ }
2437
+ if (sessionId.includes(":slack:")) {
2438
+ return "slack";
2439
+ }
2440
+ if (sessionId.includes(":mattermost:")) {
2441
+ return "mattermost";
2442
+ }
2443
+ if (sessionId.includes(":signal:")) {
2444
+ return "signal";
2445
+ }
2446
+ if (sessionId.includes(":imessage:")) {
2447
+ return "imessage";
2448
+ }
2449
+ if (sessionId.includes(":msteams:")) {
2450
+ return "msteams";
2451
+ }
2452
+ return null;
2453
+ }
2454
+ function inferChannelTypeFromSenderId(senderId) {
2455
+ if (!senderId) {
2456
+ return null;
2457
+ }
2458
+ if (/^(?:ou|oc)_[A-Za-z0-9_-]+$/.test(senderId)) {
2459
+ return "feishu";
2460
+ }
2461
+ if (/@(?:s\.whatsapp\.net|g\.us)$/.test(senderId)) {
2462
+ return "whatsapp";
2463
+ }
2464
+ if (/^users\/.+/.test(senderId) || /^spaces\/.+/.test(senderId)) {
2465
+ return "googlechat";
2466
+ }
2467
+ return null;
2468
+ }
2469
+ function extractSenderId(record, sender, senderInfo, conversationInfo, message, senderIdFromText) {
2470
+ return firstNonEmptyString(
2471
+ asNullableString(record.openId),
2472
+ asNullableString(record.senderId),
2473
+ asNullableString(record.userId),
2474
+ asNullableString(record.fromId),
2475
+ asNullableString(record.participantId),
2476
+ asNullableString(record.authorId),
2477
+ asNullableString(sender.id),
2478
+ asNullableString(sender.open_id),
2479
+ asNullableString(sender.openId),
2480
+ asNullableString(sender.user_id),
2481
+ asNullableString(sender.userId),
2482
+ asNullableString(sender.sender_id),
2483
+ asNullableString(sender.senderId),
2484
+ asNullableString(sender.from_id),
2485
+ asNullableString(sender.fromId),
2486
+ asNullableString(sender.author_id),
2487
+ asNullableString(sender.authorId),
2488
+ asNullableString(sender.chat_id),
2489
+ asNullableString(sender.chatId),
2490
+ asNullableString(sender.participant),
2491
+ asNullableString(sender.participant_id),
2492
+ asNullableString(sender.participantId),
2493
+ asNullableString(sender.jid),
2494
+ asNullableString(sender.handle),
2495
+ asNullableString(sender.username),
2496
+ asNullableString(sender.phone),
2497
+ asNullableString(sender.phone_number),
2498
+ asNullableString(findNestedValue(sender, ["from", "id"])),
2499
+ asNullableString(findNestedValue(sender, ["author", "id"])),
2500
+ asNullableString(findNestedValue(message, ["from", "id"])),
2501
+ asNullableString(findNestedValue(message, ["author", "id"])),
2502
+ asNullableString(findNestedValue(message, ["user", "id"])),
2503
+ asNullableString(senderInfo?.id),
2504
+ asNullableString(senderInfo?.user_id),
2505
+ asNullableString(senderInfo?.sender_id),
2506
+ asNullableString(conversationInfo?.sender_id),
2507
+ asNullableString(conversationInfo?.user_id),
2508
+ asNullableString(conversationInfo?.from_id),
2509
+ senderIdFromText
2510
+ );
2511
+ }
2512
+ function extractSenderIdFromMetadataText(metadataText) {
2513
+ if (!metadataText) {
2514
+ return null;
2515
+ }
2516
+ return firstNonEmptyString(
2517
+ extractRegexValue(metadataText, /"sender_id"\s*:\s*"([^"]+)"/),
2518
+ extractRegexValue(metadataText, /"user_id"\s*:\s*"([^"]+)"/),
2519
+ extractRegexValue(metadataText, /"from_id"\s*:\s*"([^"]+)"/),
2520
+ extractRegexValue(metadataText, /"author_id"\s*:\s*"([^"]+)"/),
2521
+ extractRegexValue(metadataText, /"chat_id"\s*:\s*"([^"]+)"/),
2522
+ extractRegexValue(metadataText, /"participant"\s*:\s*"([^"]+)"/),
2523
+ extractRegexValue(metadataText, /"jid"\s*:\s*"([^"]+)"/),
2524
+ extractRegexValue(metadataText, /"id"\s*:\s*"((?:ou|oc)_[^"]+)"/)
2525
+ );
2526
+ }
2527
+ function firstNonEmptyString(...values) {
2528
+ for (const value of values) {
2529
+ if (typeof value === "string" && value.trim()) {
2530
+ return value.trim();
2531
+ }
2532
+ }
2533
+ return null;
2534
+ }
2535
+ function extractRegexValue(text, pattern) {
2536
+ const match = text.match(pattern);
2537
+ return match?.[1]?.trim() || null;
2538
+ }
2539
+ function extractOpenIdFromSessionId(sessionId) {
2540
+ if (!sessionId) {
2541
+ return null;
2542
+ }
2543
+ for (const channel of ["feishu", "telegram", "whatsapp", "discord", "googlechat", "slack", "mattermost", "signal", "imessage", "msteams"]) {
2544
+ const marker = `:${channel}:`;
2545
+ const channelIndex = sessionId.indexOf(marker);
2546
+ if (channelIndex < 0) {
2547
+ continue;
2548
+ }
2549
+ const remainder = sessionId.slice(channelIndex + marker.length);
2550
+ const modeSeparator = remainder.indexOf(":");
2551
+ if (modeSeparator < 0) {
2552
+ continue;
2553
+ }
2554
+ const senderId = remainder.slice(modeSeparator + 1).trim();
2555
+ if (senderId) {
2556
+ return senderId;
2557
+ }
2558
+ }
2559
+ const match = sessionId.match(/:([^:]+)$/);
2560
+ return match?.[1]?.trim() || null;
2561
+ }
2562
+ function getAgentIdFromContext(context) {
2563
+ const record = context && typeof context === "object" ? context : {};
2564
+ return asNullableString(record.agentId) ?? asNullableString(record.agent?.id) ?? asNullableString(record.agent?.name);
2565
+ }
2566
+ function sanitizeProfilePatch(params) {
2567
+ return {
2568
+ name: asNullableString(params.name),
2569
+ gender: asNullableString(params.gender),
2570
+ birthDate: asNullableString(params.birthDate) ?? asNullableString(params.birthday),
2571
+ birthYear: asNullableString(params.birthYear),
2572
+ age: asNullableString(params.age),
2573
+ nickname: asNullableString(params.nickname),
2574
+ preferences: asNullableString(params.preferences),
2575
+ personality: asNullableString(params.personality),
2576
+ interests: asNullableString(params.interests),
2577
+ role: asNullableString(params.role),
2578
+ timezone: asNullableString(params.timezone),
2579
+ notes: asNullableString(params.notes)
2580
+ };
2581
+ }
2582
+ function extractProfilePatchOperations(params) {
2583
+ const operations = {};
2584
+ const fields = ["name", "gender", "birthDate", "birthYear", "age", "nickname", "preferences", "personality", "interests", "role", "timezone", "notes"];
2585
+ for (const field of fields) {
2586
+ const raw = asNullableString(params[`${field}Operation`]) ?? asNullableString(params[`${field}_operation`]);
2587
+ if (raw === "replace" || raw === "append" || raw === "remove") {
2588
+ operations[field] = raw;
2589
+ }
2590
+ }
2591
+ return Object.keys(operations).length > 0 ? operations : void 0;
2592
+ }
2593
+ function parseAdminInstruction(instruction) {
2594
+ const normalized = instruction.trim();
2595
+ if (/issue|问题|失败/i.test(normalized)) {
2596
+ return { action: "list_issues", userIds: [], patch: {} };
2597
+ }
2598
+ if (/merge|合并/i.test(normalized)) {
2599
+ const userIds = normalized.match(/user[:=]([A-Za-z0-9:_-]+)/g)?.map((item) => item.split(/[:=]/)[1]) ?? [];
2600
+ return { action: "merge", userIds, patch: {} };
2601
+ }
2602
+ if (/sync|重同步|同步/i.test(normalized)) {
2603
+ return { action: "sync", userIds: extractUserIds(normalized), patch: {} };
2604
+ }
2605
+ if (/edit|修改|改成|更新/i.test(normalized)) {
2606
+ return {
2607
+ action: "edit",
2608
+ userIds: extractUserIds(normalized),
2609
+ patch: extractProfilePatch(normalized)
2610
+ };
2611
+ }
2612
+ return {
2613
+ action: "query",
1275
2614
  userIds: extractUserIds(normalized),
1276
2615
  patch: {}
1277
2616
  };
@@ -1281,11 +2620,32 @@ function extractUserIds(input) {
1281
2620
  }
1282
2621
  function extractProfilePatch(input) {
1283
2622
  const patch = {};
2623
+ const name = input.match(/(?:name|姓名)[=:: ]([^,,]+)/i);
2624
+ const gender = input.match(/(?:gender|性别)[=:: ]([^,,]+)/i);
2625
+ const birthDate = input.match(/(?:birthdate|birthday|生日)[=:: ]([^,,]+)/i);
2626
+ const birthYear = input.match(/(?:birthyear|出生年份|出生年月)[=:: ]([^,,]+)/i);
2627
+ const age = input.match(/(?:age|年龄)[=:: ]([^,,]+)/i);
1284
2628
  const nickname = input.match(/(?:nickname|称呼)[=:: ]([^,,]+)$/i) ?? input.match(/(?:nickname|称呼)[=:: ]([^,,]+)/i);
1285
2629
  const role = input.match(/(?:role|职责|角色)[=:: ]([^,,]+)/i);
1286
2630
  const preferences = input.match(/(?:preferences|偏好)[=:: ]([^,,]+)/i);
1287
2631
  const personality = input.match(/(?:personality|性格)[=:: ]([^,,]+)/i);
2632
+ const interests = input.match(/(?:interests|兴趣|爱好)[=:: ]([^,,]+)/i);
1288
2633
  const timezone = input.match(/(?:timezone|时区)[=:: ]([^,,]+)/i);
2634
+ if (name) {
2635
+ patch.name = name[1].trim();
2636
+ }
2637
+ if (gender) {
2638
+ patch.gender = gender[1].trim();
2639
+ }
2640
+ if (birthDate) {
2641
+ patch.birthDate = birthDate[1].trim();
2642
+ }
2643
+ if (birthYear) {
2644
+ patch.birthYear = birthYear[1].trim();
2645
+ }
2646
+ if (age) {
2647
+ patch.age = age[1].trim();
2648
+ }
1289
2649
  if (nickname) {
1290
2650
  patch.nickname = nickname[1].trim();
1291
2651
  }
@@ -1298,6 +2658,9 @@ function extractProfilePatch(input) {
1298
2658
  if (personality) {
1299
2659
  patch.personality = personality[1].trim();
1300
2660
  }
2661
+ if (interests) {
2662
+ patch.interests = interests[1].trim();
2663
+ }
1301
2664
  if (timezone) {
1302
2665
  patch.timezone = timezone[1].trim();
1303
2666
  }
@@ -1311,6 +2674,18 @@ function renderIdentityContext(identity) {
1311
2674
  if (identity.profile.name) {
1312
2675
  lines.push(`Name: ${identity.profile.name}`);
1313
2676
  }
2677
+ if (identity.profile.gender) {
2678
+ lines.push(`Gender: ${identity.profile.gender}`);
2679
+ }
2680
+ if (identity.profile.birthDate) {
2681
+ lines.push(`Birth date: ${identity.profile.birthDate}`);
2682
+ }
2683
+ if (identity.profile.birthYear) {
2684
+ lines.push(`Birth year: ${identity.profile.birthYear}`);
2685
+ }
2686
+ if (identity.profile.age) {
2687
+ lines.push(`Age: ${identity.profile.age}`);
2688
+ }
1314
2689
  if (identity.profile.nickname) {
1315
2690
  lines.push(`Preferred address: ${identity.profile.nickname}`);
1316
2691
  }
@@ -1323,6 +2698,9 @@ function renderIdentityContext(identity) {
1323
2698
  if (identity.profile.personality) {
1324
2699
  lines.push(`Personality: ${identity.profile.personality}`);
1325
2700
  }
2701
+ if (identity.profile.interests) {
2702
+ lines.push(`Interests: ${identity.profile.interests}`);
2703
+ }
1326
2704
  if (identity.profile.role) {
1327
2705
  lines.push(`Role: ${identity.profile.role}`);
1328
2706
  }
@@ -1332,44 +2710,142 @@ function renderIdentityContext(identity) {
1332
2710
  return lines.join("\n");
1333
2711
  }
1334
2712
  function renderProfileMarkdown(profile) {
1335
- const frontmatter = [
2713
+ const notes = sanitizeProfileNotes(profile.notes) ?? defaultProfileNotes();
2714
+ const frontmatterLines = [
1336
2715
  "---",
1337
- `userId: ${escapeFrontmatter(profile.userId)}`,
1338
- `name: ${escapeFrontmatter(profile.name)}`,
1339
- `nickname: ${escapeFrontmatter(profile.nickname)}`,
1340
- `timezone: ${escapeFrontmatter(profile.timezone ?? "Asia/Shanghai")}`,
1341
- `preferences: ${escapeFrontmatter(profile.preferences ?? "\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206")}`,
1342
- `personality: ${escapeFrontmatter(profile.personality)}`,
1343
- `role: ${escapeFrontmatter(profile.role)}`,
2716
+ `userId: ${escapeFrontmatter(profile.userId)}`
2717
+ ];
2718
+ if (profile.name) {
2719
+ frontmatterLines.push(`name: ${escapeFrontmatter(profile.name)}`);
2720
+ }
2721
+ if (profile.gender) {
2722
+ frontmatterLines.push(`gender: ${escapeFrontmatter(profile.gender)}`);
2723
+ }
2724
+ if (profile.birthDate) {
2725
+ frontmatterLines.push(`birthDate: ${escapeFrontmatter(profile.birthDate)}`);
2726
+ }
2727
+ if (profile.birthYear) {
2728
+ frontmatterLines.push(`birthYear: ${escapeFrontmatter(profile.birthYear)}`);
2729
+ }
2730
+ if (profile.age) {
2731
+ frontmatterLines.push(`age: ${escapeFrontmatter(profile.age)}`);
2732
+ }
2733
+ if (profile.nickname) {
2734
+ frontmatterLines.push(`nickname: ${escapeFrontmatter(profile.nickname)}`);
2735
+ }
2736
+ if (profile.timezone) {
2737
+ frontmatterLines.push(`timezone: ${escapeFrontmatter(profile.timezone)}`);
2738
+ }
2739
+ if (profile.preferences) {
2740
+ frontmatterLines.push(`preferences: ${escapeFrontmatter(profile.preferences)}`);
2741
+ }
2742
+ if (profile.personality) {
2743
+ frontmatterLines.push(`personality: ${escapeFrontmatter(profile.personality)}`);
2744
+ }
2745
+ if (profile.interests) {
2746
+ frontmatterLines.push(`interests: ${escapeFrontmatter(profile.interests)}`);
2747
+ }
2748
+ if (profile.role) {
2749
+ frontmatterLines.push(`role: ${escapeFrontmatter(profile.role)}`);
2750
+ }
2751
+ frontmatterLines.push(
1344
2752
  `visibility: ${escapeFrontmatter(profile.visibility)}`,
1345
2753
  `source: ${escapeFrontmatter(profile.source)}`,
1346
2754
  `updatedAt: ${escapeFrontmatter(profile.updatedAt)}`,
2755
+ `syncHash: ${escapeFrontmatter(computeProfilePayloadHash({
2756
+ name: profile.name,
2757
+ gender: profile.gender,
2758
+ birthDate: profile.birthDate,
2759
+ birthYear: profile.birthYear,
2760
+ age: profile.age,
2761
+ nickname: profile.nickname,
2762
+ timezone: profile.timezone,
2763
+ preferences: profile.preferences,
2764
+ personality: profile.personality,
2765
+ interests: profile.interests,
2766
+ role: profile.role,
2767
+ visibility: profile.visibility
2768
+ }, notes))}`,
1347
2769
  "---"
1348
- ].join("\n");
1349
- const notes = sanitizeProfileNotes(profile.notes) ?? defaultProfileNotes();
2770
+ );
2771
+ const frontmatter = frontmatterLines.join("\n");
2772
+ const confirmedProfileLines = renderConfirmedProfileSection(profile);
1350
2773
  return `${frontmatter}
1351
2774
 
1352
2775
  # \u7528\u6237\u753B\u50CF
1353
2776
 
1354
- \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
2777
+ \u8FD9\u4E2A\u6587\u4EF6\u7531\u7CFB\u7EDF\u7EF4\u62A4\u3002Frontmatter \u662F\u7CFB\u7EDF\u673A\u5668\u8BFB\u5199\u7684\u7ED3\u6784\u5316\u6E90\uFF1B\u4E0B\u9762\u7684\u201C\u5DF2\u786E\u8BA4\u753B\u50CF\u201D\u662F\u7ED9\u4EBA\u770B\u7684\u540C\u6B65\u955C\u50CF\u3002\u6CA1\u6709\u51FA\u73B0\u7684\u5B57\u6BB5\uFF0C\u8868\u793A\u5F53\u524D\u8FD8\u6CA1\u6709\u786E\u8BA4\uFF0C\u4E0D\u4EE3\u8868\u7A7A\u503C\u7ED3\u8BBA\u3002
1355
2778
 
1356
- ## \u4F7F\u7528\u5EFA\u8BAE
2779
+ ## \u5DF2\u786E\u8BA4\u753B\u50CF
1357
2780
 
1358
- - \u5E38\u7528\u79F0\u547C\uFF1A\u4F8B\u5982\u201C\u8001\u677F\u201D
1359
- - \u65F6\u533A\uFF1A\u4F8B\u5982 Asia/Shanghai
1360
- - \u98CE\u683C\u504F\u597D\uFF1A\u4F8B\u5982\u201C\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206\u201D
1361
- - \u89D2\u8272\u4FE1\u606F\uFF1A\u4F8B\u5982\u5DE5\u4F5C\u804C\u8D23\u3001\u534F\u4F5C\u8FB9\u754C\u3001\u5E38\u89C1\u4EFB\u52A1
1362
- - \u5176\u4ED6\u5907\u6CE8\uFF1A\u4F8B\u5982\u7981\u5FCC\u3001\u4E60\u60EF\u3001\u8F93\u51FA\u504F\u597D
2781
+ ${confirmedProfileLines}
1363
2782
 
1364
- ## \u5907\u6CE8
2783
+ ## \u8865\u5145\u5907\u6CE8
1365
2784
 
1366
2785
  ${notes}
1367
2786
  `;
1368
2787
  }
2788
+ function renderConfirmedProfileSection(profile) {
2789
+ const rows = [];
2790
+ if (profile.name) {
2791
+ rows.push(`- \u59D3\u540D\uFF1A${profile.name}`);
2792
+ }
2793
+ if (profile.gender) {
2794
+ rows.push(`- \u6027\u522B\uFF1A${profile.gender}`);
2795
+ }
2796
+ if (profile.birthDate) {
2797
+ rows.push(`- \u751F\u65E5\uFF1A${profile.birthDate}`);
2798
+ }
2799
+ if (profile.birthYear) {
2800
+ rows.push(`- \u51FA\u751F\u5E74\u4EFD\uFF1A${profile.birthYear}`);
2801
+ }
2802
+ if (profile.age) {
2803
+ rows.push(`- \u5E74\u9F84\uFF1A${profile.age}`);
2804
+ }
2805
+ if (profile.nickname) {
2806
+ rows.push(`- \u79F0\u547C\uFF1A${profile.nickname}`);
2807
+ }
2808
+ if (profile.preferences) {
2809
+ rows.push(`- \u56DE\u7B54\u504F\u597D\uFF1A${profile.preferences}`);
2810
+ }
2811
+ if (profile.personality) {
2812
+ rows.push(`- \u98CE\u683C\u504F\u597D\uFF1A${profile.personality}`);
2813
+ }
2814
+ if (profile.interests) {
2815
+ rows.push(`- \u5174\u8DA3\u7231\u597D\uFF1A${profile.interests}`);
2816
+ }
2817
+ if (profile.role) {
2818
+ rows.push(`- \u89D2\u8272\u8EAB\u4EFD\uFF1A${profile.role}`);
2819
+ }
2820
+ if (profile.timezone) {
2821
+ rows.push(`- \u65F6\u533A\uFF1A${profile.timezone}`);
2822
+ }
2823
+ return rows.length > 0 ? rows.join("\n") : "- \u6682\u65E0\u5DF2\u786E\u8BA4\u7684\u7ED3\u6784\u5316\u753B\u50CF\u5B57\u6BB5";
2824
+ }
2825
+ function computeProfilePayloadHash(patch, notes) {
2826
+ return hashId(JSON.stringify({
2827
+ name: patch.name ?? null,
2828
+ gender: patch.gender ?? null,
2829
+ birthDate: patch.birthDate ?? null,
2830
+ birthYear: patch.birthYear ?? null,
2831
+ age: patch.age ?? null,
2832
+ nickname: patch.nickname ?? null,
2833
+ timezone: patch.timezone ?? null,
2834
+ preferences: patch.preferences ?? null,
2835
+ personality: patch.personality ?? null,
2836
+ interests: patch.interests ?? null,
2837
+ role: patch.role ?? null,
2838
+ visibility: patch.visibility ?? "private",
2839
+ notes: sanitizeProfileNotes(notes) ?? null
2840
+ }));
2841
+ }
1369
2842
  function parseProfileMarkdown(markdown) {
1370
2843
  const lines = markdown.split(/\r?\n/);
1371
2844
  const patch = {};
1372
2845
  let notes = null;
2846
+ let updatedAt = null;
2847
+ let source = null;
2848
+ let syncHash = null;
1373
2849
  let index = 0;
1374
2850
  if (lines[index] === "---") {
1375
2851
  index += 1;
@@ -1379,7 +2855,15 @@ function parseProfileMarkdown(markdown) {
1379
2855
  if (separatorIndex > 0) {
1380
2856
  const key = line.slice(0, separatorIndex).trim();
1381
2857
  const value = line.slice(separatorIndex + 1).trim();
1382
- applyFrontmatterField(patch, key, value);
2858
+ if (key === "updatedAt") {
2859
+ updatedAt = value === "null" ? null : value;
2860
+ } else if (key === "source") {
2861
+ source = value === "null" ? null : value;
2862
+ } else if (key === "syncHash") {
2863
+ syncHash = value === "null" ? null : value;
2864
+ } else {
2865
+ applyFrontmatterField(patch, key, value);
2866
+ }
1383
2867
  }
1384
2868
  index += 1;
1385
2869
  }
@@ -1388,19 +2872,30 @@ function parseProfileMarkdown(markdown) {
1388
2872
  }
1389
2873
  }
1390
2874
  const body = lines.slice(index).join("\n");
1391
- const notesMatch = body.match(/##\s*备注\s*\n([\s\S]*)$/);
2875
+ const notesMatch = body.match(/##\s*(?:补充备注|备注)\s*\n([\s\S]*)$/);
1392
2876
  if (notesMatch?.[1]) {
1393
2877
  notes = sanitizeProfileNotes(notesMatch[1]);
1394
2878
  }
1395
2879
  return {
1396
2880
  profilePatch: patch,
1397
- notes
2881
+ notes,
2882
+ updatedAt,
2883
+ source,
2884
+ syncHash
1398
2885
  };
1399
2886
  }
1400
2887
  function applyFrontmatterField(patch, key, value) {
1401
2888
  const normalized = value === "null" ? null : value;
1402
2889
  if (key === "name") {
1403
2890
  patch.name = normalized;
2891
+ } else if (key === "gender") {
2892
+ patch.gender = normalized;
2893
+ } else if (key === "birthDate") {
2894
+ patch.birthDate = normalized;
2895
+ } else if (key === "birthYear") {
2896
+ patch.birthYear = normalized;
2897
+ } else if (key === "age") {
2898
+ patch.age = normalized;
1404
2899
  } else if (key === "nickname") {
1405
2900
  patch.nickname = normalized;
1406
2901
  } else if (key === "timezone") {
@@ -1409,16 +2904,14 @@ function applyFrontmatterField(patch, key, value) {
1409
2904
  patch.preferences = normalized;
1410
2905
  } else if (key === "personality") {
1411
2906
  patch.personality = normalized;
2907
+ } else if (key === "interests") {
2908
+ patch.interests = normalized;
1412
2909
  } else if (key === "role") {
1413
2910
  patch.role = normalized;
1414
2911
  } else if (key === "visibility") {
1415
2912
  patch.visibility = normalized === "shared" ? "shared" : "private";
1416
2913
  }
1417
2914
  }
1418
- function hasProfileDifference(current, patch) {
1419
- const entries = Object.entries(patch);
1420
- return entries.some(([key, value]) => value != null && current[key] !== value);
1421
- }
1422
2915
  function renderYamlList(rows) {
1423
2916
  if (rows.length === 0) {
1424
2917
  return "[]\n";
@@ -1463,6 +2956,386 @@ function asTextResult(value) {
1463
2956
  function asNullableString(value) {
1464
2957
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
1465
2958
  }
2959
+ function escapeRegExp(value) {
2960
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2961
+ }
2962
+ function expandHomePath(value) {
2963
+ const text = asNullableString(value);
2964
+ if (!text) {
2965
+ return null;
2966
+ }
2967
+ if (text === "~") {
2968
+ return (0, import_node_os.homedir)();
2969
+ }
2970
+ if (text.startsWith("~/")) {
2971
+ return (0, import_node_path.join)((0, import_node_os.homedir)(), text.slice(2));
2972
+ }
2973
+ return text;
2974
+ }
2975
+ function computeSemanticProfileRetryDelayMs(attempt) {
2976
+ const base = Number(process.env.BAMDRA_USER_BIND_RETRY_BASE_MS ?? 5e3);
2977
+ const safeBase = Number.isFinite(base) && base > 0 ? base : 5e3;
2978
+ return safeBase * Math.max(1, attempt);
2979
+ }
2980
+ function buildPendingSemanticRefineNote(messageText, fingerprint) {
2981
+ const compact = messageText.replace(/\s+/g, " ").trim().slice(0, 120);
2982
+ return `[pending-profile-refine:${fingerprint}] ${compact}`;
2983
+ }
2984
+ function extractPendingSemanticRefineEntries(notes) {
2985
+ const value = sanitizeProfileNotes(notes);
2986
+ if (!value) {
2987
+ return [];
2988
+ }
2989
+ const entries = [];
2990
+ const pattern = /\[pending-profile-refine:([a-f0-9]+)\]\s*([^\n]+)/g;
2991
+ for (const match of value.matchAll(pattern)) {
2992
+ const fingerprint = match[1]?.trim();
2993
+ const messageText = match[2]?.trim();
2994
+ if (!fingerprint || !messageText) {
2995
+ continue;
2996
+ }
2997
+ entries.push({ fingerprint, messageText });
2998
+ }
2999
+ return entries;
3000
+ }
3001
+ function removePendingSemanticRefineEntry(notes, fingerprint) {
3002
+ const value = sanitizeProfileNotes(notes);
3003
+ if (!value) {
3004
+ return null;
3005
+ }
3006
+ const pattern = new RegExp(`(?:^|\\n)\\[pending-profile-refine:${escapeRegExp(fingerprint)}\\][^\\n]*(?=\\n|$)`, "g");
3007
+ const cleaned = value.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trim();
3008
+ return cleaned || null;
3009
+ }
3010
+ async function inferSemanticProfileExtraction(messageText, currentProfile) {
3011
+ const model = readProfileExtractorModelFromOpenClawConfig();
3012
+ if (!model) {
3013
+ return null;
3014
+ }
3015
+ logUserBindEvent("semantic-profile-extractor-request", {
3016
+ providerId: model.providerId,
3017
+ modelId: model.modelId,
3018
+ baseUrl: model.baseUrl,
3019
+ timeoutMs: SEMANTIC_PROFILE_CAPTURE_TIMEOUT_MS,
3020
+ messageChars: messageText.length
3021
+ });
3022
+ const response = await fetch(`${model.baseUrl}/chat/completions`, {
3023
+ method: "POST",
3024
+ signal: AbortSignal.timeout(SEMANTIC_PROFILE_CAPTURE_TIMEOUT_MS),
3025
+ headers: {
3026
+ authorization: `Bearer ${model.apiKey}`,
3027
+ "content-type": "application/json; charset=utf-8"
3028
+ },
3029
+ body: JSON.stringify({
3030
+ model: model.modelId,
3031
+ temperature: 0,
3032
+ max_tokens: 400,
3033
+ response_format: { type: "json_object" },
3034
+ messages: [
3035
+ {
3036
+ role: "system",
3037
+ content: [
3038
+ "You extract stable user-profile information from a single user message, a short window of recent user messages, or a brief assistant-question plus user-answer exchange from the same conversation.",
3039
+ "Return JSON only.",
3040
+ "Only capture durable, reusable identity facts, preferences, and personal profile details that should affect future conversations.",
3041
+ "Ignore transient task requirements, one-off requests, or speculative guesses.",
3042
+ "Prefer precision, but do not miss clear self-descriptions or durable profile facts.",
3043
+ "Allowed fields: name, gender, birthDate, birthYear, age, nickname, preferences, personality, interests, role, timezone, notes.",
3044
+ "For each field, also decide the update operation: replace, append, or remove.",
3045
+ "Use the structured fields whenever possible instead of dumping transcripts into notes.",
3046
+ "notes is only for durable boundaries or habits that do not fit the structured fields cleanly.",
3047
+ "Use append when the user adds another stable fact or preference without revoking the old one.",
3048
+ "Use replace when the user corrects or changes an existing stable fact or preference.",
3049
+ "Use remove when the user clearly asks to drop a specific old fact or preference.",
3050
+ "Do not require rigid trigger phrases. Judge by meaning, not literal wording.",
3051
+ "If the input includes a recent assistant profile-collection question followed by a short user answer, use that dialogue context to resolve what the user meant.",
3052
+ "Treat meta-instructions about saving or updating the profile as control signals, not profile content themselves.",
3053
+ "Do not copy placeholders, examples, or template language into the patch.",
3054
+ 'Return exactly this shape: {"should_update":boolean,"confidence":number,"patch":{"name":string?,"gender":string?,"birthDate":string?,"birthYear":string?,"age":string?,"nickname":string?,"preferences":string?,"personality":string?,"interests":string?,"role":string?,"timezone":string?,"notes":string?},"operations":{"name":"replace|append|remove"?,"gender":"replace|append|remove"?,"birthDate":"replace|append|remove"?,"birthYear":"replace|append|remove"?,"age":"replace|append|remove"?,"nickname":"replace|append|remove"?,"preferences":"replace|append|remove"?,"personality":"replace|append|remove"?,"interests":"replace|append|remove"?,"role":"replace|append|remove"?,"timezone":"replace|append|remove"?,"notes":"replace|append|remove"?}}'
3055
+ ].join("\n")
3056
+ },
3057
+ {
3058
+ role: "user",
3059
+ content: JSON.stringify({
3060
+ current_profile: {
3061
+ name: currentProfile.name,
3062
+ gender: currentProfile.gender,
3063
+ birthDate: currentProfile.birthDate,
3064
+ birthYear: currentProfile.birthYear,
3065
+ age: currentProfile.age,
3066
+ nickname: currentProfile.nickname,
3067
+ preferences: currentProfile.preferences,
3068
+ personality: currentProfile.personality,
3069
+ interests: currentProfile.interests,
3070
+ role: currentProfile.role,
3071
+ timezone: currentProfile.timezone,
3072
+ notes: currentProfile.notes
3073
+ },
3074
+ latest_user_message: messageText
3075
+ })
3076
+ }
3077
+ ]
3078
+ })
3079
+ });
3080
+ const payload = await response.json();
3081
+ if (!response.ok) {
3082
+ throw new Error(`semantic profile extractor request failed: ${JSON.stringify(payload)}`);
3083
+ }
3084
+ const content = extractOpenAiMessageContent(payload);
3085
+ const parsed = parseSemanticExtractionResult(content);
3086
+ if (!parsed) {
3087
+ throw new Error(`semantic profile extractor returned unreadable content: ${content}`);
3088
+ }
3089
+ return parsed;
3090
+ }
3091
+ function extractOpenAiMessageContent(payload) {
3092
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
3093
+ const first = choices[0];
3094
+ if (!first || typeof first !== "object") {
3095
+ return "";
3096
+ }
3097
+ const message = first.message;
3098
+ if (!message || typeof message !== "object") {
3099
+ return "";
3100
+ }
3101
+ const content = message.content;
3102
+ if (typeof content === "string") {
3103
+ return content.trim();
3104
+ }
3105
+ if (!Array.isArray(content)) {
3106
+ return "";
3107
+ }
3108
+ return content.map((item) => {
3109
+ if (!item || typeof item !== "object") {
3110
+ return "";
3111
+ }
3112
+ const record = item;
3113
+ return asNullableString(record.text) ?? "";
3114
+ }).filter(Boolean).join("\n").trim();
3115
+ }
3116
+ function parseSemanticExtractionResult(content) {
3117
+ const jsonText = extractJsonObject(content);
3118
+ if (!jsonText) {
3119
+ return null;
3120
+ }
3121
+ try {
3122
+ const parsed = JSON.parse(jsonText);
3123
+ const patchInput = parsed.patch && typeof parsed.patch === "object" ? parsed.patch : {};
3124
+ const operationsInput = parsed.operations && typeof parsed.operations === "object" ? parsed.operations : {};
3125
+ return {
3126
+ shouldUpdate: parsed.should_update === true || parsed.shouldUpdate === true,
3127
+ confidence: Number(parsed.confidence ?? 0),
3128
+ patch: {
3129
+ name: asNullableString(patchInput.name),
3130
+ gender: asNullableString(patchInput.gender),
3131
+ birthDate: asNullableString(patchInput.birthDate),
3132
+ birthYear: asNullableString(patchInput.birthYear),
3133
+ age: asNullableString(patchInput.age),
3134
+ nickname: asNullableString(patchInput.nickname),
3135
+ preferences: asNullableString(patchInput.preferences),
3136
+ personality: asNullableString(patchInput.personality),
3137
+ interests: asNullableString(patchInput.interests),
3138
+ role: asNullableString(patchInput.role),
3139
+ timezone: asNullableString(patchInput.timezone),
3140
+ notes: asNullableString(patchInput.notes)
3141
+ },
3142
+ operations: parseProfilePatchOperations(operationsInput)
3143
+ };
3144
+ } catch {
3145
+ return null;
3146
+ }
3147
+ }
3148
+ function parseProfilePatchOperations(input) {
3149
+ const operations = {};
3150
+ for (const field of ["name", "gender", "birthDate", "birthYear", "age", "nickname", "preferences", "personality", "interests", "role", "timezone", "notes"]) {
3151
+ const raw = asNullableString(input[field]);
3152
+ if (raw === "replace" || raw === "append" || raw === "remove") {
3153
+ operations[field] = raw;
3154
+ }
3155
+ }
3156
+ return Object.keys(operations).length > 0 ? operations : void 0;
3157
+ }
3158
+ function extractJsonObject(content) {
3159
+ const trimmed = content.trim();
3160
+ if (!trimmed) {
3161
+ return null;
3162
+ }
3163
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
3164
+ if (fenced?.[1]) {
3165
+ return fenced[1].trim();
3166
+ }
3167
+ const start = trimmed.indexOf("{");
3168
+ const end = trimmed.lastIndexOf("}");
3169
+ if (start >= 0 && end > start) {
3170
+ return trimmed.slice(start, end + 1);
3171
+ }
3172
+ return null;
3173
+ }
3174
+ function cleanupSemanticProfilePatch(patch, currentProfile, operations) {
3175
+ const next = {};
3176
+ const nextOperations = {};
3177
+ const name = asNullableString(patch.name);
3178
+ const gender = asNullableString(patch.gender);
3179
+ const birthDate = asNullableString(patch.birthDate);
3180
+ const birthYear = asNullableString(patch.birthYear);
3181
+ const age = asNullableString(patch.age);
3182
+ const nickname = asNullableString(patch.nickname);
3183
+ const preferences = asNullableString(patch.preferences);
3184
+ const personality = asNullableString(patch.personality);
3185
+ const interests = asNullableString(patch.interests);
3186
+ const role = asNullableString(patch.role);
3187
+ const timezone = asNullableString(patch.timezone);
3188
+ const notes = asNullableString(patch.notes);
3189
+ if (name && name !== currentProfile.name) {
3190
+ next.name = name;
3191
+ if (operations?.name) {
3192
+ nextOperations.name = operations.name;
3193
+ }
3194
+ }
3195
+ if (gender && gender !== currentProfile.gender) {
3196
+ next.gender = gender;
3197
+ if (operations?.gender) {
3198
+ nextOperations.gender = operations.gender;
3199
+ }
3200
+ }
3201
+ if (birthDate && birthDate !== currentProfile.birthDate) {
3202
+ next.birthDate = birthDate;
3203
+ if (operations?.birthDate) {
3204
+ nextOperations.birthDate = operations.birthDate;
3205
+ }
3206
+ }
3207
+ if (birthYear && birthYear !== currentProfile.birthYear) {
3208
+ next.birthYear = birthYear;
3209
+ if (operations?.birthYear) {
3210
+ nextOperations.birthYear = operations.birthYear;
3211
+ }
3212
+ }
3213
+ if (age && age !== currentProfile.age) {
3214
+ next.age = age;
3215
+ if (operations?.age) {
3216
+ nextOperations.age = operations.age;
3217
+ }
3218
+ }
3219
+ if (nickname && nickname !== currentProfile.nickname) {
3220
+ next.nickname = nickname;
3221
+ if (operations?.nickname) {
3222
+ nextOperations.nickname = operations.nickname;
3223
+ }
3224
+ }
3225
+ if (preferences && preferences !== currentProfile.preferences) {
3226
+ next.preferences = preferences;
3227
+ if (operations?.preferences) {
3228
+ nextOperations.preferences = operations.preferences;
3229
+ }
3230
+ }
3231
+ if (personality && personality !== currentProfile.personality) {
3232
+ next.personality = personality;
3233
+ if (operations?.personality) {
3234
+ nextOperations.personality = operations.personality;
3235
+ }
3236
+ }
3237
+ if (interests && interests !== currentProfile.interests) {
3238
+ next.interests = interests;
3239
+ if (operations?.interests) {
3240
+ nextOperations.interests = operations.interests;
3241
+ }
3242
+ }
3243
+ if (role && role !== currentProfile.role) {
3244
+ next.role = role;
3245
+ if (operations?.role) {
3246
+ nextOperations.role = operations.role;
3247
+ }
3248
+ }
3249
+ if (timezone && timezone !== currentProfile.timezone) {
3250
+ next.timezone = timezone;
3251
+ if (operations?.timezone) {
3252
+ nextOperations.timezone = operations.timezone;
3253
+ }
3254
+ }
3255
+ if (notes) {
3256
+ next.notes = notes;
3257
+ if (operations?.notes) {
3258
+ nextOperations.notes = operations.notes;
3259
+ }
3260
+ }
3261
+ return {
3262
+ patch: next,
3263
+ operations: Object.keys(nextOperations).length > 0 ? nextOperations : void 0
3264
+ };
3265
+ }
3266
+ function applyProfilePatchOperations(currentProfile, patch, operations) {
3267
+ const next = {};
3268
+ const fields = ["name", "gender", "birthDate", "birthYear", "age", "nickname", "preferences", "personality", "interests", "role", "timezone", "notes"];
3269
+ for (const field of fields) {
3270
+ if (patch[field] === void 0) {
3271
+ continue;
3272
+ }
3273
+ const currentValue = currentProfile[field];
3274
+ const incomingValue = asNullableString(patch[field]);
3275
+ const operation = operations?.[field] ?? defaultProfileFieldOperation(field);
3276
+ const resolved = resolveProfileFieldUpdate(field, currentValue, incomingValue, operation);
3277
+ if (resolved !== void 0) {
3278
+ next[field] = resolved;
3279
+ }
3280
+ }
3281
+ return next;
3282
+ }
3283
+ function defaultProfileFieldOperation(field) {
3284
+ if (field === "notes" || field === "interests") {
3285
+ return "append";
3286
+ }
3287
+ return "replace";
3288
+ }
3289
+ function resolveProfileFieldUpdate(field, currentValue, incomingValue, operation) {
3290
+ if (operation === "remove") {
3291
+ if (!currentValue) {
3292
+ return void 0;
3293
+ }
3294
+ if (!incomingValue) {
3295
+ return null;
3296
+ }
3297
+ const removed = removeProfileFeatureValue(currentValue, incomingValue);
3298
+ return removed === currentValue ? void 0 : removed;
3299
+ }
3300
+ if (!incomingValue) {
3301
+ return void 0;
3302
+ }
3303
+ if (operation === "append") {
3304
+ if (field === "notes") {
3305
+ const mergedNotes = joinNotes(currentValue, incomingValue);
3306
+ return mergedNotes === currentValue ? void 0 : mergedNotes;
3307
+ }
3308
+ const appended = appendProfileFeatureValue(currentValue, incomingValue);
3309
+ return appended === currentValue ? void 0 : appended;
3310
+ }
3311
+ return incomingValue === currentValue ? void 0 : incomingValue;
3312
+ }
3313
+ function appendProfileFeatureValue(currentValue, incomingValue) {
3314
+ if (!currentValue) {
3315
+ return incomingValue;
3316
+ }
3317
+ const currentItems = splitProfileFeatureValues(currentValue);
3318
+ const incomingItems = splitProfileFeatureValues(incomingValue);
3319
+ const merged = [...currentItems];
3320
+ for (const item of incomingItems) {
3321
+ if (!merged.includes(item)) {
3322
+ merged.push(item);
3323
+ }
3324
+ }
3325
+ return merged.join("\uFF1B");
3326
+ }
3327
+ function removeProfileFeatureValue(currentValue, incomingValue) {
3328
+ const currentItems = splitProfileFeatureValues(currentValue);
3329
+ const removals = new Set(splitProfileFeatureValues(incomingValue));
3330
+ const nextItems = currentItems.filter((item) => !removals.has(item));
3331
+ if (nextItems.length === currentItems.length) {
3332
+ return currentValue;
3333
+ }
3334
+ return nextItems.length > 0 ? nextItems.join("\uFF1B") : null;
3335
+ }
3336
+ function splitProfileFeatureValues(value) {
3337
+ return value.split(/[\n;;,,、]+/u).map((item) => item.trim()).filter(Boolean);
3338
+ }
1466
3339
  function ensureObject(parent, key) {
1467
3340
  const current = parent[key];
1468
3341
  if (current && typeof current === "object" && !Array.isArray(current)) {
@@ -1484,60 +3357,6 @@ function ensureArrayIncludes(parent, key, value) {
1484
3357
  parent[key] = current;
1485
3358
  return true;
1486
3359
  }
1487
- function extractScopes(result) {
1488
- const candidates = [
1489
- findNestedValue(result, ["data", "scopes"]),
1490
- findNestedValue(result, ["scopes"]),
1491
- findNestedValue(result, ["data", "items"])
1492
- ];
1493
- for (const candidate of candidates) {
1494
- if (!Array.isArray(candidate)) {
1495
- continue;
1496
- }
1497
- const scopes = candidate.map((item) => {
1498
- if (typeof item === "string") {
1499
- return item;
1500
- }
1501
- if (item && typeof item === "object") {
1502
- const record = item;
1503
- const scope = record.scope ?? record.name;
1504
- return typeof scope === "string" ? scope : "";
1505
- }
1506
- return "";
1507
- }).filter(Boolean);
1508
- if (scopes.length > 0) {
1509
- return scopes;
1510
- }
1511
- }
1512
- return [];
1513
- }
1514
- function findBitableRecordId(result, userId) {
1515
- const candidates = [
1516
- findNestedValue(result, ["data", "items"]),
1517
- findNestedValue(result, ["items"]),
1518
- findNestedValue(result, ["data", "records"])
1519
- ];
1520
- for (const candidate of candidates) {
1521
- if (!Array.isArray(candidate)) {
1522
- continue;
1523
- }
1524
- for (const item of candidate) {
1525
- if (!item || typeof item !== "object") {
1526
- continue;
1527
- }
1528
- const record = item;
1529
- const fields = record.fields && typeof record.fields === "object" ? record.fields : {};
1530
- if (String(fields.user_id ?? "") !== userId) {
1531
- continue;
1532
- }
1533
- const recordId = record.record_id ?? record.recordId ?? record.id;
1534
- if (typeof recordId === "string" && recordId.trim()) {
1535
- return recordId;
1536
- }
1537
- }
1538
- }
1539
- return null;
1540
- }
1541
3360
  function readFeishuAccountsFromOpenClawConfig() {
1542
3361
  const openclawConfigPath = (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "openclaw.json");
1543
3362
  if (!(0, import_node_fs.existsSync)(openclawConfigPath)) {
@@ -1561,6 +3380,89 @@ function readFeishuAccountsFromOpenClawConfig() {
1561
3380
  return [];
1562
3381
  }
1563
3382
  }
3383
+ function readProfileExtractorModelFromOpenClawConfig() {
3384
+ const openclawConfigPath = (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "openclaw.json");
3385
+ if (!(0, import_node_fs.existsSync)(openclawConfigPath)) {
3386
+ return null;
3387
+ }
3388
+ try {
3389
+ const parsed = JSON.parse((0, import_node_fs.readFileSync)(openclawConfigPath, "utf8"));
3390
+ const models = parsed.models && typeof parsed.models === "object" ? parsed.models : {};
3391
+ const providers = models.providers && typeof models.providers === "object" ? models.providers : {};
3392
+ const agents = parsed.agents && typeof parsed.agents === "object" ? parsed.agents : {};
3393
+ const defaults = agents.defaults && typeof agents.defaults === "object" ? agents.defaults : {};
3394
+ const defaultModel = defaults.model && typeof defaults.model === "object" ? defaults.model : {};
3395
+ const candidates = [
3396
+ ...readConfiguredModelRefs(defaultModel.fallback),
3397
+ ...readConfiguredModelRefs(defaultModel.fallbacks),
3398
+ ...readConfiguredModelRefs(models.fallback),
3399
+ ...readConfiguredModelRefs(models.fallbacks),
3400
+ ...readConfiguredModelRefs(defaultModel.primary),
3401
+ ...readConfiguredModelRefs(models.primary)
3402
+ ];
3403
+ for (const configuredModel of candidates) {
3404
+ if (!configuredModel.includes("/")) {
3405
+ continue;
3406
+ }
3407
+ const [providerId, modelId] = configuredModel.split("/", 2);
3408
+ const provider = providers[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null;
3409
+ if (!provider) {
3410
+ continue;
3411
+ }
3412
+ const api = asNullableString(provider.api);
3413
+ const baseUrl = asNullableString(provider.baseUrl) ?? asNullableString(provider.baseURL);
3414
+ const apiKey = asNullableString(provider.apiKey);
3415
+ if (api !== "openai-completions" || !baseUrl || !apiKey) {
3416
+ continue;
3417
+ }
3418
+ return {
3419
+ providerId,
3420
+ modelId,
3421
+ baseUrl: baseUrl.replace(/\/+$/, ""),
3422
+ apiKey
3423
+ };
3424
+ }
3425
+ return null;
3426
+ } catch (error) {
3427
+ logUserBindEvent("profile-extractor-config-read-failed", {
3428
+ message: error instanceof Error ? error.message : String(error)
3429
+ });
3430
+ return null;
3431
+ }
3432
+ }
3433
+ function readSemanticProfileCaptureTimeoutMs() {
3434
+ const raw = Number(process.env.BAMDRA_USER_BIND_SEMANTIC_TIMEOUT_MS ?? "12000");
3435
+ if (!Number.isFinite(raw)) {
3436
+ return 12e3;
3437
+ }
3438
+ return Math.max(2500, Math.floor(raw));
3439
+ }
3440
+ function isUserBindRuntimeLike(value) {
3441
+ if (!value || typeof value !== "object") {
3442
+ return false;
3443
+ }
3444
+ const record = value;
3445
+ return record[GLOBAL_RUNTIME_BRAND_KEY] === true && typeof record.register === "function" && typeof record.close === "function";
3446
+ }
3447
+ function getGlobalPendingSemanticRefines() {
3448
+ const globalRecord = globalThis;
3449
+ const existing = globalRecord[GLOBAL_PENDING_REFINE_KEY];
3450
+ if (existing instanceof Set) {
3451
+ return existing;
3452
+ }
3453
+ const created = /* @__PURE__ */ new Set();
3454
+ globalRecord[GLOBAL_PENDING_REFINE_KEY] = created;
3455
+ return created;
3456
+ }
3457
+ function readConfiguredModelRefs(value) {
3458
+ if (typeof value === "string") {
3459
+ return value.trim() ? [value.trim()] : [];
3460
+ }
3461
+ if (Array.isArray(value)) {
3462
+ return value.map((item) => asNullableString(item)).filter((item) => Boolean(item));
3463
+ }
3464
+ return [];
3465
+ }
1564
3466
  function normalizeFeishuAccount(accountId, input, fallback) {
1565
3467
  const record = input && typeof input === "object" ? input : {};
1566
3468
  const enabled = record.enabled !== false && fallback.enabled !== false;
@@ -1602,12 +3504,107 @@ function getConfiguredAgentId(agent) {
1602
3504
  }
1603
3505
  function defaultProfileNotes() {
1604
3506
  return [
1605
- "- \u5EFA\u8BAE\u79F0\u547C\uFF1A\u8001\u677F",
1606
- "- \u65F6\u533A\uFF1AAsia/Shanghai",
1607
- "- \u504F\u597D\uFF1A\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206",
1608
- "- \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"
3507
+ "- \u8FD9\u91CC\u53EA\u8BB0\u5F55\u5DF2\u7ECF\u786E\u8BA4\u7684\u957F\u671F\u753B\u50CF\u4FE1\u606F\uFF0C\u4E0D\u8BB0\u5F55\u4E34\u65F6\u4EFB\u52A1\u8981\u6C42\u3002",
3508
+ `- \u82E5\u7528\u6237\u5C1A\u672A\u660E\u786E\u63D0\u4F9B\u65F6\u533A\uFF0C\u9ED8\u8BA4\u8DDF\u968F\u5F53\u524D\u670D\u52A1\u5668\u65F6\u533A\uFF08\u5F53\u524D\u68C0\u6D4B\u503C\uFF1A${getServerTimezone()}\uFF09\u3002`,
3509
+ "- \u5907\u6CE8\u91CC\u9002\u5408\u5199\u6682\u65F6\u8FD8\u4E0D\u4FBF\u7ED3\u6784\u5316\u3001\u4F46\u5DF2\u7ECF\u786E\u8BA4\u7684\u957F\u671F\u4E60\u60EF\u3001\u8FB9\u754C\u548C\u534F\u4F5C\u504F\u597D\u3002"
1609
3510
  ].join("\n");
1610
3511
  }
3512
+ function createEphemeralUserId(channelType, openId, sessionId) {
3513
+ return `temp_${hashId(`${channelType}:${openId ?? sessionId}`)}`;
3514
+ }
3515
+ function createProvisionalUserId(channelType, openId) {
3516
+ return `${channelType}:oid_${hashId(`${channelType}:${openId}`)}`;
3517
+ }
3518
+ function createStableLocalUserId(channelType, openId, sessionId) {
3519
+ return `${channelType}:ub_${hashId(`${channelType}:${openId ?? sessionId}`)}`;
3520
+ }
3521
+ function isProvisionalScopedUserId(userId) {
3522
+ return /:oid_[a-f0-9]+$/i.test(userId);
3523
+ }
3524
+ function getPendingBindingRetryDelayMs(reason, attempts) {
3525
+ if (reason === "feishu-contact-scope-missing") {
3526
+ return Math.min(30 * 60 * 1e3, Math.max(6e4, attempts * 5 * 6e4));
3527
+ }
3528
+ return Math.min(10 * 60 * 1e3, Math.max(15e3, attempts * 3e4));
3529
+ }
3530
+ function scopeUserId(channelType, userId) {
3531
+ if (!userId) {
3532
+ return userId;
3533
+ }
3534
+ if (channelType === "manual" || channelType === "local") {
3535
+ return userId;
3536
+ }
3537
+ if (userId.startsWith("temp_") || userId.startsWith("ub_")) {
3538
+ return userId;
3539
+ }
3540
+ if (extractChannelFromScopedUserId(userId)) {
3541
+ return userId;
3542
+ }
3543
+ return `${channelType}:${userId}`;
3544
+ }
3545
+ function getExternalUserId(channelType, userId) {
3546
+ if (!userId) {
3547
+ return userId;
3548
+ }
3549
+ if (channelType === "manual" || channelType === "local") {
3550
+ return userId;
3551
+ }
3552
+ if (userId.startsWith("temp_") || userId.startsWith("ub_")) {
3553
+ return userId;
3554
+ }
3555
+ const currentChannel = extractChannelFromScopedUserId(userId);
3556
+ if (currentChannel && userId.startsWith(`${currentChannel}:`)) {
3557
+ return userId.slice(currentChannel.length + 1);
3558
+ }
3559
+ return userId;
3560
+ }
3561
+ function extractChannelFromScopedUserId(userId) {
3562
+ const match = userId.match(/^([a-z][a-z0-9_-]*):(.+)$/i);
3563
+ if (!match) {
3564
+ return null;
3565
+ }
3566
+ return match[1].toLowerCase();
3567
+ }
3568
+ function buildLightweightProfile(args) {
3569
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3570
+ return {
3571
+ userId: args.userId,
3572
+ name: args.profilePatch.name ?? args.current?.name ?? null,
3573
+ gender: args.profilePatch.gender ?? args.current?.gender ?? null,
3574
+ email: args.profilePatch.email ?? args.current?.email ?? null,
3575
+ avatar: args.profilePatch.avatar ?? args.current?.avatar ?? null,
3576
+ nickname: args.profilePatch.nickname ?? args.current?.nickname ?? null,
3577
+ preferences: args.profilePatch.preferences ?? args.current?.preferences ?? null,
3578
+ personality: args.profilePatch.personality ?? args.current?.personality ?? null,
3579
+ role: args.profilePatch.role ?? args.current?.role ?? null,
3580
+ timezone: args.profilePatch.timezone ?? args.current?.timezone ?? getServerTimezone(),
3581
+ notes: args.profilePatch.notes ?? args.current?.notes ?? defaultProfileNotes(),
3582
+ visibility: args.profilePatch.visibility ?? args.current?.visibility ?? "private",
3583
+ source: args.source,
3584
+ updatedAt: now
3585
+ };
3586
+ }
3587
+ function looksLikeFeishuContactScopeError(message) {
3588
+ return message.includes("99991672") || message.includes("permission_violations") || message.includes("contact:contact.base:readonly") || message.includes("contact:contact:access_as_app") || message.includes("contact:contact:readonly");
3589
+ }
3590
+ function renderFeishuContactScopeGuidance(accountIds) {
3591
+ const scopeText = accountIds.length > 0 ? `\u6D89\u53CA\u8D26\u53F7\uFF1A${accountIds.join("\u3001")}\u3002` : "";
3592
+ return [
3593
+ "\u5F53\u524D\u65E0\u6CD5\u5B8C\u6574\u542F\u7528\u7528\u6237\u753B\u50CF\u3002",
3594
+ "\u539F\u56E0\uFF1A\u5BF9\u5E94\u7684 Feishu App \u7F3A\u5C11\u8054\u7CFB\u4EBA\u6743\u9650\uFF0C\u6682\u65F6\u65E0\u6CD5\u7A33\u5B9A\u89E3\u6790\u771F\u5B9E\u7528\u6237\u8EAB\u4EFD\u3002",
3595
+ `${scopeText}\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5F00\u901A\u8054\u7CFB\u4EBA\u76F8\u5173\u6743\u9650\uFF0C\u4EE5\u4FDD\u8BC1\u7528\u6237\u753B\u50CF\u53EF\u7528\u3002`
3596
+ ].join(" ");
3597
+ }
3598
+ function getServerTimezone() {
3599
+ try {
3600
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
3601
+ if (typeof timezone === "string" && timezone.trim()) {
3602
+ return timezone.trim();
3603
+ }
3604
+ } catch {
3605
+ }
3606
+ return "UTC";
3607
+ }
1611
3608
  function sanitizeProfileNotes(notes) {
1612
3609
  const value = typeof notes === "string" ? notes.trim() : "";
1613
3610
  if (!value) {