chattercatcher 0.2.6 → 0.2.7

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/cli.js CHANGED
@@ -8,7 +8,7 @@ import fs15 from "fs/promises";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "chattercatcher",
11
- version: "0.2.6",
11
+ version: "0.2.7",
12
12
  description: "\u672C\u5730\u4F18\u5148\u7684\u98DE\u4E66/Lark \u5BB6\u5EAD\u7FA4\u77E5\u8BC6\u5E93\u673A\u5668\u4EBA",
13
13
  type: "module",
14
14
  main: "dist/index.js",
@@ -31,7 +31,8 @@ var package_default = {
31
31
  "docs/PRD.md",
32
32
  "docs/TECHNICAL_ARCHITECTURE.md",
33
33
  "README.md",
34
- "AGENTS.md"
34
+ "AGENTS.md",
35
+ "CHANGELOG.md"
35
36
  ],
36
37
  directories: {
37
38
  doc: "docs"
@@ -416,6 +417,7 @@ function migrateDatabase(database) {
416
417
  chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
417
418
  sender_id TEXT NOT NULL,
418
419
  sender_name TEXT NOT NULL,
420
+ person_id TEXT REFERENCES persons(id) ON DELETE SET NULL,
419
421
  message_type TEXT NOT NULL,
420
422
  text TEXT NOT NULL,
421
423
  raw_payload_json TEXT NOT NULL,
@@ -453,6 +455,80 @@ function migrateDatabase(database) {
453
455
  UNIQUE(chat_id, started_at, ended_at)
454
456
  );
455
457
 
458
+ CREATE TABLE IF NOT EXISTS persons (
459
+ id TEXT PRIMARY KEY,
460
+ primary_name TEXT NOT NULL,
461
+ notes TEXT,
462
+ created_at TEXT NOT NULL,
463
+ updated_at TEXT NOT NULL
464
+ );
465
+
466
+ CREATE TABLE IF NOT EXISTS person_identities (
467
+ id TEXT PRIMARY KEY,
468
+ person_id TEXT NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
469
+ platform TEXT NOT NULL,
470
+ platform_chat_id TEXT NOT NULL,
471
+ external_user_id TEXT NOT NULL,
472
+ external_open_id TEXT,
473
+ external_union_id TEXT,
474
+ external_user_id_raw TEXT,
475
+ display_name TEXT NOT NULL,
476
+ alias TEXT,
477
+ source TEXT NOT NULL CHECK(source IN ('message','feishu_member','manual','inferred')),
478
+ first_seen_at TEXT NOT NULL,
479
+ last_seen_at TEXT NOT NULL,
480
+ UNIQUE(platform, platform_chat_id, external_user_id)
481
+ );
482
+
483
+ CREATE INDEX IF NOT EXISTS person_identities_person_idx ON person_identities(person_id);
484
+ CREATE INDEX IF NOT EXISTS person_identities_lookup_idx ON person_identities(platform, platform_chat_id, external_user_id);
485
+ CREATE INDEX IF NOT EXISTS person_identities_name_idx ON person_identities(display_name, alias);
486
+
487
+ CREATE TABLE IF NOT EXISTS person_profile_entries (
488
+ id TEXT PRIMARY KEY,
489
+ person_id TEXT NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
490
+ category TEXT NOT NULL,
491
+ content TEXT NOT NULL,
492
+ entry_type TEXT NOT NULL CHECK(entry_type IN ('fact','inferred')),
493
+ confidence REAL NOT NULL,
494
+ status TEXT NOT NULL CHECK(status IN ('active','superseded','deleted')),
495
+ source TEXT NOT NULL CHECK(source IN ('dream','explicit_user_request','manual')),
496
+ created_at TEXT NOT NULL,
497
+ updated_at TEXT NOT NULL,
498
+ last_observed_at TEXT NOT NULL
499
+ );
500
+
501
+ CREATE INDEX IF NOT EXISTS person_profile_entries_person_status_idx ON person_profile_entries(person_id, status, updated_at);
502
+
503
+ CREATE TABLE IF NOT EXISTS person_profile_evidence (
504
+ entry_id TEXT NOT NULL REFERENCES person_profile_entries(id) ON DELETE CASCADE,
505
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
506
+ quote TEXT NOT NULL,
507
+ reason TEXT NOT NULL,
508
+ PRIMARY KEY (entry_id, message_id, quote)
509
+ );
510
+
511
+ CREATE TABLE IF NOT EXISTS profile_dream_state (
512
+ platform TEXT NOT NULL,
513
+ platform_chat_id TEXT NOT NULL,
514
+ last_message_id TEXT,
515
+ last_message_sent_at TEXT,
516
+ updated_at TEXT NOT NULL,
517
+ PRIMARY KEY (platform, platform_chat_id)
518
+ );
519
+
520
+ CREATE TABLE IF NOT EXISTS profile_dream_runs (
521
+ id TEXT PRIMARY KEY,
522
+ platform TEXT NOT NULL,
523
+ platform_chat_id TEXT NOT NULL,
524
+ status TEXT NOT NULL CHECK(status IN ('succeeded','failed','skipped')),
525
+ processed_message_count INTEGER NOT NULL,
526
+ generated_entry_count INTEGER NOT NULL,
527
+ error TEXT,
528
+ started_at TEXT NOT NULL,
529
+ finished_at TEXT NOT NULL
530
+ );
531
+
456
532
  CREATE TABLE IF NOT EXISTS memory_episode_messages (
457
533
  episode_id TEXT NOT NULL REFERENCES memory_episodes(id) ON DELETE CASCADE,
458
534
  message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
@@ -580,6 +656,11 @@ function migrateDatabase(database) {
580
656
  CREATE INDEX IF NOT EXISTS feishu_chat_members_chat_name_idx
581
657
  ON feishu_chat_members(chat_id, user_name);
582
658
  `);
659
+ const messageColumns = database.prepare("PRAGMA table_info(messages)").all();
660
+ if (!messageColumns.some((column) => column.name === "person_id")) {
661
+ database.prepare("ALTER TABLE messages ADD COLUMN person_id TEXT REFERENCES persons(id) ON DELETE SET NULL").run();
662
+ }
663
+ database.prepare("CREATE INDEX IF NOT EXISTS messages_person_idx ON messages(person_id, sent_at)").run();
583
664
  const cronJobColumns = database.prepare("PRAGMA table_info(cron_jobs)").all();
584
665
  const ensureCronJobColumn = (name, definition) => {
585
666
  if (!cronJobColumns.some((column) => column.name === name)) {
@@ -1289,6 +1370,10 @@ function buildScopeWhere(scope) {
1289
1370
  clauses.push("c.platform_chat_id = ?");
1290
1371
  params.push(scope.platformChatId);
1291
1372
  }
1373
+ if (scope?.personId) {
1374
+ clauses.push("m.person_id = ?");
1375
+ params.push(scope.personId);
1376
+ }
1292
1377
  return {
1293
1378
  where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
1294
1379
  params
@@ -1333,14 +1418,15 @@ var MessageRepository = class {
1333
1418
  `
1334
1419
  INSERT INTO messages (
1335
1420
  id, platform, platform_message_id, chat_id, sender_id, sender_name,
1336
- message_type, text, raw_payload_json, sent_at, received_at, created_at
1421
+ person_id, message_type, text, raw_payload_json, sent_at, received_at, created_at
1337
1422
  )
1338
1423
  VALUES (
1339
1424
  @id, @platform, @platformMessageId, @chatId, @senderId, @senderName,
1340
- @messageType, @text, @rawPayloadJson, @sentAt, @receivedAt, @createdAt
1425
+ @personId, @messageType, @text, @rawPayloadJson, @sentAt, @receivedAt, @createdAt
1341
1426
  )
1342
1427
  ON CONFLICT(platform, platform_message_id)
1343
1428
  DO UPDATE SET
1429
+ person_id = COALESCE(excluded.person_id, messages.person_id),
1344
1430
  message_type = excluded.message_type,
1345
1431
  text = excluded.text,
1346
1432
  raw_payload_json = excluded.raw_payload_json,
@@ -1353,6 +1439,7 @@ var MessageRepository = class {
1353
1439
  chatId,
1354
1440
  senderId: input2.senderId,
1355
1441
  senderName: input2.senderName,
1442
+ personId: input2.personId ?? null,
1356
1443
  messageType: input2.messageType,
1357
1444
  text: input2.text,
1358
1445
  rawPayloadJson,
@@ -1395,6 +1482,7 @@ var MessageRepository = class {
1395
1482
  m.chat_id AS chatId,
1396
1483
  m.sender_id AS senderId,
1397
1484
  m.sender_name AS senderName,
1485
+ m.person_id AS personId,
1398
1486
  m.sent_at AS sentAt,
1399
1487
  c.platform_chat_id AS platformChatId,
1400
1488
  c.name AS chatName
@@ -1417,6 +1505,7 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1417
1505
  platformMessageId: derivedPlatformMessageId,
1418
1506
  senderId: source.senderId,
1419
1507
  senderName: source.senderName,
1508
+ personId: source.personId ?? void 0,
1420
1509
  messageType: "image_summary",
1421
1510
  text: summaryText,
1422
1511
  sentAt: source.sentAt,
@@ -1443,7 +1532,9 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1443
1532
  1.0 AS score,
1444
1533
  m.message_type AS messageType,
1445
1534
  c.name AS chatName,
1535
+ m.sender_id AS senderId,
1446
1536
  m.sender_name AS senderName,
1537
+ m.person_id AS personId,
1447
1538
  m.sent_at AS sentAt
1448
1539
  FROM message_chunks mc
1449
1540
  JOIN messages m ON m.id = mc.message_id
@@ -1464,7 +1555,9 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1464
1555
  1.0 AS score,
1465
1556
  m.message_type AS messageType,
1466
1557
  c.name AS chatName,
1558
+ m.sender_id AS senderId,
1467
1559
  m.sender_name AS senderName,
1560
+ m.person_id AS personId,
1468
1561
  m.sent_at AS sentAt
1469
1562
  FROM message_chunks mc
1470
1563
  JOIN messages m ON m.id = mc.message_id
@@ -1488,7 +1581,9 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1488
1581
  1.0 AS score,
1489
1582
  m.message_type AS messageType,
1490
1583
  c.name AS chatName,
1584
+ m.sender_id AS senderId,
1491
1585
  m.sender_name AS senderName,
1586
+ m.person_id AS personId,
1492
1587
  m.sent_at AS sentAt
1493
1588
  FROM message_chunks mc
1494
1589
  JOIN messages m ON m.id = mc.message_id
@@ -1504,7 +1599,7 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1504
1599
  const excludedIds = options.excludeMessageIds ?? [];
1505
1600
  const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
1506
1601
  const scope = buildScopeWhere(options.scope);
1507
- const ftsResults = this.database.prepare(
1602
+ const ftsRows = this.database.prepare(
1508
1603
  `
1509
1604
  SELECT
1510
1605
  fts.chunk_id AS chunkId,
@@ -1514,8 +1609,11 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1514
1609
  bm25(message_chunks_fts) * -1 AS score,
1515
1610
  m.message_type AS messageType,
1516
1611
  c.name AS chatName,
1612
+ m.sender_id AS senderId,
1517
1613
  m.sender_name AS senderName,
1518
- m.sent_at AS sentAt
1614
+ m.person_id AS personId,
1615
+ m.sent_at AS sentAt,
1616
+ mc.chunk_index AS chunkIndex
1519
1617
  FROM message_chunks_fts fts
1520
1618
  JOIN message_chunks mc ON mc.id = fts.chunk_id
1521
1619
  JOIN messages m ON m.id = fts.message_id
@@ -1523,10 +1621,23 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1523
1621
  WHERE message_chunks_fts MATCH ?
1524
1622
  ${excludedWhere}
1525
1623
  ${scope.where}
1526
- ORDER BY bm25(message_chunks_fts)
1624
+ ORDER BY bm25(message_chunks_fts), m.sent_at DESC, mc.chunk_index ASC
1527
1625
  LIMIT ?
1528
1626
  `
1529
- ).all(ftsQuery, ...excludedIds, ...scope.params, limit);
1627
+ ).all(ftsQuery, ...excludedIds, ...scope.params, Math.max(limit * 8, limit));
1628
+ const ftsResults = [];
1629
+ const seenMessageIds = /* @__PURE__ */ new Set();
1630
+ for (const row of ftsRows) {
1631
+ if (seenMessageIds.has(row.messageId)) {
1632
+ continue;
1633
+ }
1634
+ seenMessageIds.add(row.messageId);
1635
+ const { chunkIndex: _chunkIndex, ...result } = row;
1636
+ ftsResults.push(result);
1637
+ if (ftsResults.length >= limit) {
1638
+ break;
1639
+ }
1640
+ }
1530
1641
  if (ftsResults.length > 0) {
1531
1642
  return ftsResults;
1532
1643
  }
@@ -1540,22 +1651,30 @@ ${input2.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}
1540
1651
  return this.database.prepare(
1541
1652
  `
1542
1653
  SELECT
1543
- mc.id AS chunkId,
1544
- m.id AS messageId,
1545
- m.platform AS platform,
1546
- mc.text AS text,
1547
- 0.1 AS score,
1548
- m.message_type AS messageType,
1549
- c.name AS chatName,
1550
- m.sender_name AS senderName,
1551
- m.sent_at AS sentAt
1552
- FROM message_chunks mc
1553
- JOIN messages m ON m.id = mc.message_id
1554
- JOIN chats c ON c.id = m.chat_id
1555
- WHERE (${where})
1556
- ${likeExcludedWhere}
1557
- ${scope.where}
1558
- ORDER BY m.sent_at DESC
1654
+ *
1655
+ FROM (
1656
+ SELECT
1657
+ mc.id AS chunkId,
1658
+ m.id AS messageId,
1659
+ m.platform AS platform,
1660
+ mc.text AS text,
1661
+ 0.1 AS score,
1662
+ m.message_type AS messageType,
1663
+ c.name AS chatName,
1664
+ m.sender_id AS senderId,
1665
+ m.sender_name AS senderName,
1666
+ m.person_id AS personId,
1667
+ m.sent_at AS sentAt,
1668
+ ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY mc.chunk_index ASC) AS rowNumber
1669
+ FROM message_chunks mc
1670
+ JOIN messages m ON m.id = mc.message_id
1671
+ JOIN chats c ON c.id = m.chat_id
1672
+ WHERE (${where})
1673
+ ${likeExcludedWhere}
1674
+ ${scope.where}
1675
+ ) ranked
1676
+ WHERE rowNumber = 1
1677
+ ORDER BY sentAt DESC
1559
1678
  LIMIT ?
1560
1679
  `
1561
1680
  ).all(...params, ...excludedIds, ...scope.params, limit);
@@ -1970,19 +2089,20 @@ var HybridRetriever = class {
1970
2089
 
1971
2090
  // src/rag/message-retriever.ts
1972
2091
  function toEvidenceSource(result) {
1973
- if (result.messageType === "file") {
1974
- return {
1975
- type: "file",
1976
- label: result.senderName,
1977
- timestamp: result.sentAt
1978
- };
1979
- }
1980
- return {
1981
- type: "message",
1982
- label: result.chatName,
1983
- sender: result.senderName,
2092
+ const source = {
2093
+ type: result.messageType === "file" ? "file" : "message",
2094
+ label: result.messageType === "file" ? result.senderName : result.chatName,
1984
2095
  timestamp: result.sentAt
1985
2096
  };
2097
+ if (result.messageType !== "file") {
2098
+ source.sender = result.senderName;
2099
+ }
2100
+ source.senderId = result.senderId;
2101
+ source.profileAvailable = Boolean(result.personId);
2102
+ if (result.personId) {
2103
+ source.personId = result.personId;
2104
+ }
2105
+ return source;
1986
2106
  }
1987
2107
  var MessageFtsRetriever = class {
1988
2108
  constructor(messages, options = {}) {
@@ -2111,6 +2231,9 @@ function toEvidenceSource2(row) {
2111
2231
  type: "message",
2112
2232
  label: row.chatName,
2113
2233
  sender: row.senderName,
2234
+ senderId: row.senderId,
2235
+ personId: row.personId,
2236
+ profileAvailable: Boolean(row.personId),
2114
2237
  timestamp: row.sentAt
2115
2238
  };
2116
2239
  }
@@ -2125,6 +2248,10 @@ function buildScopeWhere3(scope) {
2125
2248
  clauses.push("c.platform_chat_id = ?");
2126
2249
  params.push(scope.platformChatId);
2127
2250
  }
2251
+ if (scope?.personId) {
2252
+ clauses.push("m.person_id = ?");
2253
+ params.push(scope.personId);
2254
+ }
2128
2255
  return {
2129
2256
  where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
2130
2257
  params
@@ -2175,7 +2302,9 @@ var SqliteVectorStore = class {
2175
2302
  mc.id AS chunkId,
2176
2303
  mc.text AS text,
2177
2304
  c.name AS chatName,
2305
+ m.sender_id AS senderId,
2178
2306
  m.sender_name AS senderName,
2307
+ m.person_id AS personId,
2179
2308
  m.sent_at AS sentAt,
2180
2309
  e.embedding_json AS embeddingJson
2181
2310
  FROM message_chunk_embeddings e
@@ -2256,8 +2385,12 @@ async function createAgenticRagSearchTools(input2) {
2256
2385
  new SqliteVectorStore(input2.database, { model: input2.config.embedding.model })
2257
2386
  ) : void 0;
2258
2387
  const hybrid = new HybridRetriever(semantic ? [episodes, messages, semantic] : [episodes, messages]);
2388
+ const tools = createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input2.scope });
2389
+ if (input2.profileTools && input2.profileTools.length > 0) {
2390
+ tools.push(...input2.profileTools);
2391
+ }
2259
2392
  return {
2260
- tools: createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input2.scope }),
2393
+ tools,
2261
2394
  close: () => {
2262
2395
  }
2263
2396
  };
@@ -3414,6 +3547,588 @@ function parseExactNumber2(field, min, max) {
3414
3547
  return value;
3415
3548
  }
3416
3549
 
3550
+ // src/profiles/rag-tools.ts
3551
+ var getProfileInputSchema = {
3552
+ type: "object",
3553
+ properties: {
3554
+ personId: { type: "string", description: "Stable person identifier from retrieved evidence." },
3555
+ senderId: { type: "string", description: "Message sender id when personId is unavailable." },
3556
+ platformChatId: { type: "string", description: "Chat id paired with senderId for profile lookup." },
3557
+ includeEvidence: { type: "boolean", description: "Whether to include evidence snippets in the profile text." },
3558
+ includeInferred: { type: "boolean", description: "Whether to include inferred profile entries." }
3559
+ },
3560
+ additionalProperties: false
3561
+ };
3562
+ var searchMessagesInputSchema = {
3563
+ type: "object",
3564
+ properties: {
3565
+ personId: { type: "string", description: "The stable person identifier whose messages to search." },
3566
+ query: { type: "string", description: "Search query written by the model." },
3567
+ limit: { type: "number", description: "Maximum number of evidence blocks to return." }
3568
+ },
3569
+ required: ["personId", "query"],
3570
+ additionalProperties: false
3571
+ };
3572
+ function readString(value) {
3573
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
3574
+ }
3575
+ function resolvePersonId(profiles, input2) {
3576
+ const raw = typeof input2 === "object" && input2 !== null ? input2 : {};
3577
+ const personId = readString(raw.personId);
3578
+ if (personId) return personId;
3579
+ const senderId = readString(raw.senderId);
3580
+ const platformChatId = readString(raw.platformChatId);
3581
+ if (senderId && platformChatId) {
3582
+ const resolved = profiles.resolvePersonIdForSender({ senderId, platformChatId });
3583
+ if (resolved) return resolved;
3584
+ }
3585
+ throw new Error("personId \u6216 senderId + platformChatId \u5FC5\u987B\u63D0\u4F9B\u3002");
3586
+ }
3587
+ function parseBoolean(input2, key, defaultValue) {
3588
+ const value = typeof input2 === "object" && input2 !== null ? input2[key] : void 0;
3589
+ return typeof value === "boolean" ? value : defaultValue;
3590
+ }
3591
+ function parseQuery(input2) {
3592
+ const rawQuery = typeof input2 === "object" && input2 !== null && "query" in input2 ? input2.query : void 0;
3593
+ if (typeof rawQuery !== "string") {
3594
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
3595
+ }
3596
+ const query = rawQuery.trim();
3597
+ if (!query) {
3598
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
3599
+ }
3600
+ return query;
3601
+ }
3602
+ function parseLimit(input2) {
3603
+ const rawLimit = typeof input2 === "object" && input2 !== null && "limit" in input2 ? input2.limit : void 0;
3604
+ const numericLimit = typeof rawLimit === "number" && Number.isFinite(rawLimit) ? rawLimit : 5;
3605
+ return Math.min(12, Math.max(1, Math.floor(numericLimit)));
3606
+ }
3607
+ function createGetPersonProfileTool(profiles) {
3608
+ return {
3609
+ name: "get_person_profile",
3610
+ description: "Retrieve an evidence-backed profile for a person. Use this when the question depends on who someone is, their role, preferences, personality, relationships, or recent state.",
3611
+ inputSchema: getProfileInputSchema,
3612
+ execute: async (input2) => {
3613
+ const personId = resolvePersonId(profiles, input2);
3614
+ const includeEvidence = parseBoolean(input2, "includeEvidence", false);
3615
+ const includeInferred = parseBoolean(input2, "includeInferred", true);
3616
+ const profile = profiles.getPersonProfile(personId, { includeEvidence, includeInferred });
3617
+ if (!profile) {
3618
+ return [];
3619
+ }
3620
+ const aliases = profile.identities.map((identity) => identity.displayName).filter(Boolean).join("\u3001");
3621
+ const entries = profile.entries.map((entry) => {
3622
+ const evidence = includeEvidence && entry.evidence?.length ? `
3623
+ \u8BC1\u636E\uFF1A${entry.evidence.map((item) => `${item.quote}\uFF08${item.reason}\uFF09`).join("\uFF1B")}` : "";
3624
+ return `- [${entry.entryType}] ${entry.category}\uFF1A${entry.content}\uFF08\u7F6E\u4FE1\u5EA6 ${entry.confidence}\uFF0C\u6765\u6E90 ${entry.source}\uFF09${evidence}`;
3625
+ });
3626
+ return [{
3627
+ id: `person_profile:${profile.person.id}`,
3628
+ text: [`\u4EBA\u7269\uFF1A${profile.person.primaryName}`, aliases ? `\u8EAB\u4EFD/\u6635\u79F0\uFF1A${aliases}` : void 0, ...entries].filter(Boolean).join("\n"),
3629
+ score: 1,
3630
+ source: {
3631
+ type: "person_profile",
3632
+ label: profile.person.primaryName,
3633
+ personId: profile.person.id,
3634
+ profileAvailable: true
3635
+ }
3636
+ }];
3637
+ }
3638
+ };
3639
+ }
3640
+ function createSearchPersonMessagesTool(profiles) {
3641
+ return {
3642
+ name: "search_person_messages",
3643
+ description: "Search chat messages sent by a specific person only. Use this when the question is explicitly about what a particular person said, or when you need to find messages from a specific person.",
3644
+ inputSchema: searchMessagesInputSchema,
3645
+ execute: async (input2) => {
3646
+ const personId = resolvePersonId(profiles, input2);
3647
+ const query = parseQuery(input2);
3648
+ const limit = parseLimit(input2);
3649
+ const results = profiles.searchPersonMessages(personId, query, limit);
3650
+ return results.map((result) => ({
3651
+ id: result.chunkId,
3652
+ text: result.text,
3653
+ score: result.score,
3654
+ source: {
3655
+ type: result.messageType === "file" ? "file" : "message",
3656
+ label: result.chatName,
3657
+ sender: result.senderName,
3658
+ senderId: result.senderId,
3659
+ timestamp: result.sentAt,
3660
+ personId: result.personId ?? void 0,
3661
+ profileAvailable: Boolean(result.personId)
3662
+ }
3663
+ }));
3664
+ }
3665
+ };
3666
+ }
3667
+ function createPersonProfileTools({ profiles }) {
3668
+ return [createGetPersonProfileTool(profiles), createSearchPersonMessagesTool(profiles)];
3669
+ }
3670
+
3671
+ // src/profiles/repository.ts
3672
+ import crypto5 from "crypto";
3673
+ function nowIso4() {
3674
+ return (/* @__PURE__ */ new Date()).toISOString();
3675
+ }
3676
+ function createId(prefix, parts) {
3677
+ return `${prefix}_${crypto5.createHash("sha256").update(parts.join("")).digest("hex").slice(0, 24)}`;
3678
+ }
3679
+ function mapPerson(row) {
3680
+ return {
3681
+ id: row.id,
3682
+ primaryName: row.primaryName,
3683
+ notes: row.notes ?? void 0,
3684
+ createdAt: row.createdAt,
3685
+ updatedAt: row.updatedAt
3686
+ };
3687
+ }
3688
+ var ProfileRepository = class {
3689
+ constructor(database) {
3690
+ this.database = database;
3691
+ }
3692
+ database;
3693
+ resolvePersonForSender(input2) {
3694
+ const observedAt = input2.observedAt ?? nowIso4();
3695
+ const personId = createId("person", [input2.platform, input2.platformChatId, input2.senderId]);
3696
+ const identityId = createId("identity", [input2.platform, input2.platformChatId, input2.senderId]);
3697
+ const findPerson = this.database.prepare(
3698
+ `
3699
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3700
+ FROM persons
3701
+ WHERE id = ?
3702
+ `
3703
+ );
3704
+ const transaction = this.database.transaction(() => {
3705
+ this.database.prepare(
3706
+ `
3707
+ INSERT INTO persons (id, primary_name, notes, created_at, updated_at)
3708
+ VALUES (?, ?, NULL, ?, ?)
3709
+ ON CONFLICT(id) DO NOTHING
3710
+ `
3711
+ ).run(personId, input2.senderName, observedAt, observedAt);
3712
+ this.database.prepare(
3713
+ `
3714
+ INSERT INTO person_identities (
3715
+ id, person_id, platform, platform_chat_id, external_user_id, external_open_id,
3716
+ external_union_id, external_user_id_raw, display_name, alias, source, first_seen_at, last_seen_at
3717
+ ) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?, ?)
3718
+ ON CONFLICT(platform, platform_chat_id, external_user_id)
3719
+ DO UPDATE SET
3720
+ display_name = excluded.display_name,
3721
+ source = excluded.source,
3722
+ last_seen_at = excluded.last_seen_at
3723
+ `
3724
+ ).run(identityId, personId, input2.platform, input2.platformChatId, input2.senderId, input2.senderId, input2.senderName, input2.source, observedAt, observedAt);
3725
+ this.database.prepare(
3726
+ `
3727
+ UPDATE persons
3728
+ SET primary_name = ?, updated_at = ?
3729
+ WHERE id = ?
3730
+ `
3731
+ ).run(input2.senderName, observedAt, personId);
3732
+ return findPerson.get(personId);
3733
+ });
3734
+ return transaction();
3735
+ }
3736
+ listPersons() {
3737
+ const rows = this.database.prepare(
3738
+ `
3739
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3740
+ FROM persons
3741
+ ORDER BY updated_at DESC, created_at DESC
3742
+ `
3743
+ ).all();
3744
+ return rows.map(mapPerson);
3745
+ }
3746
+ getPersonProfile(personId, options = {}) {
3747
+ const personRow = this.database.prepare(
3748
+ `
3749
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3750
+ FROM persons
3751
+ WHERE id = ?
3752
+ `
3753
+ ).get(personId);
3754
+ if (!personRow) {
3755
+ return void 0;
3756
+ }
3757
+ const includeInferred = options.includeInferred ?? true;
3758
+ const entryRows = this.database.prepare(
3759
+ `
3760
+ SELECT
3761
+ id,
3762
+ person_id AS personId,
3763
+ category,
3764
+ content,
3765
+ entry_type AS entryType,
3766
+ confidence,
3767
+ status,
3768
+ source,
3769
+ created_at AS createdAt,
3770
+ updated_at AS updatedAt,
3771
+ last_observed_at AS lastObservedAt
3772
+ FROM person_profile_entries
3773
+ WHERE person_id = ?
3774
+ AND status = 'active'
3775
+ ${includeInferred ? "" : "AND entry_type = 'fact'"}
3776
+ ORDER BY updated_at DESC, created_at DESC
3777
+ `
3778
+ ).all(personId);
3779
+ const entries = options.includeEvidence ? entryRows.map((entry) => ({ ...entry, evidence: this.getEvidence(entry.id) })) : entryRows;
3780
+ const identities = this.database.prepare(
3781
+ `
3782
+ SELECT
3783
+ platform,
3784
+ platform_chat_id AS platformChatId,
3785
+ external_user_id AS externalUserId,
3786
+ display_name AS displayName,
3787
+ alias,
3788
+ source,
3789
+ first_seen_at AS firstSeenAt,
3790
+ last_seen_at AS lastSeenAt
3791
+ FROM person_identities
3792
+ WHERE person_id = ?
3793
+ ORDER BY last_seen_at DESC, first_seen_at DESC
3794
+ `
3795
+ ).all(personId);
3796
+ return {
3797
+ person: mapPerson(personRow),
3798
+ identities,
3799
+ entries
3800
+ };
3801
+ }
3802
+ upsertProfileEntry(input2) {
3803
+ if (input2.evidence.length === 0) {
3804
+ throw new Error("Profile entry evidence is required.");
3805
+ }
3806
+ const timestamp = input2.observedAt ?? nowIso4();
3807
+ const entryId = createId("profile_entry", [input2.personId, input2.category, input2.content]);
3808
+ const transaction = this.database.transaction(() => {
3809
+ this.database.prepare(
3810
+ `
3811
+ INSERT INTO person_profile_entries (
3812
+ id, person_id, category, content, entry_type, confidence, status, source, created_at, updated_at, last_observed_at
3813
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
3814
+ ON CONFLICT(id) DO UPDATE SET
3815
+ confidence = MAX(person_profile_entries.confidence, excluded.confidence),
3816
+ status = 'active',
3817
+ source = excluded.source,
3818
+ updated_at = excluded.updated_at,
3819
+ last_observed_at = excluded.last_observed_at
3820
+ `
3821
+ ).run(entryId, input2.personId, input2.category, input2.content, input2.entryType, input2.confidence, input2.source, timestamp, timestamp, timestamp);
3822
+ const insertEvidence = this.database.prepare(
3823
+ `
3824
+ INSERT INTO person_profile_evidence (entry_id, message_id, quote, reason)
3825
+ VALUES (?, ?, ?, ?)
3826
+ ON CONFLICT(entry_id, message_id, quote) DO UPDATE SET reason = excluded.reason
3827
+ `
3828
+ );
3829
+ for (const evidence of input2.evidence) {
3830
+ insertEvidence.run(entryId, evidence.messageId, evidence.quote, evidence.reason);
3831
+ }
3832
+ });
3833
+ transaction();
3834
+ return entryId;
3835
+ }
3836
+ backfillMessagePersons({ limit }) {
3837
+ const rows = this.database.prepare(
3838
+ `
3839
+ SELECT
3840
+ m.id AS id,
3841
+ m.platform AS platform,
3842
+ c.platform_chat_id AS platformChatId,
3843
+ m.sender_id AS senderId,
3844
+ m.sender_name AS senderName,
3845
+ m.sent_at AS sentAt
3846
+ FROM messages m
3847
+ JOIN chats c ON c.id = m.chat_id
3848
+ WHERE m.person_id IS NULL
3849
+ ORDER BY m.sent_at ASC
3850
+ LIMIT ?
3851
+ `
3852
+ ).all(limit);
3853
+ const update = this.database.prepare("UPDATE messages SET person_id = ? WHERE id = ?");
3854
+ const transaction = this.database.transaction(() => {
3855
+ for (const row of rows) {
3856
+ const person = this.resolvePersonForSender({
3857
+ platform: row.platform,
3858
+ platformChatId: row.platformChatId,
3859
+ senderId: row.senderId,
3860
+ senderName: row.senderName,
3861
+ source: "inferred",
3862
+ observedAt: row.sentAt
3863
+ });
3864
+ update.run(person.id, row.id);
3865
+ }
3866
+ });
3867
+ transaction();
3868
+ return { updatedMessages: rows.length };
3869
+ }
3870
+ getDreamState(platform, platformChatId) {
3871
+ return this.database.prepare(
3872
+ `
3873
+ SELECT
3874
+ platform,
3875
+ platform_chat_id AS platformChatId,
3876
+ last_message_id AS lastMessageId,
3877
+ last_message_sent_at AS lastMessageSentAt,
3878
+ updated_at AS updatedAt
3879
+ FROM profile_dream_state
3880
+ WHERE platform = ? AND platform_chat_id = ?
3881
+ `
3882
+ ).get(platform, platformChatId);
3883
+ }
3884
+ updateDreamState(input2) {
3885
+ this.database.prepare(
3886
+ `
3887
+ INSERT INTO profile_dream_state (platform, platform_chat_id, last_message_id, last_message_sent_at, updated_at)
3888
+ VALUES (?, ?, ?, ?, ?)
3889
+ ON CONFLICT(platform, platform_chat_id)
3890
+ DO UPDATE SET
3891
+ last_message_id = excluded.last_message_id,
3892
+ last_message_sent_at = excluded.last_message_sent_at,
3893
+ updated_at = excluded.updated_at
3894
+ `
3895
+ ).run(input2.platform, input2.platformChatId, input2.lastMessageId ?? null, input2.lastMessageSentAt ?? null, input2.updatedAt);
3896
+ }
3897
+ listMessagesForDream(input2) {
3898
+ const afterWhere = input2.afterSentAt ? "AND m.sent_at > ?" : "";
3899
+ const params = input2.afterSentAt ? [input2.platform, input2.platformChatId, input2.afterSentAt, input2.limit] : [input2.platform, input2.platformChatId, input2.limit];
3900
+ return this.database.prepare(
3901
+ `
3902
+ SELECT
3903
+ m.id AS messageId,
3904
+ m.person_id AS personId,
3905
+ m.sender_name AS senderName,
3906
+ m.sent_at AS sentAt,
3907
+ m.text AS text
3908
+ FROM messages m
3909
+ JOIN chats c ON c.id = m.chat_id
3910
+ WHERE m.platform = ?
3911
+ AND c.platform_chat_id = ?
3912
+ AND m.person_id IS NOT NULL
3913
+ ${afterWhere}
3914
+ ORDER BY m.sent_at ASC, m.created_at ASC
3915
+ LIMIT ?
3916
+ `
3917
+ ).all(...params);
3918
+ }
3919
+ listChatsWithPendingDreamMessages() {
3920
+ return this.database.prepare(
3921
+ `
3922
+ SELECT DISTINCT m.platform AS platform, c.platform_chat_id AS platformChatId
3923
+ FROM messages m
3924
+ JOIN chats c ON c.id = m.chat_id
3925
+ LEFT JOIN profile_dream_state pds ON pds.platform = m.platform AND pds.platform_chat_id = c.platform_chat_id
3926
+ WHERE m.person_id IS NOT NULL
3927
+ AND (pds.last_message_sent_at IS NULL OR m.sent_at > pds.last_message_sent_at)
3928
+ ORDER BY c.platform_chat_id ASC
3929
+ `
3930
+ ).all();
3931
+ }
3932
+ recordDreamRun(input2) {
3933
+ const id = input2.id ?? createId("profile_dream_run", [input2.platform, input2.platformChatId, input2.status, input2.startedAt, input2.finishedAt, crypto5.randomUUID()]);
3934
+ this.database.prepare(
3935
+ `
3936
+ INSERT INTO profile_dream_runs (
3937
+ id, platform, platform_chat_id, status, processed_message_count, generated_entry_count, error, started_at, finished_at
3938
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3939
+ `
3940
+ ).run(
3941
+ id,
3942
+ input2.platform,
3943
+ input2.platformChatId,
3944
+ input2.status,
3945
+ input2.processedMessageCount,
3946
+ input2.generatedEntryCount,
3947
+ input2.error ?? null,
3948
+ input2.startedAt,
3949
+ input2.finishedAt
3950
+ );
3951
+ return id;
3952
+ }
3953
+ resolvePersonIdForSender(input2) {
3954
+ const platform = input2.platform ?? "feishu";
3955
+ const row = this.database.prepare(
3956
+ `
3957
+ SELECT person_id AS personId
3958
+ FROM person_identities
3959
+ WHERE platform = ? AND platform_chat_id = ? AND external_user_id = ?
3960
+ LIMIT 1
3961
+ `
3962
+ ).get(platform, input2.platformChatId, input2.senderId);
3963
+ return row?.personId;
3964
+ }
3965
+ searchPersonMessages(personId, query, limit, options = {}) {
3966
+ const cleaned = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter(Boolean);
3967
+ const wrapped = cleaned.map((term) => `"${term}"`).join(" ");
3968
+ if (!wrapped) {
3969
+ return [];
3970
+ }
3971
+ const excludedIds = options.excludeMessageIds ?? [];
3972
+ const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
3973
+ const rows = this.database.prepare(
3974
+ `
3975
+ SELECT
3976
+ fts.chunk_id AS chunkId,
3977
+ fts.message_id AS messageId,
3978
+ m.platform AS platform,
3979
+ mc.text AS text,
3980
+ bm25(message_chunks_fts) * -1 AS score,
3981
+ m.message_type AS messageType,
3982
+ c.name AS chatName,
3983
+ m.sender_id AS senderId,
3984
+ m.sender_name AS senderName,
3985
+ m.person_id AS personId,
3986
+ m.sent_at AS sentAt,
3987
+ mc.chunk_index AS chunkIndex
3988
+ FROM message_chunks_fts fts
3989
+ JOIN message_chunks mc ON mc.id = fts.chunk_id
3990
+ JOIN messages m ON m.id = fts.message_id
3991
+ JOIN chats c ON c.id = m.chat_id
3992
+ WHERE message_chunks_fts MATCH ?
3993
+ ${excludedWhere}
3994
+ AND m.person_id = ?
3995
+ ORDER BY bm25(message_chunks_fts), m.sent_at DESC, mc.chunk_index ASC
3996
+ LIMIT ?
3997
+ `
3998
+ ).all(wrapped, ...excludedIds, personId, Math.max(limit * 8, limit));
3999
+ const results = [];
4000
+ const seenMessageIds = /* @__PURE__ */ new Set();
4001
+ for (const row of rows) {
4002
+ if (seenMessageIds.has(row.messageId)) {
4003
+ continue;
4004
+ }
4005
+ seenMessageIds.add(row.messageId);
4006
+ const { chunkIndex: _chunkIndex, ...result } = row;
4007
+ results.push(result);
4008
+ if (results.length >= limit) {
4009
+ break;
4010
+ }
4011
+ }
4012
+ if (results.length > 0) {
4013
+ return results;
4014
+ }
4015
+ const terms = query.split(/[  ]+/).map((term) => term.trim()).filter((term) => term.length > 0);
4016
+ if (terms.length === 0) {
4017
+ return [];
4018
+ }
4019
+ const where = terms.map(() => "mc.text LIKE ? ESCAPE '\\'").join(" OR ");
4020
+ const params = terms.map((term) => `%${term.replace(/[%_]/g, "\\$&")}%`);
4021
+ const likeExcludedWhere = excludedIds.length > 0 ? `AND m.id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
4022
+ return this.database.prepare(
4023
+ `
4024
+ SELECT
4025
+ *
4026
+ FROM (
4027
+ SELECT
4028
+ mc.id AS chunkId,
4029
+ m.id AS messageId,
4030
+ m.platform AS platform,
4031
+ mc.text AS text,
4032
+ 0.1 AS score,
4033
+ m.message_type AS messageType,
4034
+ c.name AS chatName,
4035
+ m.sender_id AS senderId,
4036
+ m.sender_name AS senderName,
4037
+ m.person_id AS personId,
4038
+ m.sent_at AS sentAt,
4039
+ ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY mc.chunk_index ASC) AS rowNumber
4040
+ FROM message_chunks mc
4041
+ JOIN messages m ON m.id = mc.message_id
4042
+ JOIN chats c ON c.id = m.chat_id
4043
+ WHERE (${where})
4044
+ ${likeExcludedWhere}
4045
+ AND m.person_id = ?
4046
+ ) ranked
4047
+ WHERE rowNumber = 1
4048
+ ORDER BY sentAt DESC
4049
+ LIMIT ?
4050
+ `
4051
+ ).all(...params, ...excludedIds, personId, limit);
4052
+ }
4053
+ getProfileEntry(entryId) {
4054
+ const row = this.database.prepare(
4055
+ `
4056
+ SELECT
4057
+ id,
4058
+ person_id AS personId,
4059
+ category,
4060
+ content,
4061
+ entry_type AS entryType,
4062
+ confidence,
4063
+ status,
4064
+ source,
4065
+ created_at AS createdAt,
4066
+ updated_at AS updatedAt,
4067
+ last_observed_at AS lastObservedAt
4068
+ FROM person_profile_entries
4069
+ WHERE id = ?
4070
+ `
4071
+ ).get(entryId);
4072
+ if (!row) {
4073
+ return void 0;
4074
+ }
4075
+ return { ...row, evidence: this.getEvidence(entryId) };
4076
+ }
4077
+ replaceProfileEntry(input2) {
4078
+ if (input2.input.evidence.length === 0) {
4079
+ throw new Error("Profile entry evidence is required.");
4080
+ }
4081
+ const timestamp = input2.input.observedAt ?? nowIso4();
4082
+ const newEntryId = createId("profile_entry", [input2.input.personId, input2.input.category, input2.input.content]);
4083
+ const transaction = this.database.transaction(() => {
4084
+ this.database.prepare(
4085
+ `
4086
+ INSERT INTO person_profile_entries (
4087
+ id, person_id, category, content, entry_type, confidence, status, source, created_at, updated_at, last_observed_at
4088
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
4089
+ `
4090
+ ).run(newEntryId, input2.input.personId, input2.input.category, input2.input.content, input2.input.entryType, input2.input.confidence, input2.input.source, timestamp, timestamp, timestamp);
4091
+ this.database.prepare(
4092
+ `
4093
+ UPDATE person_profile_entries
4094
+ SET status = 'superseded', updated_at = ?
4095
+ WHERE id = ? AND status = 'active'
4096
+ `
4097
+ ).run(timestamp, input2.supersedeEntryId);
4098
+ const insertEvidence = this.database.prepare(
4099
+ `
4100
+ INSERT INTO person_profile_evidence (entry_id, message_id, quote, reason)
4101
+ VALUES (?, ?, ?, ?)
4102
+ ON CONFLICT(entry_id, message_id, quote) DO UPDATE SET reason = excluded.reason
4103
+ `
4104
+ );
4105
+ for (const evidence of input2.input.evidence) {
4106
+ insertEvidence.run(newEntryId, evidence.messageId, evidence.quote, evidence.reason);
4107
+ }
4108
+ });
4109
+ transaction();
4110
+ return newEntryId;
4111
+ }
4112
+ markProfileEntryDeleted(entryId) {
4113
+ const timestamp = nowIso4();
4114
+ this.database.prepare("UPDATE person_profile_entries SET status = 'deleted', updated_at = ? WHERE id = ?").run(timestamp, entryId);
4115
+ }
4116
+ personExists(personId) {
4117
+ const row = this.database.prepare("SELECT 1 AS existsFlag FROM persons WHERE id = ? LIMIT 1").get(personId);
4118
+ return Boolean(row);
4119
+ }
4120
+ getEvidence(entryId) {
4121
+ return this.database.prepare(
4122
+ `
4123
+ SELECT entry_id AS entryId, message_id AS messageId, quote, reason
4124
+ FROM person_profile_evidence
4125
+ WHERE entry_id = ?
4126
+ ORDER BY message_id ASC, quote ASC
4127
+ `
4128
+ ).all(entryId);
4129
+ }
4130
+ };
4131
+
3417
4132
  // src/rag/indexer.ts
3418
4133
  var EMBEDDING_INDEX_BATCH_SIZE = 64;
3419
4134
  async function indexMessageChunks(input2) {
@@ -3501,12 +4216,12 @@ async function processMessagesNow(input2) {
3501
4216
  }
3502
4217
 
3503
4218
  // src/multimodal/tasks.ts
3504
- import crypto5 from "crypto";
3505
- function nowIso4() {
4219
+ import crypto6 from "crypto";
4220
+ function nowIso5() {
3506
4221
  return (/* @__PURE__ */ new Date()).toISOString();
3507
4222
  }
3508
4223
  function stableId3(sourceMessageId, imageKey) {
3509
- return crypto5.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
4224
+ return crypto6.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
3510
4225
  }
3511
4226
  function mapRow(row) {
3512
4227
  if (!row) {
@@ -3534,7 +4249,7 @@ var ImageMultimodalTaskRepository = class {
3534
4249
  database;
3535
4250
  enqueue(input2) {
3536
4251
  const id = stableId3(input2.sourceMessageId, input2.imageKey);
3537
- const timestamp = nowIso4();
4252
+ const timestamp = nowIso5();
3538
4253
  this.database.prepare(
3539
4254
  `
3540
4255
  INSERT INTO image_multimodal_tasks (
@@ -3622,7 +4337,7 @@ var ImageMultimodalTaskRepository = class {
3622
4337
  updated_at = @updatedAt
3623
4338
  WHERE id = @id AND status = 'pending'
3624
4339
  `
3625
- ).run({ id, updatedAt: nowIso4() });
4340
+ ).run({ id, updatedAt: nowIso5() });
3626
4341
  if (result.changes === 0) {
3627
4342
  throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A${id}`);
3628
4343
  }
@@ -3638,7 +4353,7 @@ var ImageMultimodalTaskRepository = class {
3638
4353
  updated_at = @updatedAt
3639
4354
  WHERE id = @id
3640
4355
  `
3641
- ).run({ id, derivedMessageId, updatedAt: nowIso4() });
4356
+ ).run({ id, derivedMessageId, updatedAt: nowIso5() });
3642
4357
  return this.requireById(id);
3643
4358
  }
3644
4359
  markSkipped(id, reason) {
@@ -3651,7 +4366,7 @@ var ImageMultimodalTaskRepository = class {
3651
4366
  updated_at = @updatedAt
3652
4367
  WHERE id = @id
3653
4368
  `
3654
- ).run({ id, reason, updatedAt: nowIso4() });
4369
+ ).run({ id, reason, updatedAt: nowIso5() });
3655
4370
  return this.requireById(id);
3656
4371
  }
3657
4372
  markFailed(id, error, finalFailure) {
@@ -3664,7 +4379,7 @@ var ImageMultimodalTaskRepository = class {
3664
4379
  updated_at = @updatedAt
3665
4380
  WHERE id = @id
3666
4381
  `
3667
- ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso4() });
4382
+ ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso5() });
3668
4383
  return this.requireById(id);
3669
4384
  }
3670
4385
  getById(id) {
@@ -3946,7 +4661,7 @@ function createFeishuChatMembersClient(client) {
3946
4661
  }
3947
4662
 
3948
4663
  // src/cron/tools.ts
3949
- function readString(input2, key) {
4664
+ function readString2(input2, key) {
3950
4665
  const value = typeof input2 === "object" && input2 !== null && key in input2 ? input2[key] : void 0;
3951
4666
  if (typeof value !== "string" || !value.trim()) {
3952
4667
  throw new Error(`${key} \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002`);
@@ -3998,8 +4713,8 @@ function createCronJobTools(input2) {
3998
4713
  const job = input2.repository.create({
3999
4714
  chatId: input2.chatId,
4000
4715
  createdByOpenId: input2.createdByOpenId,
4001
- schedule: readString(rawInput, "schedule"),
4002
- prompt: readString(rawInput, "prompt"),
4716
+ schedule: readString2(rawInput, "schedule"),
4717
+ prompt: readString2(rawInput, "prompt"),
4003
4718
  imageFileName: readOptionalString(rawInput, "imageFileName"),
4004
4719
  mentionTargetName,
4005
4720
  mentionOpenId: mentionTarget?.openId,
@@ -4029,7 +4744,7 @@ function createCronJobTools(input2) {
4029
4744
  additionalProperties: false
4030
4745
  },
4031
4746
  execute: async (rawInput) => {
4032
- const id = readString(rawInput, "id");
4747
+ const id = readString2(rawInput, "id");
4033
4748
  const ok = input2.repository.deleteByChat(id, input2.chatId);
4034
4749
  return JSON.stringify({
4035
4750
  ok,
@@ -4042,7 +4757,7 @@ function createCronJobTools(input2) {
4042
4757
  }
4043
4758
 
4044
4759
  // src/rag/qa-logs.ts
4045
- import crypto6 from "crypto";
4760
+ import crypto7 from "crypto";
4046
4761
 
4047
4762
  // src/rag/qa-trace.ts
4048
4763
  function hasQaTrace(trace) {
@@ -4082,7 +4797,7 @@ var QaLogRepository = class {
4082
4797
  create(input2) {
4083
4798
  const trace = input2.trace ?? {};
4084
4799
  const record = {
4085
- id: `qa_${crypto6.randomUUID()}`,
4800
+ id: `qa_${crypto7.randomUUID()}`,
4086
4801
  chatId: input2.chatId ?? null,
4087
4802
  questionMessageId: input2.questionMessageId ?? null,
4088
4803
  question: input2.question,
@@ -4262,13 +4977,13 @@ ${block.text}`;
4262
4977
  function toToolErrorContent(message) {
4263
4978
  return JSON.stringify({ ok: false, error: message });
4264
4979
  }
4265
- function nowIso5() {
4980
+ function nowIso6() {
4266
4981
  return (/* @__PURE__ */ new Date()).toISOString();
4267
4982
  }
4268
4983
  function finalizeTrace(trace, status, finalAnswer, startedAtMs) {
4269
4984
  return {
4270
4985
  ...trace,
4271
- completedAt: nowIso5(),
4986
+ completedAt: nowIso6(),
4272
4987
  durationMs: Date.now() - startedAtMs,
4273
4988
  status,
4274
4989
  finalAnswer
@@ -4325,11 +5040,11 @@ async function runFeishuToolLoop(input2) {
4325
5040
  content: assistantResult.content,
4326
5041
  reasoningContent: assistantResult.reasoningContent,
4327
5042
  toolCalls: assistantResult.toolCalls,
4328
- createdAt: nowIso5()
5043
+ createdAt: nowIso6()
4329
5044
  });
4330
5045
  if (assistantResult.toolCalls.length === 0) {
4331
5046
  if (hasRawToolCallMarkup) {
4332
- trace.fallbacks?.push({ type: "raw_tool_markup", message: "\u6A21\u578B\u8F93\u51FA\u4E86\u539F\u59CB\u5DE5\u5177\u8C03\u7528\u6807\u8BB0\uFF0C\u8F6C\u5165\u6700\u7EC8\u8865\u6551\u56DE\u7B54\u3002", createdAt: nowIso5() });
5047
+ trace.fallbacks?.push({ type: "raw_tool_markup", message: "\u6A21\u578B\u8F93\u51FA\u4E86\u539F\u59CB\u5DE5\u5177\u8C03\u7528\u6807\u8BB0\uFF0C\u8F6C\u5165\u6700\u7EC8\u8865\u6551\u56DE\u7B54\u3002", createdAt: nowIso6() });
4333
5048
  break;
4334
5049
  }
4335
5050
  const answer = assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
@@ -4337,7 +5052,7 @@ async function runFeishuToolLoop(input2) {
4337
5052
  }
4338
5053
  for (const toolCall of assistantResult.toolCalls) {
4339
5054
  if (toolCallsUsed >= maxToolCalls) {
4340
- trace.fallbacks?.push({ type: "tool_limit", message: FEISHU_TOOL_LOOP_LIMIT_REACHED, createdAt: nowIso5() });
5055
+ trace.fallbacks?.push({ type: "tool_limit", message: FEISHU_TOOL_LOOP_LIMIT_REACHED, createdAt: nowIso6() });
4341
5056
  return { answer: FEISHU_TOOL_LOOP_LIMIT_REACHED, trace: finalizeTrace(trace, "failed", FEISHU_TOOL_LOOP_LIMIT_REACHED, startedAtMs) };
4342
5057
  }
4343
5058
  toolCallsUsed += 1;
@@ -4348,7 +5063,7 @@ async function runFeishuToolLoop(input2) {
4348
5063
  name: toolCall.name,
4349
5064
  input: toolCall.input,
4350
5065
  error: `\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`,
4351
- createdAt: nowIso5()
5066
+ createdAt: nowIso6()
4352
5067
  });
4353
5068
  messages.push({
4354
5069
  role: "tool",
@@ -4364,7 +5079,7 @@ async function runFeishuToolLoop(input2) {
4364
5079
  name: toolCall.name,
4365
5080
  input: toolCall.input,
4366
5081
  content: result,
4367
- createdAt: nowIso5()
5082
+ createdAt: nowIso6()
4368
5083
  });
4369
5084
  messages.push({
4370
5085
  role: "tool",
@@ -4378,7 +5093,7 @@ async function runFeishuToolLoop(input2) {
4378
5093
  name: toolCall.name,
4379
5094
  input: toolCall.input,
4380
5095
  error: message,
4381
- createdAt: nowIso5()
5096
+ createdAt: nowIso6()
4382
5097
  });
4383
5098
  messages.push({
4384
5099
  role: "tool",
@@ -4394,11 +5109,11 @@ async function runFeishuToolLoop(input2) {
4394
5109
  { role: "system", content: "\u8BF7\u57FA\u4E8E\u4EE5\u4E0A\u6240\u6709\u5DE5\u5177\u8FD4\u56DE\u7684\u4FE1\u606F\uFF0C\u76F4\u63A5\u7ED9\u51FA\u6700\u7EC8\u7B54\u6848\u3002\u4E0D\u8981\u518D\u8C03\u7528\u5DE5\u5177\u3002" }
4395
5110
  ]);
4396
5111
  const answer = salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4397
- trace.fallbacks?.push({ type: "salvage_completion", message: "\u5DE5\u5177\u5FAA\u73AF\u7ED3\u675F\u540E\u4F7F\u7528\u65E0\u5DE5\u5177\u8865\u6551\u56DE\u7B54\u3002", createdAt: nowIso5() });
5112
+ trace.fallbacks?.push({ type: "salvage_completion", message: "\u5DE5\u5177\u5FAA\u73AF\u7ED3\u675F\u540E\u4F7F\u7528\u65E0\u5DE5\u5177\u8865\u6551\u56DE\u7B54\u3002", createdAt: nowIso6() });
4398
5113
  return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4399
5114
  } catch {
4400
5115
  const answer = "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4401
- trace.fallbacks?.push({ type: "answer_generation_failed", message: answer, createdAt: nowIso5() });
5116
+ trace.fallbacks?.push({ type: "answer_generation_failed", message: answer, createdAt: nowIso6() });
4402
5117
  return { answer, trace: finalizeTrace(trace, "failed", answer, startedAtMs) };
4403
5118
  }
4404
5119
  }
@@ -4505,7 +5220,8 @@ var FeishuQuestionHandler = class {
4505
5220
  secrets: this.options.secrets,
4506
5221
  database: this.options.database,
4507
5222
  messages: new MessageRepository(this.options.database),
4508
- excludeMessageIds: options.excludeMessageIds
5223
+ excludeMessageIds: options.excludeMessageIds,
5224
+ profileTools: createPersonProfileTools({ profiles: new ProfileRepository(this.options.database) })
4509
5225
  });
4510
5226
  try {
4511
5227
  try {
@@ -4982,7 +5698,8 @@ function createFeishuGateway(options) {
4982
5698
  secrets: options.secrets,
4983
5699
  database: options.cronJobProcessor.database,
4984
5700
  messages: new MessageRepository(options.cronJobProcessor.database),
4985
- scope: { platform: "feishu", platformChatId: job.chatId }
5701
+ scope: { platform: "feishu", platformChatId: job.chatId },
5702
+ profileTools: createPersonProfileTools({ profiles: new ProfileRepository(options.cronJobProcessor.database) })
4986
5703
  });
4987
5704
  try {
4988
5705
  const memberPrompt = formatFeishuMemberPrompt(
@@ -5086,7 +5803,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
5086
5803
  };
5087
5804
 
5088
5805
  // src/files/ingest.ts
5089
- import crypto7 from "crypto";
5806
+ import crypto8 from "crypto";
5090
5807
  import fs12 from "fs/promises";
5091
5808
  import path15 from "path";
5092
5809
 
@@ -5150,7 +5867,7 @@ function ensureSupportedTextFile(filePath) {
5150
5867
  }
5151
5868
  }
5152
5869
  function stableStoredName(sourcePath, fileName) {
5153
- const digest = crypto7.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
5870
+ const digest = crypto8.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
5154
5871
  return `${digest}-${fileName}`;
5155
5872
  }
5156
5873
  async function ingestLocalFile(input2) {
@@ -5406,16 +6123,32 @@ function isMultimodalReady(config, secrets) {
5406
6123
  return Boolean(config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey);
5407
6124
  }
5408
6125
  var GatewayIngestor = class {
5409
- constructor(database) {
6126
+ constructor(database, options = {}) {
5410
6127
  this.database = database;
6128
+ this.options = options;
5411
6129
  this.messages = new MessageRepository(database);
5412
6130
  this.jobs = new FileJobRepository(database);
5413
6131
  this.imageTasks = new ImageMultimodalTaskRepository(database);
5414
6132
  }
5415
6133
  database;
6134
+ options;
5416
6135
  messages;
5417
6136
  jobs;
5418
6137
  imageTasks;
6138
+ enrichWithPerson(input2) {
6139
+ const person = this.options.profiles?.resolvePersonForSender({
6140
+ platform: input2.platform,
6141
+ platformChatId: input2.platformChatId,
6142
+ senderId: input2.senderId,
6143
+ senderName: input2.senderName,
6144
+ source: "message",
6145
+ observedAt: input2.sentAt
6146
+ });
6147
+ return {
6148
+ ...input2,
6149
+ personId: person?.id
6150
+ };
6151
+ }
5419
6152
  ingestFeishuEvent(payload) {
5420
6153
  const normalized = normalizeFeishuReceiveMessageEvent(payload);
5421
6154
  if (!normalized) {
@@ -5424,12 +6157,13 @@ var GatewayIngestor = class {
5424
6157
  reason: "\u4E8B\u4EF6\u4E0D\u662F\u53EF\u5165\u5E93\u7684\u98DE\u4E66\u6D88\u606F\u3002"
5425
6158
  };
5426
6159
  }
5427
- const duplicate = this.messages.hasPlatformMessage(normalized.platform, normalized.platformMessageId);
5428
- const messageId = this.messages.ingest(normalized);
6160
+ const enriched = this.enrichWithPerson(normalized);
6161
+ const duplicate = this.messages.hasPlatformMessage(enriched.platform, enriched.platformMessageId);
6162
+ const messageId = this.messages.ingest(enriched);
5429
6163
  return {
5430
6164
  accepted: true,
5431
6165
  messageId,
5432
- message: normalized,
6166
+ message: enriched,
5433
6167
  duplicate
5434
6168
  };
5435
6169
  }
@@ -5443,7 +6177,7 @@ var GatewayIngestor = class {
5443
6177
  }
5444
6178
  const openId = extractFeishuSenderOpenId(normalized);
5445
6179
  const senderName = openId ? await input2.memberResolver.resolveOpenIdName(normalized.platformChatId, openId) : normalized.senderName;
5446
- const enriched = { ...normalized, senderName };
6180
+ const enriched = this.enrichWithPerson({ ...normalized, senderName });
5447
6181
  const duplicate = this.messages.hasPlatformMessage(enriched.platform, enriched.platformMessageId);
5448
6182
  const messageId = this.messages.ingest(enriched);
5449
6183
  return {
@@ -5712,6 +6446,153 @@ function createMultimodalModel(config, secrets) {
5712
6446
  // src/cli.ts
5713
6447
  import * as lark4 from "@larksuiteoapi/node-sdk";
5714
6448
 
6449
+ // src/profiles/dream.ts
6450
+ function stripJsonFence(value) {
6451
+ const trimmed = value.trim();
6452
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
6453
+ return fenced ? fenced[1].trim() : trimmed;
6454
+ }
6455
+ function parseDreamOutput(value) {
6456
+ const parsed = JSON.parse(stripJsonFence(value));
6457
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.updates)) {
6458
+ throw new Error("Dream output must be a JSON object with updates array.");
6459
+ }
6460
+ return parsed;
6461
+ }
6462
+ function buildPrompts(messages, existingProfiles) {
6463
+ return {
6464
+ system: [
6465
+ "\u4F60\u662F ChatterCatcher \u7684\u4E2A\u4EBA\u6863\u6848 Dream \u5904\u7406\u5668\u3002",
6466
+ "\u53EA\u80FD\u57FA\u4E8E\u8F93\u5165\u6D88\u606F\u63D0\u53D6\u4EBA\u7269\u6863\u6848\u53D8\u5316\u3002",
6467
+ '\u8F93\u51FA\u4E25\u683C JSON\uFF1A{"updates":[{"personId":string,"category":string,"entryType":"fact"|"inferred","content":string,"confidence":number,"evidence":[{"messageId":string,"quote":string,"reason":string}]}]}\u3002',
6468
+ '\u6CA1\u6709\u8DB3\u591F\u8BC1\u636E\u65F6\u8FD4\u56DE {"updates":[]}\u3002'
6469
+ ].join("\n"),
6470
+ user: JSON.stringify({ messages, existingProfiles }, null, 2)
6471
+ };
6472
+ }
6473
+ var ProfileDreamProcessor = class {
6474
+ constructor(input2) {
6475
+ this.input = input2;
6476
+ }
6477
+ input;
6478
+ async processChat(input2) {
6479
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
6480
+ const state = this.input.profiles.getDreamState(input2.platform, input2.platformChatId);
6481
+ const messages = this.input.profiles.listMessagesForDream({
6482
+ platform: input2.platform,
6483
+ platformChatId: input2.platformChatId,
6484
+ afterSentAt: state?.lastMessageSentAt,
6485
+ limit: input2.limit ?? 100
6486
+ });
6487
+ if (messages.length === 0) {
6488
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
6489
+ this.input.profiles.recordDreamRun({
6490
+ platform: input2.platform,
6491
+ platformChatId: input2.platformChatId,
6492
+ status: "skipped",
6493
+ processedMessageCount: 0,
6494
+ generatedEntryCount: 0,
6495
+ startedAt,
6496
+ finishedAt
6497
+ });
6498
+ return { status: "skipped", processedMessageCount: 0, generatedEntryCount: 0 };
6499
+ }
6500
+ try {
6501
+ const personIds = [...new Set(messages.map((message) => message.personId))];
6502
+ const existingProfiles = personIds.map((personId) => this.input.profiles.getPersonProfile(personId, { includeEvidence: false, includeInferred: true }));
6503
+ const prompts = buildPrompts(messages, existingProfiles);
6504
+ const raw = await this.input.model.complete([
6505
+ { role: "system", content: prompts.system },
6506
+ { role: "user", content: prompts.user }
6507
+ ]);
6508
+ const output = parseDreamOutput(raw);
6509
+ const messageIds = new Set(messages.map((message) => message.messageId));
6510
+ for (const update of output.updates) {
6511
+ this.validateUpdate(update, messageIds);
6512
+ }
6513
+ for (const update of output.updates) {
6514
+ this.input.profiles.upsertProfileEntry({
6515
+ personId: update.personId,
6516
+ category: update.category,
6517
+ content: update.content,
6518
+ entryType: update.entryType,
6519
+ confidence: update.confidence,
6520
+ source: "dream",
6521
+ evidence: update.evidence,
6522
+ observedAt: messages[messages.length - 1].sentAt
6523
+ });
6524
+ }
6525
+ const lastMessage = messages[messages.length - 1];
6526
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
6527
+ this.input.profiles.updateDreamState({
6528
+ platform: input2.platform,
6529
+ platformChatId: input2.platformChatId,
6530
+ lastMessageId: lastMessage.messageId,
6531
+ lastMessageSentAt: lastMessage.sentAt,
6532
+ updatedAt: finishedAt
6533
+ });
6534
+ this.input.profiles.recordDreamRun({
6535
+ platform: input2.platform,
6536
+ platformChatId: input2.platformChatId,
6537
+ status: "succeeded",
6538
+ processedMessageCount: messages.length,
6539
+ generatedEntryCount: output.updates.length,
6540
+ startedAt,
6541
+ finishedAt
6542
+ });
6543
+ return { status: "succeeded", processedMessageCount: messages.length, generatedEntryCount: output.updates.length };
6544
+ } catch (error) {
6545
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
6546
+ const message = error instanceof Error ? error.message : String(error);
6547
+ this.input.profiles.recordDreamRun({
6548
+ platform: input2.platform,
6549
+ platformChatId: input2.platformChatId,
6550
+ status: "failed",
6551
+ processedMessageCount: messages.length,
6552
+ generatedEntryCount: 0,
6553
+ error: message,
6554
+ startedAt,
6555
+ finishedAt
6556
+ });
6557
+ return { status: "failed", processedMessageCount: messages.length, generatedEntryCount: 0, error: message };
6558
+ }
6559
+ }
6560
+ validateUpdate(update, messageIds) {
6561
+ if (!this.input.profiles.personExists(update.personId)) {
6562
+ throw new Error(`Unknown personId in dream output: ${update.personId}`);
6563
+ }
6564
+ if (update.entryType !== "fact" && update.entryType !== "inferred") {
6565
+ throw new Error("Dream update entryType must be fact or inferred.");
6566
+ }
6567
+ if (typeof update.confidence !== "number" || !Number.isFinite(update.confidence) || update.confidence < 0 || update.confidence > 1) {
6568
+ throw new Error("Dream update confidence must be a number between 0 and 1.");
6569
+ }
6570
+ if (!Array.isArray(update.evidence) || update.evidence.length === 0) {
6571
+ throw new Error("Dream update evidence is required.");
6572
+ }
6573
+ if (typeof update.category !== "string" || !update.category.trim()) {
6574
+ throw new Error("Dream update category is required.");
6575
+ }
6576
+ if (typeof update.content !== "string" || !update.content.trim()) {
6577
+ throw new Error("Dream update content is required.");
6578
+ }
6579
+ for (const evidence of update.evidence) {
6580
+ if (!evidence || typeof evidence !== "object") {
6581
+ throw new Error("Dream update evidence item must be an object.");
6582
+ }
6583
+ if (typeof evidence.messageId !== "string" || !messageIds.has(evidence.messageId)) {
6584
+ throw new Error(`Dream update evidence message is outside the processed batch: ${String(evidence.messageId)}`);
6585
+ }
6586
+ if (typeof evidence.quote !== "string" || !evidence.quote.trim()) {
6587
+ throw new Error("Dream update evidence quote is required.");
6588
+ }
6589
+ if (typeof evidence.reason !== "string" || !evidence.reason.trim()) {
6590
+ throw new Error("Dream update evidence reason is required.");
6591
+ }
6592
+ }
6593
+ }
6594
+ };
6595
+
5715
6596
  // src/rag/answer.ts
5716
6597
  var DEFAULT_MAX_EVIDENCE_BLOCKS = 8;
5717
6598
  var DEFAULT_MAX_CHARS_PER_BLOCK = 1200;
@@ -5952,7 +6833,7 @@ async function updateChatterCatcher(options) {
5952
6833
  }
5953
6834
 
5954
6835
  // src/web/server.ts
5955
- import crypto8 from "crypto";
6836
+ import crypto9 from "crypto";
5956
6837
  import Fastify from "fastify";
5957
6838
  function buildHtml() {
5958
6839
  return `<!doctype html>
@@ -6294,6 +7175,10 @@ function buildHtml() {
6294
7175
  <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
6295
7176
  <span>\u95EE\u7B54\u65E5\u5FD7</span>
6296
7177
  </button>
7178
+ <button class="nav-item" data-view="persons">
7179
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
7180
+ <span>\u4E2A\u4EBA\u6863\u6848</span>
7181
+ </button>
6297
7182
  <button class="nav-item" data-view="settings">
6298
7183
  <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
6299
7184
  <span>\u8BBE\u7F6E</span>
@@ -6325,6 +7210,10 @@ function buildHtml() {
6325
7210
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
6326
7211
  <span>\u4EFB\u52A1</span>
6327
7212
  </button>
7213
+ <button class="mobile-nav-item" data-view="persons">
7214
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
7215
+ <span>\u6863\u6848</span>
7216
+ </button>
6328
7217
  <button class="mobile-nav-item" data-view="settings">
6329
7218
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
6330
7219
  <span>\u8BBE\u7F6E</span>
@@ -6421,6 +7310,18 @@ function buildHtml() {
6421
7310
  <div class="content-panel glass"><div id="qa-logs-list"></div></div>
6422
7311
  </div>
6423
7312
 
7313
+ <div class="view" id="view-persons">
7314
+ <div class="section-header"><div><h1 class="section-title">\u4E2A\u4EBA\u6863\u6848</h1><p class="page-subtitle">\u7FA4\u6210\u5458\u6863\u6848\u4E0E\u4E8B\u5B9E\u4FE1\u606F</p></div></div>
7315
+ <div class="content-panel glass" id="persons-list-panel"><div id="persons-list"></div></div>
7316
+ <div class="content-panel glass mt-lg" id="person-profile-panel" style="display:none;">
7317
+ <div class="panel-header">
7318
+ <h2 class="panel-title" id="person-profile-name"></h2>
7319
+ <button class="btn btn-sm" onclick="navigateTo('persons')">\u8FD4\u56DE\u5217\u8868</button>
7320
+ </div>
7321
+ <div id="person-profile-detail"></div>
7322
+ </div>
7323
+ </div>
7324
+
6424
7325
  <div class="view" id="view-settings">
6425
7326
  <div class="section-header"><div><h1 class="section-title">\u8BBE\u7F6E</h1><p class="page-subtitle">\u7CFB\u7EDF\u914D\u7F6E\u4E0E\u64CD\u4F5C</p></div></div>
6426
7327
  <div class="settings-group glass" id="settings-config"></div>
@@ -6456,7 +7357,8 @@ function buildHtml() {
6456
7357
  let allFileJobs = [];
6457
7358
  let allCronJobs = [];
6458
7359
  let allQaLogs = [];
6459
- let selectedQaLogId = null;
7360
+ let allPersons = [];
7361
+ let selectedPersonId = null;
6460
7362
  let statusData = null;
6461
7363
 
6462
7364
  function fmt(value) { return value == null || value === "" ? "-" : String(value); }
@@ -6500,6 +7402,7 @@ function buildHtml() {
6500
7402
  if (view === "files") renderFilesView();
6501
7403
  if (view === "tasks") renderTasksView();
6502
7404
  if (view === "qa-logs") renderQaLogsView();
7405
+ if (view === "persons") renderPersonsView();
6503
7406
  }
6504
7407
 
6505
7408
  document.querySelectorAll(".nav-item, .mobile-nav-item").forEach(function(el) {
@@ -6542,6 +7445,55 @@ function buildHtml() {
6542
7445
  return result;
6543
7446
  }
6544
7447
 
7448
+ function toJsArgument(value) {
7449
+ return escapeHtml(JSON.stringify(fmt(value)));
7450
+ }
7451
+
7452
+ function findEntryEvidenceMessage(entry) {
7453
+ var evidence = entry.evidence || [];
7454
+ return evidence.length > 0 ? evidence[0].messageId : "";
7455
+ }
7456
+
7457
+ function findEntryEvidenceQuote(entry) {
7458
+ var evidence = entry.evidence || [];
7459
+ return evidence.length > 0 ? evidence[0].quote : "";
7460
+ }
7461
+
7462
+ async function correctPersonProfileEntry(personId, entryId, category, content, entryType, confidence, evidenceMessageId, quote) {
7463
+ var nextContent = prompt("\u4FEE\u6B63\u6863\u6848\u5185\u5BB9", content);
7464
+ if (!nextContent || !nextContent.trim()) return;
7465
+ var reason = prompt("\u4FEE\u6B63\u7406\u7531", "\u7528\u6237\u5728 Web UI \u663E\u5F0F\u4FEE\u6B63") || "\u7528\u6237\u5728 Web UI \u663E\u5F0F\u4FEE\u6B63";
7466
+ try {
7467
+ await postJson("/api/persons/" + encodeURIComponent(personId) + "/profile/entries/" + encodeURIComponent(entryId) + "/correct", {
7468
+ headers: { "content-type": "application/json" },
7469
+ body: JSON.stringify({
7470
+ category: category,
7471
+ content: nextContent.trim(),
7472
+ entryType: entryType,
7473
+ confidence: confidence,
7474
+ evidenceMessageId: evidenceMessageId,
7475
+ quote: quote || content,
7476
+ reason: reason
7477
+ })
7478
+ });
7479
+ showToast("\u6863\u6848\u6761\u76EE\u5DF2\u4FEE\u6B63", "success");
7480
+ await showPersonProfile(personId);
7481
+ } catch (error) {
7482
+ showToast(error instanceof Error ? error.message : String(error), "error");
7483
+ }
7484
+ }
7485
+
7486
+ async function deletePersonProfileEntry(personId, entryId) {
7487
+ if (!confirm("\u786E\u8BA4\u5220\u9664\u8FD9\u6761\u6863\u6848\u6761\u76EE\uFF1F")) return;
7488
+ try {
7489
+ await deleteJson("/api/persons/" + encodeURIComponent(personId) + "/profile/entries/" + encodeURIComponent(entryId));
7490
+ showToast("\u6863\u6848\u6761\u76EE\u5DF2\u5220\u9664", "success");
7491
+ await showPersonProfile(personId);
7492
+ } catch (error) {
7493
+ showToast(error instanceof Error ? error.message : String(error), "error");
7494
+ }
7495
+ }
7496
+
6545
7497
  function renderMetrics(status) {
6546
7498
  var gatewayClass = status.gateway.configured ? "status-dot online" : "status-dot offline";
6547
7499
  var gatewayText = status.gateway.connection === "running" ? "\u8FD0\u884C\u4E2D" : (!status.gateway.configured ? "\u672A\u914D\u7F6E" : "\u5F85\u542F\u52A8");
@@ -6803,6 +7755,96 @@ function buildHtml() {
6803
7755
  container.innerHTML = html;
6804
7756
  }
6805
7757
 
7758
+ function renderPersonsView() {
7759
+ var listEl = document.getElementById("persons-list");
7760
+ var profilePanel = document.getElementById("person-profile-panel");
7761
+ if (profilePanel) profilePanel.style.display = "none";
7762
+ if (!allPersons || allPersons.length === 0) {
7763
+ listEl.innerHTML = '<div class="empty-state">\u8FD8\u6CA1\u6709\u4E2A\u4EBA\u6863\u6848\u3002\u7CFB\u7EDF\u4F1A\u81EA\u52A8\u4ECE\u804A\u5929\u8BB0\u5F55\u4E2D\u8BC6\u522B\u7FA4\u6210\u5458\u3002</div>';
7764
+ return;
7765
+ }
7766
+ var html = '<table class="data-table"><thead><tr><th>\u6210\u5458</th><th>\u6863\u6848\u6761\u76EE</th><th>\u6D88\u606F\u6570</th></tr></thead><tbody>';
7767
+ for (var i = 0; i < allPersons.length; i++) {
7768
+ var p = allPersons[i];
7769
+ html += '<tr data-view-person="' + escapeHtml(p.id) + '" style="cursor:pointer;">' +
7770
+ '<td><span style="font-weight:500;">' + escapeHtml(p.primaryName) + '</span></td>' +
7771
+ '<td>' + escapeHtml(p.profileEntryCount) + '</td>' +
7772
+ '<td>' + escapeHtml(p.messageCount) + '</td></tr>';
7773
+ }
7774
+ html += '</tbody></table>';
7775
+ listEl.innerHTML = html;
7776
+ }
7777
+
7778
+ async function showPersonProfile(id) {
7779
+ selectedPersonId = id;
7780
+ var listEl = document.getElementById("persons-list-panel");
7781
+ var profilePanel = document.getElementById("person-profile-panel");
7782
+ var detailEl = document.getElementById("person-profile-detail");
7783
+ if (listEl) listEl.style.display = "none";
7784
+ if (profilePanel) profilePanel.style.display = "block";
7785
+ detailEl.innerHTML = '<div class="empty-state">\u6B63\u5728\u52A0\u8F7D\u4E2A\u4EBA\u6863\u6848...</div>';
7786
+ try {
7787
+ var profile = await fetchJson("/api/persons/" + encodeURIComponent(id) + "/profile");
7788
+ var messages = await fetchJson("/api/persons/" + encodeURIComponent(id) + "/messages?limit=50");
7789
+ renderPersonProfile(id, profile, messages.items || []);
7790
+ } catch (error) {
7791
+ detailEl.innerHTML = '<div class="empty-state">\u52A0\u8F7D\u5931\u8D25\uFF1A' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
7792
+ }
7793
+ }
7794
+
7795
+ function renderPersonProfile(id, profile, messages) {
7796
+ var el = document.getElementById("person-profile-detail");
7797
+ var nameEl = document.getElementById("person-profile-name");
7798
+ if (nameEl) nameEl.textContent = profile.person.primaryName;
7799
+ var html = '';
7800
+ var identities = profile.identities || [];
7801
+ if (identities.length > 0) {
7802
+ html += '<div class="mb-md"><span style="color:var(--text-muted);font-size:13px;">\u8EAB\u4EFD\uFF1A</span> ';
7803
+ html += identities.map(function(id) { return escapeHtml(id.displayName) + ' (' + escapeHtml(id.platform) + ')'; }).join('\uFF0C');
7804
+ html += '</div>';
7805
+ }
7806
+ var entries = profile.entries || [];
7807
+ if (entries.length === 0) {
7808
+ html += '<div class="empty-state">\u8FD8\u6CA1\u6709\u6863\u6848\u6761\u76EE\u3002</div>';
7809
+ } else {
7810
+ html += '<div class="grid-2">';
7811
+ for (var i = 0; i < entries.length; i++) {
7812
+ var entry = entries[i];
7813
+ var evidence = entry.evidence || [];
7814
+ html += '<div class="content-panel" style="background:rgba(255,255,255,0.03);padding:var(--space-md);">' +
7815
+ '<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm);align-items:center;flex-wrap:wrap;">' +
7816
+ '<span class="tag">' + escapeHtml(entry.category) + '</span>' +
7817
+ '<span class="tag ' + (entry.entryType === 'fact' ? 'tag-success' : 'tag-info') + '">' + escapeHtml(entry.entryType) + '</span>' +
7818
+ '<span style="font-size:12px;color:var(--text-muted);">' + escapeHtml(Math.round(entry.confidence * 100)) + '%</span>' +
7819
+ '<span style="flex:1;"></span>' +
7820
+ '<button class="btn btn-sm" onclick="correctPersonProfileEntry(' + toJsArgument(id) + ',' + toJsArgument(entry.id) + ',' + toJsArgument(entry.category) + ',' + toJsArgument(entry.content) + ',' + toJsArgument(entry.entryType) + ',' + Number(entry.confidence || 0) + ',' + toJsArgument(findEntryEvidenceMessage(entry)) + ',' + toJsArgument(findEntryEvidenceQuote(entry)) + ')">\u4FEE\u6B63</button>' +
7821
+ '<button class="btn btn-sm btn-danger" onclick="deletePersonProfileEntry(' + toJsArgument(id) + ',' + toJsArgument(entry.id) + ')">\u5220\u9664</button>' +
7822
+ '</div>' +
7823
+ '<div style="font-weight:500;margin-bottom:var(--space-xs);">' + escapeHtml(entry.content) + '</div>';
7824
+ if (evidence.length > 0) {
7825
+ html += '<div style="font-size:12px;color:var(--text-muted);">\u8BC1\u636E\uFF1A' +
7826
+ evidence.map(function(e) { return '<span style="display:block;margin-top:2px;">"' + escapeHtml(e.quote) + '" (' + escapeHtml(e.reason) + ')</span>'; }).join('') +
7827
+ '</div>';
7828
+ }
7829
+ html += '</div>';
7830
+ }
7831
+ html += '</div>';
7832
+ }
7833
+ if (messages && messages.length > 0) {
7834
+ html += '<h3 style="margin:var(--space-lg) 0 var(--space-sm);font-size:16px;">\u6700\u8FD1\u6D88\u606F</h3>';
7835
+ html += '<div class="message-list">';
7836
+ for (var j = 0; j < Math.min(messages.length, 10); j++) {
7837
+ var msg = messages[j];
7838
+ html += '<div class="message-card"><div class="message-meta">' +
7839
+ '<span>' + escapeHtml(formatDateTime(msg.sentAt)) + '</span>' +
7840
+ '<span>' + escapeHtml(displayChatName(msg.chatName, msg.platform)) + '</span>' +
7841
+ '</div><div class="message-text">' + escapeHtml(msg.text) + '</div></div>';
7842
+ }
7843
+ html += '</div>';
7844
+ }
7845
+ el.innerHTML = html;
7846
+ }
7847
+
6806
7848
  function renderSettings(status) {
6807
7849
  var el = document.getElementById("settings-config");
6808
7850
  var html = '<h3 style="font-size:16px;font-weight:600;margin-bottom:var(--space-md);">\u7CFB\u7EDF\u914D\u7F6E</h3>';
@@ -6833,6 +7875,7 @@ function buildHtml() {
6833
7875
  await loadSection("/api/file-jobs", function(data) { allFileJobs = data.items || []; });
6834
7876
  await loadSection("/api/qa-logs?limit=20", function(data) { allQaLogs = data.items || []; });
6835
7877
  await loadSection("/api/cron-jobs", function(data) { allCronJobs = data.items || []; });
7878
+ await loadSection("/api/persons", function(data) { allPersons = data.items || []; });
6836
7879
  if (currentView === "messages") renderMessagesView();
6837
7880
  if (currentView === "episodes") renderEpisodesView();
6838
7881
  if (currentView === "files") renderFilesView();
@@ -6841,6 +7884,10 @@ function buildHtml() {
6841
7884
  renderQaLogsView();
6842
7885
  if (selectedQaLogId) void showQaLogDetail(selectedQaLogId);
6843
7886
  }
7887
+ if (currentView === "persons") {
7888
+ renderPersonsView();
7889
+ if (selectedPersonId) void showPersonProfile(selectedPersonId);
7890
+ }
6844
7891
  }
6845
7892
 
6846
7893
  async function processNow() {
@@ -6867,6 +7914,11 @@ function buildHtml() {
6867
7914
  void showQaLogDetail(qaLogId);
6868
7915
  return;
6869
7916
  }
7917
+ var personId = target.dataset.viewPerson || target.closest('[data-view-person]')?.dataset.viewPerson;
7918
+ if (personId) {
7919
+ void showPersonProfile(personId);
7920
+ return;
7921
+ }
6870
7922
  var id = target.dataset.deleteCronJob;
6871
7923
  if (!id) return;
6872
7924
  target.disabled = true;
@@ -6886,7 +7938,7 @@ function buildHtml() {
6886
7938
  </body>
6887
7939
  </html>`;
6888
7940
  }
6889
- function parseLimit(value, fallback, max) {
7941
+ function parseLimit2(value, fallback, max) {
6890
7942
  const rawLimit = Number(value ?? fallback);
6891
7943
  return Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), max) : fallback;
6892
7944
  }
@@ -6910,6 +7962,16 @@ function parseCookies(header) {
6910
7962
  function isAuthorizedWebAction(request, token) {
6911
7963
  return parseCookies(request.headers.cookie).chattercatcher_web_token === token;
6912
7964
  }
7965
+ function readStringField(input2, key) {
7966
+ if (!input2 || typeof input2 !== "object" || Array.isArray(input2)) return void 0;
7967
+ const value = input2[key];
7968
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
7969
+ }
7970
+ function readNumberField(input2, key, fallback) {
7971
+ if (!input2 || typeof input2 !== "object" || Array.isArray(input2)) return fallback;
7972
+ const value = input2[key];
7973
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
7974
+ }
6913
7975
  function toQaLogListItem(log) {
6914
7976
  const { trace: _trace, ...item } = log;
6915
7977
  return item;
@@ -6923,11 +7985,12 @@ function createWebApp(config, options = {}) {
6923
7985
  const fileJobs = new FileJobRepository(database);
6924
7986
  const qaLogs = new QaLogRepository(database);
6925
7987
  const cronJobs = new CronJobRepository(database);
7988
+ const profiles = new ProfileRepository(database);
6926
7989
  let webActionToken = "";
6927
7990
  const tokenReady = (async () => {
6928
7991
  const secrets = await loadSecrets();
6929
7992
  if (!secrets.web.actionToken) {
6930
- secrets.web.actionToken = crypto8.randomBytes(32).toString("hex");
7993
+ secrets.web.actionToken = crypto9.randomBytes(32).toString("hex");
6931
7994
  await saveSecrets(secrets);
6932
7995
  }
6933
7996
  webActionToken = getWebActionToken(secrets);
@@ -6965,32 +8028,32 @@ function createWebApp(config, options = {}) {
6965
8028
  items: messages.listChats()
6966
8029
  }));
6967
8030
  app.get("/api/files", async (request) => {
6968
- const limit = parseLimit(request.query.limit, 50, 200);
8031
+ const limit = parseLimit2(request.query.limit, 50, 200);
6969
8032
  return {
6970
8033
  items: messages.listFiles(limit)
6971
8034
  };
6972
8035
  });
6973
8036
  app.get("/api/file-jobs", async (request) => {
6974
- const limit = parseLimit(request.query.limit, 50, 200);
8037
+ const limit = parseLimit2(request.query.limit, 50, 200);
6975
8038
  const status = request.query.status;
6976
8039
  return {
6977
8040
  items: fileJobs.list(limit, status === "processing" || status === "indexed" || status === "failed" ? { status } : {})
6978
8041
  };
6979
8042
  });
6980
8043
  app.get("/api/messages/recent", async (request) => {
6981
- const limit = parseLimit(request.query.limit, 20, 100);
8044
+ const limit = parseLimit2(request.query.limit, 20, 100);
6982
8045
  return {
6983
8046
  items: messages.listRecentMessages(limit)
6984
8047
  };
6985
8048
  });
6986
8049
  app.get("/api/episodes", async (request) => {
6987
- const limit = parseLimit(request.query.limit, 20, 100);
8050
+ const limit = parseLimit2(request.query.limit, 20, 100);
6988
8051
  return {
6989
8052
  items: episodes.listRecentEpisodes(limit)
6990
8053
  };
6991
8054
  });
6992
8055
  app.get("/api/qa-logs", async (request) => {
6993
- const limit = parseLimit(request.query.limit, 20, 100);
8056
+ const limit = parseLimit2(request.query.limit, 20, 100);
6994
8057
  return {
6995
8058
  items: qaLogs.listRecent(limit).map(toQaLogListItem)
6996
8059
  };
@@ -7005,7 +8068,7 @@ function createWebApp(config, options = {}) {
7005
8068
  return log;
7006
8069
  });
7007
8070
  app.get("/api/cron-jobs", async (request) => {
7008
- const limit = parseLimit(request.query.limit, 50, 200);
8071
+ const limit = parseLimit2(request.query.limit, 50, 200);
7009
8072
  return {
7010
8073
  items: cronJobs.list(limit)
7011
8074
  };
@@ -7025,6 +8088,105 @@ function createWebApp(config, options = {}) {
7025
8088
  const ok = cronJobs.deleteByChat(id, job.chatId);
7026
8089
  return { ok };
7027
8090
  });
8091
+ app.get("/api/persons", async (_request) => {
8092
+ const persons = profiles.listPersons();
8093
+ const items = persons.map((person) => {
8094
+ const profile = profiles.getPersonProfile(person.id, { includeEvidence: false, includeInferred: true });
8095
+ return {
8096
+ id: person.id,
8097
+ primaryName: person.primaryName,
8098
+ profileEntryCount: profile?.entries.length ?? 0,
8099
+ messageCount: database.prepare(
8100
+ "SELECT COUNT(1) AS count FROM messages WHERE person_id = ?"
8101
+ ).get(person.id).count
8102
+ };
8103
+ });
8104
+ return { items };
8105
+ });
8106
+ app.get("/api/persons/:id/profile", async (request, reply) => {
8107
+ const id = request.params.id;
8108
+ const profile = profiles.getPersonProfile(id, { includeEvidence: true, includeInferred: true });
8109
+ if (!profile) {
8110
+ reply.code(404);
8111
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6210\u5458\u3002" };
8112
+ }
8113
+ return profile;
8114
+ });
8115
+ app.get("/api/persons/:id/messages", async (request) => {
8116
+ const id = request.params.id;
8117
+ const limit = parseLimit2(request.query.limit, 50, 200);
8118
+ const rows = database.prepare(
8119
+ `SELECT m.id, m.platform, m.platform_message_id AS platformMessageId, m.sender_id AS senderId,
8120
+ m.sender_name AS senderName, m.message_type AS messageType, m.text, m.sent_at AS sentAt,
8121
+ c.name AS chatName, c.platform_chat_id AS platformChatId
8122
+ FROM messages m
8123
+ JOIN chats c ON c.id = m.chat_id
8124
+ WHERE m.person_id = ?
8125
+ ORDER BY m.sent_at DESC, m.created_at DESC
8126
+ LIMIT ?`
8127
+ ).all(id, limit);
8128
+ return { items: rows };
8129
+ });
8130
+ app.post("/api/persons/:id/profile/entries/:entryId/correct", async (request, reply) => {
8131
+ await tokenReady;
8132
+ if (!isAuthorizedWebAction(request, webActionToken)) {
8133
+ reply.code(403);
8134
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
8135
+ }
8136
+ const { id, entryId } = request.params;
8137
+ const existing = profiles.getProfileEntry(entryId);
8138
+ if (!existing || existing.personId !== id || existing.status !== "active") {
8139
+ reply.code(404);
8140
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6863\u6848\u6761\u76EE\u3002" };
8141
+ }
8142
+ const body = request.body;
8143
+ const category = readStringField(body, "category") ?? existing.category;
8144
+ const content = readStringField(body, "content");
8145
+ const entryType = readStringField(body, "entryType") ?? existing.entryType;
8146
+ const evidenceMessageId = readStringField(body, "evidenceMessageId");
8147
+ const quote = readStringField(body, "quote");
8148
+ const reason = readStringField(body, "reason") ?? "\u7528\u6237\u5728 Web UI \u663E\u5F0F\u4FEE\u6B63";
8149
+ if (!content || !evidenceMessageId || !quote) {
8150
+ reply.code(400);
8151
+ return { ok: false, message: "\u4FEE\u6B63\u5FC5\u987B\u5305\u542B content\u3001evidenceMessageId \u548C quote\u3002" };
8152
+ }
8153
+ if (entryType !== "fact" && entryType !== "inferred") {
8154
+ reply.code(400);
8155
+ return { ok: false, message: "entryType \u5FC5\u987B\u662F fact \u6216 inferred\u3002" };
8156
+ }
8157
+ if (category === existing.category && content === existing.content) {
8158
+ reply.code(400);
8159
+ return { ok: false, message: "\u4FEE\u6B63\u5185\u5BB9\u6CA1\u6709\u53D8\u5316\u3002" };
8160
+ }
8161
+ const entryIdNew = profiles.replaceProfileEntry({
8162
+ supersedeEntryId: entryId,
8163
+ input: {
8164
+ personId: id,
8165
+ category,
8166
+ content,
8167
+ entryType,
8168
+ confidence: Math.min(1, Math.max(0, readNumberField(body, "confidence", existing.confidence))),
8169
+ source: "explicit_user_request",
8170
+ evidence: [{ messageId: evidenceMessageId, quote, reason }]
8171
+ }
8172
+ });
8173
+ return { ok: true, entryId: entryIdNew };
8174
+ });
8175
+ app.delete("/api/persons/:id/profile/entries/:entryId", async (request, reply) => {
8176
+ await tokenReady;
8177
+ if (!isAuthorizedWebAction(request, webActionToken)) {
8178
+ reply.code(403);
8179
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
8180
+ }
8181
+ const { id, entryId } = request.params;
8182
+ const existing = profiles.getProfileEntry(entryId);
8183
+ if (!existing || existing.personId !== id || existing.status !== "active") {
8184
+ reply.code(404);
8185
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6863\u6848\u6761\u76EE\u3002" };
8186
+ }
8187
+ profiles.markProfileEntryDeleted(entryId);
8188
+ return { ok: true };
8189
+ });
7028
8190
  app.post("/api/process/messages", async (request, reply) => {
7029
8191
  await tokenReady;
7030
8192
  if (!isAuthorizedWebAction(request, webActionToken)) {
@@ -7262,7 +8424,7 @@ async function startGatewayForegroundCommand() {
7262
8424
  const gatewayRuntime = createFeishuGateway({
7263
8425
  config,
7264
8426
  secrets,
7265
- ingestor: new GatewayIngestor(database),
8427
+ ingestor: new GatewayIngestor(database, { profiles: new ProfileRepository(database) }),
7266
8428
  resourceDownloader: FeishuResourceDownloader.fromConfig(config, secrets),
7267
8429
  attachmentVectorIndexer: vectorStore ? (messageId) => indexMessageChunks({
7268
8430
  messages: new MessageRepository(database),
@@ -7352,6 +8514,47 @@ web.command("start").description("\u542F\u52A8\u672C\u5730 Web UI").action(async
7352
8514
  const config = await loadConfig();
7353
8515
  await startWebServer(config, { version: package_default.version });
7354
8516
  });
8517
+ var profilesCommand = program.command("profiles").description("\u67E5\u770B\u548C\u7EF4\u62A4\u4E2A\u4EBA\u6863\u6848");
8518
+ profilesCommand.command("list").description("\u5217\u51FA\u4E2A\u4EBA\u6863\u6848").action(async () => {
8519
+ const config = await loadConfig();
8520
+ const database = openDatabase(config);
8521
+ try {
8522
+ const profiles = new ProfileRepository(database);
8523
+ for (const person of profiles.listPersons()) {
8524
+ console.log(`${person.id} ${person.primaryName} ${person.updatedAt}`);
8525
+ }
8526
+ } finally {
8527
+ database.close();
8528
+ }
8529
+ });
8530
+ profilesCommand.command("show").argument("personId").description("\u67E5\u770B\u4E2A\u4EBA\u6863\u6848").action(async (personId) => {
8531
+ const config = await loadConfig();
8532
+ const database = openDatabase(config);
8533
+ try {
8534
+ const profiles = new ProfileRepository(database);
8535
+ const profile = profiles.getPersonProfile(personId, { includeEvidence: true, includeInferred: true });
8536
+ if (!profile) {
8537
+ console.error("\u672A\u627E\u5230\u4E2A\u4EBA\u6863\u6848\u3002");
8538
+ process.exitCode = 1;
8539
+ return;
8540
+ }
8541
+ console.log(JSON.stringify(profile, null, 2));
8542
+ } finally {
8543
+ database.close();
8544
+ }
8545
+ });
8546
+ profilesCommand.command("backfill").description("\u4E3A\u5386\u53F2\u6D88\u606F\u56DE\u586B\u4E2A\u4EBA\u6863\u6848\u5173\u8054").option("--limit <number>", "\u6700\u591A\u56DE\u586B\u6D88\u606F\u6570", "1000").action(async (options) => {
8547
+ const config = await loadConfig();
8548
+ const database = openDatabase(config);
8549
+ try {
8550
+ const profiles = new ProfileRepository(database);
8551
+ const limit = Number(options.limit);
8552
+ const result = profiles.backfillMessagePersons({ limit: Number.isFinite(limit) ? limit : 1e3 });
8553
+ console.log(`\u5DF2\u56DE\u586B ${result.updatedMessages} \u6761\u6D88\u606F\u3002`);
8554
+ } finally {
8555
+ database.close();
8556
+ }
8557
+ });
7355
8558
  var data = program.command("data").description("\u7BA1\u7406\u672C\u5730\u77E5\u8BC6\u5E93\u6570\u636E");
7356
8559
  async function deleteDataCommand(targetType, targetId, options) {
7357
8560
  const shouldDelete = options.yes || await confirm({
@@ -7482,6 +8685,35 @@ processCommand.command("episodes").description("\u7ACB\u5373\u751F\u6210\u4F1A\u
7482
8685
  database.close();
7483
8686
  }
7484
8687
  });
8688
+ processCommand.command("profiles").description("\u5904\u7406\u672A\u603B\u7ED3\u6D88\u606F\u5E76\u66F4\u65B0\u4E2A\u4EBA\u6863\u6848").option("--limit <number>", "\u6BCF\u4E2A\u7FA4\u6700\u591A\u5904\u7406\u6D88\u606F\u6570", "100").action(async (options) => {
8689
+ const config = await loadConfig();
8690
+ const secrets = await loadSecrets();
8691
+ const database = openDatabase(config);
8692
+ try {
8693
+ const profiles = new ProfileRepository(database);
8694
+ profiles.backfillMessagePersons({ limit: 1e4 });
8695
+ const processor = new ProfileDreamProcessor({ profiles, model: createChatModel(config, secrets) });
8696
+ const chats = profiles.listChatsWithPendingDreamMessages();
8697
+ let succeeded = 0;
8698
+ let failed = 0;
8699
+ let skipped = 0;
8700
+ const limit = Number(options.limit);
8701
+ for (const chat of chats) {
8702
+ const result = await processor.processChat({
8703
+ platform: chat.platform,
8704
+ platformChatId: chat.platformChatId,
8705
+ limit: Number.isFinite(limit) ? limit : 100
8706
+ });
8707
+ if (result.status === "succeeded") succeeded += 1;
8708
+ if (result.status === "failed") failed += 1;
8709
+ if (result.status === "skipped") skipped += 1;
8710
+ console.log(`${chat.platformChatId}: ${result.status}, messages=${result.processedMessageCount}, entries=${result.generatedEntryCount}${result.error ? `, error=${result.error}` : ""}`);
8711
+ }
8712
+ console.log(`\u4E2A\u4EBA\u6863\u6848\u5904\u7406\u5B8C\u6210\uFF1A\u6210\u529F ${succeeded}\uFF0C\u5931\u8D25 ${failed}\uFF0C\u8DF3\u8FC7 ${skipped}\u3002`);
8713
+ } finally {
8714
+ database.close();
8715
+ }
8716
+ });
7485
8717
  var files = program.command("files").description("\u7BA1\u7406\u672C\u5730\u6587\u4EF6\u77E5\u8BC6\u6E90");
7486
8718
  files.command("add").description("\u628A\u672C\u5730\u6587\u4EF6\u89E3\u6790\u3001\u4FDD\u5B58\u5230\u6570\u636E\u76EE\u5F55\u5E76\u5199\u5165 RAG \u77E5\u8BC6\u5E93").argument("<paths...>", "\u6587\u4EF6\u8DEF\u5F84\uFF0C\u652F\u6301 txt\u3001md\u3001json\u3001csv\u3001tsv\u3001log\u3001docx\u3001pdf").action(async (paths) => {
7487
8719
  const config = await loadConfig();
@@ -7656,7 +8888,7 @@ dev.command("ingest-feishu-event").description("\u4ECE JSON \u6587\u4EF6\u6A21\u
7656
8888
  try {
7657
8889
  const raw = await fs15.readFile(options.file, "utf8");
7658
8890
  const payload = JSON.parse(raw);
7659
- const result = new GatewayIngestor(database).ingestFeishuEvent(payload);
8891
+ const result = new GatewayIngestor(database, { profiles: new ProfileRepository(database) }).ingestFeishuEvent(payload);
7660
8892
  if (!result.accepted) {
7661
8893
  console.log(result.reason);
7662
8894
  return;