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/index.js CHANGED
@@ -916,6 +916,7 @@ function migrateDatabase(database) {
916
916
  chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
917
917
  sender_id TEXT NOT NULL,
918
918
  sender_name TEXT NOT NULL,
919
+ person_id TEXT REFERENCES persons(id) ON DELETE SET NULL,
919
920
  message_type TEXT NOT NULL,
920
921
  text TEXT NOT NULL,
921
922
  raw_payload_json TEXT NOT NULL,
@@ -953,6 +954,80 @@ function migrateDatabase(database) {
953
954
  UNIQUE(chat_id, started_at, ended_at)
954
955
  );
955
956
 
957
+ CREATE TABLE IF NOT EXISTS persons (
958
+ id TEXT PRIMARY KEY,
959
+ primary_name TEXT NOT NULL,
960
+ notes TEXT,
961
+ created_at TEXT NOT NULL,
962
+ updated_at TEXT NOT NULL
963
+ );
964
+
965
+ CREATE TABLE IF NOT EXISTS person_identities (
966
+ id TEXT PRIMARY KEY,
967
+ person_id TEXT NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
968
+ platform TEXT NOT NULL,
969
+ platform_chat_id TEXT NOT NULL,
970
+ external_user_id TEXT NOT NULL,
971
+ external_open_id TEXT,
972
+ external_union_id TEXT,
973
+ external_user_id_raw TEXT,
974
+ display_name TEXT NOT NULL,
975
+ alias TEXT,
976
+ source TEXT NOT NULL CHECK(source IN ('message','feishu_member','manual','inferred')),
977
+ first_seen_at TEXT NOT NULL,
978
+ last_seen_at TEXT NOT NULL,
979
+ UNIQUE(platform, platform_chat_id, external_user_id)
980
+ );
981
+
982
+ CREATE INDEX IF NOT EXISTS person_identities_person_idx ON person_identities(person_id);
983
+ CREATE INDEX IF NOT EXISTS person_identities_lookup_idx ON person_identities(platform, platform_chat_id, external_user_id);
984
+ CREATE INDEX IF NOT EXISTS person_identities_name_idx ON person_identities(display_name, alias);
985
+
986
+ CREATE TABLE IF NOT EXISTS person_profile_entries (
987
+ id TEXT PRIMARY KEY,
988
+ person_id TEXT NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
989
+ category TEXT NOT NULL,
990
+ content TEXT NOT NULL,
991
+ entry_type TEXT NOT NULL CHECK(entry_type IN ('fact','inferred')),
992
+ confidence REAL NOT NULL,
993
+ status TEXT NOT NULL CHECK(status IN ('active','superseded','deleted')),
994
+ source TEXT NOT NULL CHECK(source IN ('dream','explicit_user_request','manual')),
995
+ created_at TEXT NOT NULL,
996
+ updated_at TEXT NOT NULL,
997
+ last_observed_at TEXT NOT NULL
998
+ );
999
+
1000
+ CREATE INDEX IF NOT EXISTS person_profile_entries_person_status_idx ON person_profile_entries(person_id, status, updated_at);
1001
+
1002
+ CREATE TABLE IF NOT EXISTS person_profile_evidence (
1003
+ entry_id TEXT NOT NULL REFERENCES person_profile_entries(id) ON DELETE CASCADE,
1004
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
1005
+ quote TEXT NOT NULL,
1006
+ reason TEXT NOT NULL,
1007
+ PRIMARY KEY (entry_id, message_id, quote)
1008
+ );
1009
+
1010
+ CREATE TABLE IF NOT EXISTS profile_dream_state (
1011
+ platform TEXT NOT NULL,
1012
+ platform_chat_id TEXT NOT NULL,
1013
+ last_message_id TEXT,
1014
+ last_message_sent_at TEXT,
1015
+ updated_at TEXT NOT NULL,
1016
+ PRIMARY KEY (platform, platform_chat_id)
1017
+ );
1018
+
1019
+ CREATE TABLE IF NOT EXISTS profile_dream_runs (
1020
+ id TEXT PRIMARY KEY,
1021
+ platform TEXT NOT NULL,
1022
+ platform_chat_id TEXT NOT NULL,
1023
+ status TEXT NOT NULL CHECK(status IN ('succeeded','failed','skipped')),
1024
+ processed_message_count INTEGER NOT NULL,
1025
+ generated_entry_count INTEGER NOT NULL,
1026
+ error TEXT,
1027
+ started_at TEXT NOT NULL,
1028
+ finished_at TEXT NOT NULL
1029
+ );
1030
+
956
1031
  CREATE TABLE IF NOT EXISTS memory_episode_messages (
957
1032
  episode_id TEXT NOT NULL REFERENCES memory_episodes(id) ON DELETE CASCADE,
958
1033
  message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
@@ -1080,6 +1155,11 @@ function migrateDatabase(database) {
1080
1155
  CREATE INDEX IF NOT EXISTS feishu_chat_members_chat_name_idx
1081
1156
  ON feishu_chat_members(chat_id, user_name);
1082
1157
  `);
1158
+ const messageColumns = database.prepare("PRAGMA table_info(messages)").all();
1159
+ if (!messageColumns.some((column) => column.name === "person_id")) {
1160
+ database.prepare("ALTER TABLE messages ADD COLUMN person_id TEXT REFERENCES persons(id) ON DELETE SET NULL").run();
1161
+ }
1162
+ database.prepare("CREATE INDEX IF NOT EXISTS messages_person_idx ON messages(person_id, sent_at)").run();
1083
1163
  const cronJobColumns = database.prepare("PRAGMA table_info(cron_jobs)").all();
1084
1164
  const ensureCronJobColumn = (name, definition) => {
1085
1165
  if (!cronJobColumns.some((column) => column.name === name)) {
@@ -1789,6 +1869,10 @@ function buildScopeWhere(scope) {
1789
1869
  clauses.push("c.platform_chat_id = ?");
1790
1870
  params.push(scope.platformChatId);
1791
1871
  }
1872
+ if (scope?.personId) {
1873
+ clauses.push("m.person_id = ?");
1874
+ params.push(scope.personId);
1875
+ }
1792
1876
  return {
1793
1877
  where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
1794
1878
  params
@@ -1833,14 +1917,15 @@ var MessageRepository = class {
1833
1917
  `
1834
1918
  INSERT INTO messages (
1835
1919
  id, platform, platform_message_id, chat_id, sender_id, sender_name,
1836
- message_type, text, raw_payload_json, sent_at, received_at, created_at
1920
+ person_id, message_type, text, raw_payload_json, sent_at, received_at, created_at
1837
1921
  )
1838
1922
  VALUES (
1839
1923
  @id, @platform, @platformMessageId, @chatId, @senderId, @senderName,
1840
- @messageType, @text, @rawPayloadJson, @sentAt, @receivedAt, @createdAt
1924
+ @personId, @messageType, @text, @rawPayloadJson, @sentAt, @receivedAt, @createdAt
1841
1925
  )
1842
1926
  ON CONFLICT(platform, platform_message_id)
1843
1927
  DO UPDATE SET
1928
+ person_id = COALESCE(excluded.person_id, messages.person_id),
1844
1929
  message_type = excluded.message_type,
1845
1930
  text = excluded.text,
1846
1931
  raw_payload_json = excluded.raw_payload_json,
@@ -1853,6 +1938,7 @@ var MessageRepository = class {
1853
1938
  chatId,
1854
1939
  senderId: input.senderId,
1855
1940
  senderName: input.senderName,
1941
+ personId: input.personId ?? null,
1856
1942
  messageType: input.messageType,
1857
1943
  text: input.text,
1858
1944
  rawPayloadJson,
@@ -1895,6 +1981,7 @@ var MessageRepository = class {
1895
1981
  m.chat_id AS chatId,
1896
1982
  m.sender_id AS senderId,
1897
1983
  m.sender_name AS senderName,
1984
+ m.person_id AS personId,
1898
1985
  m.sent_at AS sentAt,
1899
1986
  c.platform_chat_id AS platformChatId,
1900
1987
  c.name AS chatName
@@ -1917,6 +2004,7 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1917
2004
  platformMessageId: derivedPlatformMessageId,
1918
2005
  senderId: source.senderId,
1919
2006
  senderName: source.senderName,
2007
+ personId: source.personId ?? void 0,
1920
2008
  messageType: "image_summary",
1921
2009
  text: summaryText,
1922
2010
  sentAt: source.sentAt,
@@ -1943,7 +2031,9 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1943
2031
  1.0 AS score,
1944
2032
  m.message_type AS messageType,
1945
2033
  c.name AS chatName,
2034
+ m.sender_id AS senderId,
1946
2035
  m.sender_name AS senderName,
2036
+ m.person_id AS personId,
1947
2037
  m.sent_at AS sentAt
1948
2038
  FROM message_chunks mc
1949
2039
  JOIN messages m ON m.id = mc.message_id
@@ -1964,7 +2054,9 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1964
2054
  1.0 AS score,
1965
2055
  m.message_type AS messageType,
1966
2056
  c.name AS chatName,
2057
+ m.sender_id AS senderId,
1967
2058
  m.sender_name AS senderName,
2059
+ m.person_id AS personId,
1968
2060
  m.sent_at AS sentAt
1969
2061
  FROM message_chunks mc
1970
2062
  JOIN messages m ON m.id = mc.message_id
@@ -1988,7 +2080,9 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1988
2080
  1.0 AS score,
1989
2081
  m.message_type AS messageType,
1990
2082
  c.name AS chatName,
2083
+ m.sender_id AS senderId,
1991
2084
  m.sender_name AS senderName,
2085
+ m.person_id AS personId,
1992
2086
  m.sent_at AS sentAt
1993
2087
  FROM message_chunks mc
1994
2088
  JOIN messages m ON m.id = mc.message_id
@@ -2004,7 +2098,7 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
2004
2098
  const excludedIds = options.excludeMessageIds ?? [];
2005
2099
  const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
2006
2100
  const scope = buildScopeWhere(options.scope);
2007
- const ftsResults = this.database.prepare(
2101
+ const ftsRows = this.database.prepare(
2008
2102
  `
2009
2103
  SELECT
2010
2104
  fts.chunk_id AS chunkId,
@@ -2014,8 +2108,11 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
2014
2108
  bm25(message_chunks_fts) * -1 AS score,
2015
2109
  m.message_type AS messageType,
2016
2110
  c.name AS chatName,
2111
+ m.sender_id AS senderId,
2017
2112
  m.sender_name AS senderName,
2018
- m.sent_at AS sentAt
2113
+ m.person_id AS personId,
2114
+ m.sent_at AS sentAt,
2115
+ mc.chunk_index AS chunkIndex
2019
2116
  FROM message_chunks_fts fts
2020
2117
  JOIN message_chunks mc ON mc.id = fts.chunk_id
2021
2118
  JOIN messages m ON m.id = fts.message_id
@@ -2023,10 +2120,23 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
2023
2120
  WHERE message_chunks_fts MATCH ?
2024
2121
  ${excludedWhere}
2025
2122
  ${scope.where}
2026
- ORDER BY bm25(message_chunks_fts)
2123
+ ORDER BY bm25(message_chunks_fts), m.sent_at DESC, mc.chunk_index ASC
2027
2124
  LIMIT ?
2028
2125
  `
2029
- ).all(ftsQuery, ...excludedIds, ...scope.params, limit);
2126
+ ).all(ftsQuery, ...excludedIds, ...scope.params, Math.max(limit * 8, limit));
2127
+ const ftsResults = [];
2128
+ const seenMessageIds = /* @__PURE__ */ new Set();
2129
+ for (const row of ftsRows) {
2130
+ if (seenMessageIds.has(row.messageId)) {
2131
+ continue;
2132
+ }
2133
+ seenMessageIds.add(row.messageId);
2134
+ const { chunkIndex: _chunkIndex, ...result } = row;
2135
+ ftsResults.push(result);
2136
+ if (ftsResults.length >= limit) {
2137
+ break;
2138
+ }
2139
+ }
2030
2140
  if (ftsResults.length > 0) {
2031
2141
  return ftsResults;
2032
2142
  }
@@ -2040,22 +2150,30 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
2040
2150
  return this.database.prepare(
2041
2151
  `
2042
2152
  SELECT
2043
- mc.id AS chunkId,
2044
- m.id AS messageId,
2045
- m.platform AS platform,
2046
- mc.text AS text,
2047
- 0.1 AS score,
2048
- m.message_type AS messageType,
2049
- c.name AS chatName,
2050
- m.sender_name AS senderName,
2051
- m.sent_at AS sentAt
2052
- FROM message_chunks mc
2053
- JOIN messages m ON m.id = mc.message_id
2054
- JOIN chats c ON c.id = m.chat_id
2055
- WHERE (${where})
2056
- ${likeExcludedWhere}
2057
- ${scope.where}
2058
- ORDER BY m.sent_at DESC
2153
+ *
2154
+ FROM (
2155
+ SELECT
2156
+ mc.id AS chunkId,
2157
+ m.id AS messageId,
2158
+ m.platform AS platform,
2159
+ mc.text AS text,
2160
+ 0.1 AS score,
2161
+ m.message_type AS messageType,
2162
+ c.name AS chatName,
2163
+ m.sender_id AS senderId,
2164
+ m.sender_name AS senderName,
2165
+ m.person_id AS personId,
2166
+ m.sent_at AS sentAt,
2167
+ ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY mc.chunk_index ASC) AS rowNumber
2168
+ FROM message_chunks mc
2169
+ JOIN messages m ON m.id = mc.message_id
2170
+ JOIN chats c ON c.id = m.chat_id
2171
+ WHERE (${where})
2172
+ ${likeExcludedWhere}
2173
+ ${scope.where}
2174
+ ) ranked
2175
+ WHERE rowNumber = 1
2176
+ ORDER BY sentAt DESC
2059
2177
  LIMIT ?
2060
2178
  `
2061
2179
  ).all(...params, ...excludedIds, ...scope.params, limit);
@@ -2470,19 +2588,20 @@ var HybridRetriever = class {
2470
2588
 
2471
2589
  // src/rag/message-retriever.ts
2472
2590
  function toEvidenceSource(result) {
2473
- if (result.messageType === "file") {
2474
- return {
2475
- type: "file",
2476
- label: result.senderName,
2477
- timestamp: result.sentAt
2478
- };
2479
- }
2480
- return {
2481
- type: "message",
2482
- label: result.chatName,
2483
- sender: result.senderName,
2591
+ const source = {
2592
+ type: result.messageType === "file" ? "file" : "message",
2593
+ label: result.messageType === "file" ? result.senderName : result.chatName,
2484
2594
  timestamp: result.sentAt
2485
2595
  };
2596
+ if (result.messageType !== "file") {
2597
+ source.sender = result.senderName;
2598
+ }
2599
+ source.senderId = result.senderId;
2600
+ source.profileAvailable = Boolean(result.personId);
2601
+ if (result.personId) {
2602
+ source.personId = result.personId;
2603
+ }
2604
+ return source;
2486
2605
  }
2487
2606
  var MessageFtsRetriever = class {
2488
2607
  constructor(messages, options = {}) {
@@ -2611,6 +2730,9 @@ function toEvidenceSource2(row) {
2611
2730
  type: "message",
2612
2731
  label: row.chatName,
2613
2732
  sender: row.senderName,
2733
+ senderId: row.senderId,
2734
+ personId: row.personId,
2735
+ profileAvailable: Boolean(row.personId),
2614
2736
  timestamp: row.sentAt
2615
2737
  };
2616
2738
  }
@@ -2625,6 +2747,10 @@ function buildScopeWhere3(scope) {
2625
2747
  clauses.push("c.platform_chat_id = ?");
2626
2748
  params.push(scope.platformChatId);
2627
2749
  }
2750
+ if (scope?.personId) {
2751
+ clauses.push("m.person_id = ?");
2752
+ params.push(scope.personId);
2753
+ }
2628
2754
  return {
2629
2755
  where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
2630
2756
  params
@@ -2675,7 +2801,9 @@ var SqliteVectorStore = class {
2675
2801
  mc.id AS chunkId,
2676
2802
  mc.text AS text,
2677
2803
  c.name AS chatName,
2804
+ m.sender_id AS senderId,
2678
2805
  m.sender_name AS senderName,
2806
+ m.person_id AS personId,
2679
2807
  m.sent_at AS sentAt,
2680
2808
  e.embedding_json AS embeddingJson
2681
2809
  FROM message_chunk_embeddings e
@@ -2756,8 +2884,12 @@ async function createAgenticRagSearchTools(input) {
2756
2884
  new SqliteVectorStore(input.database, { model: input.config.embedding.model })
2757
2885
  ) : void 0;
2758
2886
  const hybrid = new HybridRetriever(semantic ? [episodes, messages, semantic] : [episodes, messages]);
2887
+ const tools = createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input.scope });
2888
+ if (input.profileTools && input.profileTools.length > 0) {
2889
+ tools.push(...input.profileTools);
2890
+ }
2759
2891
  return {
2760
- tools: createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input.scope }),
2892
+ tools,
2761
2893
  close: () => {
2762
2894
  }
2763
2895
  };
@@ -3365,6 +3497,588 @@ function parseExactNumber2(field, min, max) {
3365
3497
  return value;
3366
3498
  }
3367
3499
 
3500
+ // src/profiles/rag-tools.ts
3501
+ var getProfileInputSchema = {
3502
+ type: "object",
3503
+ properties: {
3504
+ personId: { type: "string", description: "Stable person identifier from retrieved evidence." },
3505
+ senderId: { type: "string", description: "Message sender id when personId is unavailable." },
3506
+ platformChatId: { type: "string", description: "Chat id paired with senderId for profile lookup." },
3507
+ includeEvidence: { type: "boolean", description: "Whether to include evidence snippets in the profile text." },
3508
+ includeInferred: { type: "boolean", description: "Whether to include inferred profile entries." }
3509
+ },
3510
+ additionalProperties: false
3511
+ };
3512
+ var searchMessagesInputSchema = {
3513
+ type: "object",
3514
+ properties: {
3515
+ personId: { type: "string", description: "The stable person identifier whose messages to search." },
3516
+ query: { type: "string", description: "Search query written by the model." },
3517
+ limit: { type: "number", description: "Maximum number of evidence blocks to return." }
3518
+ },
3519
+ required: ["personId", "query"],
3520
+ additionalProperties: false
3521
+ };
3522
+ function readString2(value) {
3523
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
3524
+ }
3525
+ function resolvePersonId(profiles, input) {
3526
+ const raw = typeof input === "object" && input !== null ? input : {};
3527
+ const personId = readString2(raw.personId);
3528
+ if (personId) return personId;
3529
+ const senderId = readString2(raw.senderId);
3530
+ const platformChatId = readString2(raw.platformChatId);
3531
+ if (senderId && platformChatId) {
3532
+ const resolved = profiles.resolvePersonIdForSender({ senderId, platformChatId });
3533
+ if (resolved) return resolved;
3534
+ }
3535
+ throw new Error("personId \u6216 senderId + platformChatId \u5FC5\u987B\u63D0\u4F9B\u3002");
3536
+ }
3537
+ function parseBoolean(input, key, defaultValue) {
3538
+ const value = typeof input === "object" && input !== null ? input[key] : void 0;
3539
+ return typeof value === "boolean" ? value : defaultValue;
3540
+ }
3541
+ function parseQuery(input) {
3542
+ const rawQuery = typeof input === "object" && input !== null && "query" in input ? input.query : void 0;
3543
+ if (typeof rawQuery !== "string") {
3544
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
3545
+ }
3546
+ const query = rawQuery.trim();
3547
+ if (!query) {
3548
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
3549
+ }
3550
+ return query;
3551
+ }
3552
+ function parseLimit(input) {
3553
+ const rawLimit = typeof input === "object" && input !== null && "limit" in input ? input.limit : void 0;
3554
+ const numericLimit = typeof rawLimit === "number" && Number.isFinite(rawLimit) ? rawLimit : 5;
3555
+ return Math.min(12, Math.max(1, Math.floor(numericLimit)));
3556
+ }
3557
+ function createGetPersonProfileTool(profiles) {
3558
+ return {
3559
+ name: "get_person_profile",
3560
+ 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.",
3561
+ inputSchema: getProfileInputSchema,
3562
+ execute: async (input) => {
3563
+ const personId = resolvePersonId(profiles, input);
3564
+ const includeEvidence = parseBoolean(input, "includeEvidence", false);
3565
+ const includeInferred = parseBoolean(input, "includeInferred", true);
3566
+ const profile = profiles.getPersonProfile(personId, { includeEvidence, includeInferred });
3567
+ if (!profile) {
3568
+ return [];
3569
+ }
3570
+ const aliases = profile.identities.map((identity) => identity.displayName).filter(Boolean).join("\u3001");
3571
+ const entries = profile.entries.map((entry) => {
3572
+ const evidence = includeEvidence && entry.evidence?.length ? `
3573
+ \u8BC1\u636E\uFF1A${entry.evidence.map((item) => `${item.quote}\uFF08${item.reason}\uFF09`).join("\uFF1B")}` : "";
3574
+ return `- [${entry.entryType}] ${entry.category}\uFF1A${entry.content}\uFF08\u7F6E\u4FE1\u5EA6 ${entry.confidence}\uFF0C\u6765\u6E90 ${entry.source}\uFF09${evidence}`;
3575
+ });
3576
+ return [{
3577
+ id: `person_profile:${profile.person.id}`,
3578
+ text: [`\u4EBA\u7269\uFF1A${profile.person.primaryName}`, aliases ? `\u8EAB\u4EFD/\u6635\u79F0\uFF1A${aliases}` : void 0, ...entries].filter(Boolean).join("\n"),
3579
+ score: 1,
3580
+ source: {
3581
+ type: "person_profile",
3582
+ label: profile.person.primaryName,
3583
+ personId: profile.person.id,
3584
+ profileAvailable: true
3585
+ }
3586
+ }];
3587
+ }
3588
+ };
3589
+ }
3590
+ function createSearchPersonMessagesTool(profiles) {
3591
+ return {
3592
+ name: "search_person_messages",
3593
+ 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.",
3594
+ inputSchema: searchMessagesInputSchema,
3595
+ execute: async (input) => {
3596
+ const personId = resolvePersonId(profiles, input);
3597
+ const query = parseQuery(input);
3598
+ const limit = parseLimit(input);
3599
+ const results = profiles.searchPersonMessages(personId, query, limit);
3600
+ return results.map((result) => ({
3601
+ id: result.chunkId,
3602
+ text: result.text,
3603
+ score: result.score,
3604
+ source: {
3605
+ type: result.messageType === "file" ? "file" : "message",
3606
+ label: result.chatName,
3607
+ sender: result.senderName,
3608
+ senderId: result.senderId,
3609
+ timestamp: result.sentAt,
3610
+ personId: result.personId ?? void 0,
3611
+ profileAvailable: Boolean(result.personId)
3612
+ }
3613
+ }));
3614
+ }
3615
+ };
3616
+ }
3617
+ function createPersonProfileTools({ profiles }) {
3618
+ return [createGetPersonProfileTool(profiles), createSearchPersonMessagesTool(profiles)];
3619
+ }
3620
+
3621
+ // src/profiles/repository.ts
3622
+ import crypto5 from "crypto";
3623
+ function nowIso4() {
3624
+ return (/* @__PURE__ */ new Date()).toISOString();
3625
+ }
3626
+ function createId(prefix, parts) {
3627
+ return `${prefix}_${crypto5.createHash("sha256").update(parts.join("")).digest("hex").slice(0, 24)}`;
3628
+ }
3629
+ function mapPerson(row) {
3630
+ return {
3631
+ id: row.id,
3632
+ primaryName: row.primaryName,
3633
+ notes: row.notes ?? void 0,
3634
+ createdAt: row.createdAt,
3635
+ updatedAt: row.updatedAt
3636
+ };
3637
+ }
3638
+ var ProfileRepository = class {
3639
+ constructor(database) {
3640
+ this.database = database;
3641
+ }
3642
+ database;
3643
+ resolvePersonForSender(input) {
3644
+ const observedAt = input.observedAt ?? nowIso4();
3645
+ const personId = createId("person", [input.platform, input.platformChatId, input.senderId]);
3646
+ const identityId = createId("identity", [input.platform, input.platformChatId, input.senderId]);
3647
+ const findPerson = this.database.prepare(
3648
+ `
3649
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3650
+ FROM persons
3651
+ WHERE id = ?
3652
+ `
3653
+ );
3654
+ const transaction = this.database.transaction(() => {
3655
+ this.database.prepare(
3656
+ `
3657
+ INSERT INTO persons (id, primary_name, notes, created_at, updated_at)
3658
+ VALUES (?, ?, NULL, ?, ?)
3659
+ ON CONFLICT(id) DO NOTHING
3660
+ `
3661
+ ).run(personId, input.senderName, observedAt, observedAt);
3662
+ this.database.prepare(
3663
+ `
3664
+ INSERT INTO person_identities (
3665
+ id, person_id, platform, platform_chat_id, external_user_id, external_open_id,
3666
+ external_union_id, external_user_id_raw, display_name, alias, source, first_seen_at, last_seen_at
3667
+ ) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?, ?)
3668
+ ON CONFLICT(platform, platform_chat_id, external_user_id)
3669
+ DO UPDATE SET
3670
+ display_name = excluded.display_name,
3671
+ source = excluded.source,
3672
+ last_seen_at = excluded.last_seen_at
3673
+ `
3674
+ ).run(identityId, personId, input.platform, input.platformChatId, input.senderId, input.senderId, input.senderName, input.source, observedAt, observedAt);
3675
+ this.database.prepare(
3676
+ `
3677
+ UPDATE persons
3678
+ SET primary_name = ?, updated_at = ?
3679
+ WHERE id = ?
3680
+ `
3681
+ ).run(input.senderName, observedAt, personId);
3682
+ return findPerson.get(personId);
3683
+ });
3684
+ return transaction();
3685
+ }
3686
+ listPersons() {
3687
+ const rows = this.database.prepare(
3688
+ `
3689
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3690
+ FROM persons
3691
+ ORDER BY updated_at DESC, created_at DESC
3692
+ `
3693
+ ).all();
3694
+ return rows.map(mapPerson);
3695
+ }
3696
+ getPersonProfile(personId, options = {}) {
3697
+ const personRow = 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
+ ).get(personId);
3704
+ if (!personRow) {
3705
+ return void 0;
3706
+ }
3707
+ const includeInferred = options.includeInferred ?? true;
3708
+ const entryRows = this.database.prepare(
3709
+ `
3710
+ SELECT
3711
+ id,
3712
+ person_id AS personId,
3713
+ category,
3714
+ content,
3715
+ entry_type AS entryType,
3716
+ confidence,
3717
+ status,
3718
+ source,
3719
+ created_at AS createdAt,
3720
+ updated_at AS updatedAt,
3721
+ last_observed_at AS lastObservedAt
3722
+ FROM person_profile_entries
3723
+ WHERE person_id = ?
3724
+ AND status = 'active'
3725
+ ${includeInferred ? "" : "AND entry_type = 'fact'"}
3726
+ ORDER BY updated_at DESC, created_at DESC
3727
+ `
3728
+ ).all(personId);
3729
+ const entries = options.includeEvidence ? entryRows.map((entry) => ({ ...entry, evidence: this.getEvidence(entry.id) })) : entryRows;
3730
+ const identities = this.database.prepare(
3731
+ `
3732
+ SELECT
3733
+ platform,
3734
+ platform_chat_id AS platformChatId,
3735
+ external_user_id AS externalUserId,
3736
+ display_name AS displayName,
3737
+ alias,
3738
+ source,
3739
+ first_seen_at AS firstSeenAt,
3740
+ last_seen_at AS lastSeenAt
3741
+ FROM person_identities
3742
+ WHERE person_id = ?
3743
+ ORDER BY last_seen_at DESC, first_seen_at DESC
3744
+ `
3745
+ ).all(personId);
3746
+ return {
3747
+ person: mapPerson(personRow),
3748
+ identities,
3749
+ entries
3750
+ };
3751
+ }
3752
+ upsertProfileEntry(input) {
3753
+ if (input.evidence.length === 0) {
3754
+ throw new Error("Profile entry evidence is required.");
3755
+ }
3756
+ const timestamp = input.observedAt ?? nowIso4();
3757
+ const entryId = createId("profile_entry", [input.personId, input.category, input.content]);
3758
+ const transaction = this.database.transaction(() => {
3759
+ this.database.prepare(
3760
+ `
3761
+ INSERT INTO person_profile_entries (
3762
+ id, person_id, category, content, entry_type, confidence, status, source, created_at, updated_at, last_observed_at
3763
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
3764
+ ON CONFLICT(id) DO UPDATE SET
3765
+ confidence = MAX(person_profile_entries.confidence, excluded.confidence),
3766
+ status = 'active',
3767
+ source = excluded.source,
3768
+ updated_at = excluded.updated_at,
3769
+ last_observed_at = excluded.last_observed_at
3770
+ `
3771
+ ).run(entryId, input.personId, input.category, input.content, input.entryType, input.confidence, input.source, timestamp, timestamp, timestamp);
3772
+ const insertEvidence = this.database.prepare(
3773
+ `
3774
+ INSERT INTO person_profile_evidence (entry_id, message_id, quote, reason)
3775
+ VALUES (?, ?, ?, ?)
3776
+ ON CONFLICT(entry_id, message_id, quote) DO UPDATE SET reason = excluded.reason
3777
+ `
3778
+ );
3779
+ for (const evidence of input.evidence) {
3780
+ insertEvidence.run(entryId, evidence.messageId, evidence.quote, evidence.reason);
3781
+ }
3782
+ });
3783
+ transaction();
3784
+ return entryId;
3785
+ }
3786
+ backfillMessagePersons({ limit }) {
3787
+ const rows = this.database.prepare(
3788
+ `
3789
+ SELECT
3790
+ m.id AS id,
3791
+ m.platform AS platform,
3792
+ c.platform_chat_id AS platformChatId,
3793
+ m.sender_id AS senderId,
3794
+ m.sender_name AS senderName,
3795
+ m.sent_at AS sentAt
3796
+ FROM messages m
3797
+ JOIN chats c ON c.id = m.chat_id
3798
+ WHERE m.person_id IS NULL
3799
+ ORDER BY m.sent_at ASC
3800
+ LIMIT ?
3801
+ `
3802
+ ).all(limit);
3803
+ const update = this.database.prepare("UPDATE messages SET person_id = ? WHERE id = ?");
3804
+ const transaction = this.database.transaction(() => {
3805
+ for (const row of rows) {
3806
+ const person = this.resolvePersonForSender({
3807
+ platform: row.platform,
3808
+ platformChatId: row.platformChatId,
3809
+ senderId: row.senderId,
3810
+ senderName: row.senderName,
3811
+ source: "inferred",
3812
+ observedAt: row.sentAt
3813
+ });
3814
+ update.run(person.id, row.id);
3815
+ }
3816
+ });
3817
+ transaction();
3818
+ return { updatedMessages: rows.length };
3819
+ }
3820
+ getDreamState(platform, platformChatId) {
3821
+ return this.database.prepare(
3822
+ `
3823
+ SELECT
3824
+ platform,
3825
+ platform_chat_id AS platformChatId,
3826
+ last_message_id AS lastMessageId,
3827
+ last_message_sent_at AS lastMessageSentAt,
3828
+ updated_at AS updatedAt
3829
+ FROM profile_dream_state
3830
+ WHERE platform = ? AND platform_chat_id = ?
3831
+ `
3832
+ ).get(platform, platformChatId);
3833
+ }
3834
+ updateDreamState(input) {
3835
+ this.database.prepare(
3836
+ `
3837
+ INSERT INTO profile_dream_state (platform, platform_chat_id, last_message_id, last_message_sent_at, updated_at)
3838
+ VALUES (?, ?, ?, ?, ?)
3839
+ ON CONFLICT(platform, platform_chat_id)
3840
+ DO UPDATE SET
3841
+ last_message_id = excluded.last_message_id,
3842
+ last_message_sent_at = excluded.last_message_sent_at,
3843
+ updated_at = excluded.updated_at
3844
+ `
3845
+ ).run(input.platform, input.platformChatId, input.lastMessageId ?? null, input.lastMessageSentAt ?? null, input.updatedAt);
3846
+ }
3847
+ listMessagesForDream(input) {
3848
+ const afterWhere = input.afterSentAt ? "AND m.sent_at > ?" : "";
3849
+ const params = input.afterSentAt ? [input.platform, input.platformChatId, input.afterSentAt, input.limit] : [input.platform, input.platformChatId, input.limit];
3850
+ return this.database.prepare(
3851
+ `
3852
+ SELECT
3853
+ m.id AS messageId,
3854
+ m.person_id AS personId,
3855
+ m.sender_name AS senderName,
3856
+ m.sent_at AS sentAt,
3857
+ m.text AS text
3858
+ FROM messages m
3859
+ JOIN chats c ON c.id = m.chat_id
3860
+ WHERE m.platform = ?
3861
+ AND c.platform_chat_id = ?
3862
+ AND m.person_id IS NOT NULL
3863
+ ${afterWhere}
3864
+ ORDER BY m.sent_at ASC, m.created_at ASC
3865
+ LIMIT ?
3866
+ `
3867
+ ).all(...params);
3868
+ }
3869
+ listChatsWithPendingDreamMessages() {
3870
+ return this.database.prepare(
3871
+ `
3872
+ SELECT DISTINCT m.platform AS platform, c.platform_chat_id AS platformChatId
3873
+ FROM messages m
3874
+ JOIN chats c ON c.id = m.chat_id
3875
+ LEFT JOIN profile_dream_state pds ON pds.platform = m.platform AND pds.platform_chat_id = c.platform_chat_id
3876
+ WHERE m.person_id IS NOT NULL
3877
+ AND (pds.last_message_sent_at IS NULL OR m.sent_at > pds.last_message_sent_at)
3878
+ ORDER BY c.platform_chat_id ASC
3879
+ `
3880
+ ).all();
3881
+ }
3882
+ recordDreamRun(input) {
3883
+ const id = input.id ?? createId("profile_dream_run", [input.platform, input.platformChatId, input.status, input.startedAt, input.finishedAt, crypto5.randomUUID()]);
3884
+ this.database.prepare(
3885
+ `
3886
+ INSERT INTO profile_dream_runs (
3887
+ id, platform, platform_chat_id, status, processed_message_count, generated_entry_count, error, started_at, finished_at
3888
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3889
+ `
3890
+ ).run(
3891
+ id,
3892
+ input.platform,
3893
+ input.platformChatId,
3894
+ input.status,
3895
+ input.processedMessageCount,
3896
+ input.generatedEntryCount,
3897
+ input.error ?? null,
3898
+ input.startedAt,
3899
+ input.finishedAt
3900
+ );
3901
+ return id;
3902
+ }
3903
+ resolvePersonIdForSender(input) {
3904
+ const platform = input.platform ?? "feishu";
3905
+ const row = this.database.prepare(
3906
+ `
3907
+ SELECT person_id AS personId
3908
+ FROM person_identities
3909
+ WHERE platform = ? AND platform_chat_id = ? AND external_user_id = ?
3910
+ LIMIT 1
3911
+ `
3912
+ ).get(platform, input.platformChatId, input.senderId);
3913
+ return row?.personId;
3914
+ }
3915
+ searchPersonMessages(personId, query, limit, options = {}) {
3916
+ const cleaned = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter(Boolean);
3917
+ const wrapped = cleaned.map((term) => `"${term}"`).join(" ");
3918
+ if (!wrapped) {
3919
+ return [];
3920
+ }
3921
+ const excludedIds = options.excludeMessageIds ?? [];
3922
+ const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
3923
+ const rows = this.database.prepare(
3924
+ `
3925
+ SELECT
3926
+ fts.chunk_id AS chunkId,
3927
+ fts.message_id AS messageId,
3928
+ m.platform AS platform,
3929
+ mc.text AS text,
3930
+ bm25(message_chunks_fts) * -1 AS score,
3931
+ m.message_type AS messageType,
3932
+ c.name AS chatName,
3933
+ m.sender_id AS senderId,
3934
+ m.sender_name AS senderName,
3935
+ m.person_id AS personId,
3936
+ m.sent_at AS sentAt,
3937
+ mc.chunk_index AS chunkIndex
3938
+ FROM message_chunks_fts fts
3939
+ JOIN message_chunks mc ON mc.id = fts.chunk_id
3940
+ JOIN messages m ON m.id = fts.message_id
3941
+ JOIN chats c ON c.id = m.chat_id
3942
+ WHERE message_chunks_fts MATCH ?
3943
+ ${excludedWhere}
3944
+ AND m.person_id = ?
3945
+ ORDER BY bm25(message_chunks_fts), m.sent_at DESC, mc.chunk_index ASC
3946
+ LIMIT ?
3947
+ `
3948
+ ).all(wrapped, ...excludedIds, personId, Math.max(limit * 8, limit));
3949
+ const results = [];
3950
+ const seenMessageIds = /* @__PURE__ */ new Set();
3951
+ for (const row of rows) {
3952
+ if (seenMessageIds.has(row.messageId)) {
3953
+ continue;
3954
+ }
3955
+ seenMessageIds.add(row.messageId);
3956
+ const { chunkIndex: _chunkIndex, ...result } = row;
3957
+ results.push(result);
3958
+ if (results.length >= limit) {
3959
+ break;
3960
+ }
3961
+ }
3962
+ if (results.length > 0) {
3963
+ return results;
3964
+ }
3965
+ const terms = query.split(/[  ]+/).map((term) => term.trim()).filter((term) => term.length > 0);
3966
+ if (terms.length === 0) {
3967
+ return [];
3968
+ }
3969
+ const where = terms.map(() => "mc.text LIKE ? ESCAPE '\\'").join(" OR ");
3970
+ const params = terms.map((term) => `%${term.replace(/[%_]/g, "\\$&")}%`);
3971
+ const likeExcludedWhere = excludedIds.length > 0 ? `AND m.id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
3972
+ return this.database.prepare(
3973
+ `
3974
+ SELECT
3975
+ *
3976
+ FROM (
3977
+ SELECT
3978
+ mc.id AS chunkId,
3979
+ m.id AS messageId,
3980
+ m.platform AS platform,
3981
+ mc.text AS text,
3982
+ 0.1 AS score,
3983
+ m.message_type AS messageType,
3984
+ c.name AS chatName,
3985
+ m.sender_id AS senderId,
3986
+ m.sender_name AS senderName,
3987
+ m.person_id AS personId,
3988
+ m.sent_at AS sentAt,
3989
+ ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY mc.chunk_index ASC) AS rowNumber
3990
+ FROM message_chunks mc
3991
+ JOIN messages m ON m.id = mc.message_id
3992
+ JOIN chats c ON c.id = m.chat_id
3993
+ WHERE (${where})
3994
+ ${likeExcludedWhere}
3995
+ AND m.person_id = ?
3996
+ ) ranked
3997
+ WHERE rowNumber = 1
3998
+ ORDER BY sentAt DESC
3999
+ LIMIT ?
4000
+ `
4001
+ ).all(...params, ...excludedIds, personId, limit);
4002
+ }
4003
+ getProfileEntry(entryId) {
4004
+ const row = this.database.prepare(
4005
+ `
4006
+ SELECT
4007
+ id,
4008
+ person_id AS personId,
4009
+ category,
4010
+ content,
4011
+ entry_type AS entryType,
4012
+ confidence,
4013
+ status,
4014
+ source,
4015
+ created_at AS createdAt,
4016
+ updated_at AS updatedAt,
4017
+ last_observed_at AS lastObservedAt
4018
+ FROM person_profile_entries
4019
+ WHERE id = ?
4020
+ `
4021
+ ).get(entryId);
4022
+ if (!row) {
4023
+ return void 0;
4024
+ }
4025
+ return { ...row, evidence: this.getEvidence(entryId) };
4026
+ }
4027
+ replaceProfileEntry(input) {
4028
+ if (input.input.evidence.length === 0) {
4029
+ throw new Error("Profile entry evidence is required.");
4030
+ }
4031
+ const timestamp = input.input.observedAt ?? nowIso4();
4032
+ const newEntryId = createId("profile_entry", [input.input.personId, input.input.category, input.input.content]);
4033
+ const transaction = this.database.transaction(() => {
4034
+ this.database.prepare(
4035
+ `
4036
+ INSERT INTO person_profile_entries (
4037
+ id, person_id, category, content, entry_type, confidence, status, source, created_at, updated_at, last_observed_at
4038
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
4039
+ `
4040
+ ).run(newEntryId, input.input.personId, input.input.category, input.input.content, input.input.entryType, input.input.confidence, input.input.source, timestamp, timestamp, timestamp);
4041
+ this.database.prepare(
4042
+ `
4043
+ UPDATE person_profile_entries
4044
+ SET status = 'superseded', updated_at = ?
4045
+ WHERE id = ? AND status = 'active'
4046
+ `
4047
+ ).run(timestamp, input.supersedeEntryId);
4048
+ const insertEvidence = this.database.prepare(
4049
+ `
4050
+ INSERT INTO person_profile_evidence (entry_id, message_id, quote, reason)
4051
+ VALUES (?, ?, ?, ?)
4052
+ ON CONFLICT(entry_id, message_id, quote) DO UPDATE SET reason = excluded.reason
4053
+ `
4054
+ );
4055
+ for (const evidence of input.input.evidence) {
4056
+ insertEvidence.run(newEntryId, evidence.messageId, evidence.quote, evidence.reason);
4057
+ }
4058
+ });
4059
+ transaction();
4060
+ return newEntryId;
4061
+ }
4062
+ markProfileEntryDeleted(entryId) {
4063
+ const timestamp = nowIso4();
4064
+ this.database.prepare("UPDATE person_profile_entries SET status = 'deleted', updated_at = ? WHERE id = ?").run(timestamp, entryId);
4065
+ }
4066
+ personExists(personId) {
4067
+ const row = this.database.prepare("SELECT 1 AS existsFlag FROM persons WHERE id = ? LIMIT 1").get(personId);
4068
+ return Boolean(row);
4069
+ }
4070
+ getEvidence(entryId) {
4071
+ return this.database.prepare(
4072
+ `
4073
+ SELECT entry_id AS entryId, message_id AS messageId, quote, reason
4074
+ FROM person_profile_evidence
4075
+ WHERE entry_id = ?
4076
+ ORDER BY message_id ASC, quote ASC
4077
+ `
4078
+ ).all(entryId);
4079
+ }
4080
+ };
4081
+
3368
4082
  // src/rag/indexer.ts
3369
4083
  var EMBEDDING_INDEX_BATCH_SIZE = 64;
3370
4084
  async function indexMessageChunks(input) {
@@ -3452,12 +4166,12 @@ async function processMessagesNow(input) {
3452
4166
  }
3453
4167
 
3454
4168
  // src/multimodal/tasks.ts
3455
- import crypto5 from "crypto";
3456
- function nowIso4() {
4169
+ import crypto6 from "crypto";
4170
+ function nowIso5() {
3457
4171
  return (/* @__PURE__ */ new Date()).toISOString();
3458
4172
  }
3459
4173
  function stableId3(sourceMessageId, imageKey) {
3460
- return crypto5.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
4174
+ return crypto6.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
3461
4175
  }
3462
4176
  function mapRow(row) {
3463
4177
  if (!row) {
@@ -3485,7 +4199,7 @@ var ImageMultimodalTaskRepository = class {
3485
4199
  database;
3486
4200
  enqueue(input) {
3487
4201
  const id = stableId3(input.sourceMessageId, input.imageKey);
3488
- const timestamp = nowIso4();
4202
+ const timestamp = nowIso5();
3489
4203
  this.database.prepare(
3490
4204
  `
3491
4205
  INSERT INTO image_multimodal_tasks (
@@ -3573,7 +4287,7 @@ var ImageMultimodalTaskRepository = class {
3573
4287
  updated_at = @updatedAt
3574
4288
  WHERE id = @id AND status = 'pending'
3575
4289
  `
3576
- ).run({ id, updatedAt: nowIso4() });
4290
+ ).run({ id, updatedAt: nowIso5() });
3577
4291
  if (result.changes === 0) {
3578
4292
  throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A${id}`);
3579
4293
  }
@@ -3589,7 +4303,7 @@ var ImageMultimodalTaskRepository = class {
3589
4303
  updated_at = @updatedAt
3590
4304
  WHERE id = @id
3591
4305
  `
3592
- ).run({ id, derivedMessageId, updatedAt: nowIso4() });
4306
+ ).run({ id, derivedMessageId, updatedAt: nowIso5() });
3593
4307
  return this.requireById(id);
3594
4308
  }
3595
4309
  markSkipped(id, reason) {
@@ -3602,7 +4316,7 @@ var ImageMultimodalTaskRepository = class {
3602
4316
  updated_at = @updatedAt
3603
4317
  WHERE id = @id
3604
4318
  `
3605
- ).run({ id, reason, updatedAt: nowIso4() });
4319
+ ).run({ id, reason, updatedAt: nowIso5() });
3606
4320
  return this.requireById(id);
3607
4321
  }
3608
4322
  markFailed(id, error, finalFailure) {
@@ -3615,7 +4329,7 @@ var ImageMultimodalTaskRepository = class {
3615
4329
  updated_at = @updatedAt
3616
4330
  WHERE id = @id
3617
4331
  `
3618
- ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso4() });
4332
+ ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso5() });
3619
4333
  return this.requireById(id);
3620
4334
  }
3621
4335
  getById(id) {
@@ -3897,7 +4611,7 @@ function createFeishuChatMembersClient(client) {
3897
4611
  }
3898
4612
 
3899
4613
  // src/rag/qa-logs.ts
3900
- import crypto6 from "crypto";
4614
+ import crypto7 from "crypto";
3901
4615
 
3902
4616
  // src/rag/qa-trace.ts
3903
4617
  function hasQaTrace(trace) {
@@ -3937,7 +4651,7 @@ var QaLogRepository = class {
3937
4651
  create(input) {
3938
4652
  const trace = input.trace ?? {};
3939
4653
  const record = {
3940
- id: `qa_${crypto6.randomUUID()}`,
4654
+ id: `qa_${crypto7.randomUUID()}`,
3941
4655
  chatId: input.chatId ?? null,
3942
4656
  questionMessageId: input.questionMessageId ?? null,
3943
4657
  question: input.question,
@@ -4117,13 +4831,13 @@ ${block.text}`;
4117
4831
  function toToolErrorContent(message) {
4118
4832
  return JSON.stringify({ ok: false, error: message });
4119
4833
  }
4120
- function nowIso5() {
4834
+ function nowIso6() {
4121
4835
  return (/* @__PURE__ */ new Date()).toISOString();
4122
4836
  }
4123
4837
  function finalizeTrace(trace, status, finalAnswer, startedAtMs) {
4124
4838
  return {
4125
4839
  ...trace,
4126
- completedAt: nowIso5(),
4840
+ completedAt: nowIso6(),
4127
4841
  durationMs: Date.now() - startedAtMs,
4128
4842
  status,
4129
4843
  finalAnswer
@@ -4180,11 +4894,11 @@ async function runFeishuToolLoop(input) {
4180
4894
  content: assistantResult.content,
4181
4895
  reasoningContent: assistantResult.reasoningContent,
4182
4896
  toolCalls: assistantResult.toolCalls,
4183
- createdAt: nowIso5()
4897
+ createdAt: nowIso6()
4184
4898
  });
4185
4899
  if (assistantResult.toolCalls.length === 0) {
4186
4900
  if (hasRawToolCallMarkup) {
4187
- 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() });
4901
+ 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() });
4188
4902
  break;
4189
4903
  }
4190
4904
  const answer = assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
@@ -4192,7 +4906,7 @@ async function runFeishuToolLoop(input) {
4192
4906
  }
4193
4907
  for (const toolCall of assistantResult.toolCalls) {
4194
4908
  if (toolCallsUsed >= maxToolCalls) {
4195
- trace.fallbacks?.push({ type: "tool_limit", message: FEISHU_TOOL_LOOP_LIMIT_REACHED, createdAt: nowIso5() });
4909
+ trace.fallbacks?.push({ type: "tool_limit", message: FEISHU_TOOL_LOOP_LIMIT_REACHED, createdAt: nowIso6() });
4196
4910
  return { answer: FEISHU_TOOL_LOOP_LIMIT_REACHED, trace: finalizeTrace(trace, "failed", FEISHU_TOOL_LOOP_LIMIT_REACHED, startedAtMs) };
4197
4911
  }
4198
4912
  toolCallsUsed += 1;
@@ -4203,7 +4917,7 @@ async function runFeishuToolLoop(input) {
4203
4917
  name: toolCall.name,
4204
4918
  input: toolCall.input,
4205
4919
  error: `\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`,
4206
- createdAt: nowIso5()
4920
+ createdAt: nowIso6()
4207
4921
  });
4208
4922
  messages.push({
4209
4923
  role: "tool",
@@ -4219,7 +4933,7 @@ async function runFeishuToolLoop(input) {
4219
4933
  name: toolCall.name,
4220
4934
  input: toolCall.input,
4221
4935
  content: result,
4222
- createdAt: nowIso5()
4936
+ createdAt: nowIso6()
4223
4937
  });
4224
4938
  messages.push({
4225
4939
  role: "tool",
@@ -4233,7 +4947,7 @@ async function runFeishuToolLoop(input) {
4233
4947
  name: toolCall.name,
4234
4948
  input: toolCall.input,
4235
4949
  error: message,
4236
- createdAt: nowIso5()
4950
+ createdAt: nowIso6()
4237
4951
  });
4238
4952
  messages.push({
4239
4953
  role: "tool",
@@ -4249,11 +4963,11 @@ async function runFeishuToolLoop(input) {
4249
4963
  { 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" }
4250
4964
  ]);
4251
4965
  const answer = salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4252
- 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() });
4966
+ 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() });
4253
4967
  return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4254
4968
  } catch {
4255
4969
  const answer = "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4256
- trace.fallbacks?.push({ type: "answer_generation_failed", message: answer, createdAt: nowIso5() });
4970
+ trace.fallbacks?.push({ type: "answer_generation_failed", message: answer, createdAt: nowIso6() });
4257
4971
  return { answer, trace: finalizeTrace(trace, "failed", answer, startedAtMs) };
4258
4972
  }
4259
4973
  }
@@ -4360,7 +5074,8 @@ var FeishuQuestionHandler = class {
4360
5074
  secrets: this.options.secrets,
4361
5075
  database: this.options.database,
4362
5076
  messages: new MessageRepository(this.options.database),
4363
- excludeMessageIds: options.excludeMessageIds
5077
+ excludeMessageIds: options.excludeMessageIds,
5078
+ profileTools: createPersonProfileTools({ profiles: new ProfileRepository(this.options.database) })
4364
5079
  });
4365
5080
  try {
4366
5081
  try {
@@ -4837,7 +5552,8 @@ function createFeishuGateway(options) {
4837
5552
  secrets: options.secrets,
4838
5553
  database: options.cronJobProcessor.database,
4839
5554
  messages: new MessageRepository(options.cronJobProcessor.database),
4840
- scope: { platform: "feishu", platformChatId: job.chatId }
5555
+ scope: { platform: "feishu", platformChatId: job.chatId },
5556
+ profileTools: createPersonProfileTools({ profiles: new ProfileRepository(options.cronJobProcessor.database) })
4841
5557
  });
4842
5558
  try {
4843
5559
  const memberPrompt = formatFeishuMemberPrompt(
@@ -5097,7 +5813,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
5097
5813
  };
5098
5814
 
5099
5815
  // src/files/ingest.ts
5100
- import crypto7 from "crypto";
5816
+ import crypto8 from "crypto";
5101
5817
  import fs12 from "fs/promises";
5102
5818
  import path15 from "path";
5103
5819
 
@@ -5161,7 +5877,7 @@ function ensureSupportedTextFile(filePath) {
5161
5877
  }
5162
5878
  }
5163
5879
  function stableStoredName(sourcePath, fileName) {
5164
- const digest = crypto7.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
5880
+ const digest = crypto8.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
5165
5881
  return `${digest}-${fileName}`;
5166
5882
  }
5167
5883
  async function ingestLocalFile(input) {
@@ -5261,16 +5977,32 @@ function isMultimodalReady(config, secrets) {
5261
5977
  return Boolean(config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey);
5262
5978
  }
5263
5979
  var GatewayIngestor = class {
5264
- constructor(database) {
5980
+ constructor(database, options = {}) {
5265
5981
  this.database = database;
5982
+ this.options = options;
5266
5983
  this.messages = new MessageRepository(database);
5267
5984
  this.jobs = new FileJobRepository(database);
5268
5985
  this.imageTasks = new ImageMultimodalTaskRepository(database);
5269
5986
  }
5270
5987
  database;
5988
+ options;
5271
5989
  messages;
5272
5990
  jobs;
5273
5991
  imageTasks;
5992
+ enrichWithPerson(input) {
5993
+ const person = this.options.profiles?.resolvePersonForSender({
5994
+ platform: input.platform,
5995
+ platformChatId: input.platformChatId,
5996
+ senderId: input.senderId,
5997
+ senderName: input.senderName,
5998
+ source: "message",
5999
+ observedAt: input.sentAt
6000
+ });
6001
+ return {
6002
+ ...input,
6003
+ personId: person?.id
6004
+ };
6005
+ }
5274
6006
  ingestFeishuEvent(payload) {
5275
6007
  const normalized = normalizeFeishuReceiveMessageEvent(payload);
5276
6008
  if (!normalized) {
@@ -5279,12 +6011,13 @@ var GatewayIngestor = class {
5279
6011
  reason: "\u4E8B\u4EF6\u4E0D\u662F\u53EF\u5165\u5E93\u7684\u98DE\u4E66\u6D88\u606F\u3002"
5280
6012
  };
5281
6013
  }
5282
- const duplicate = this.messages.hasPlatformMessage(normalized.platform, normalized.platformMessageId);
5283
- const messageId = this.messages.ingest(normalized);
6014
+ const enriched = this.enrichWithPerson(normalized);
6015
+ const duplicate = this.messages.hasPlatformMessage(enriched.platform, enriched.platformMessageId);
6016
+ const messageId = this.messages.ingest(enriched);
5284
6017
  return {
5285
6018
  accepted: true,
5286
6019
  messageId,
5287
- message: normalized,
6020
+ message: enriched,
5288
6021
  duplicate
5289
6022
  };
5290
6023
  }
@@ -5298,7 +6031,7 @@ var GatewayIngestor = class {
5298
6031
  }
5299
6032
  const openId = extractFeishuSenderOpenId(normalized);
5300
6033
  const senderName = openId ? await input.memberResolver.resolveOpenIdName(normalized.platformChatId, openId) : normalized.senderName;
5301
- const enriched = { ...normalized, senderName };
6034
+ const enriched = this.enrichWithPerson({ ...normalized, senderName });
5302
6035
  const duplicate = this.messages.hasPlatformMessage(enriched.platform, enriched.platformMessageId);
5303
6036
  const messageId = this.messages.ingest(enriched);
5304
6037
  return {
@@ -5526,7 +6259,7 @@ var MemoryVectorStore = class {
5526
6259
  };
5527
6260
 
5528
6261
  // src/web/server.ts
5529
- import crypto8 from "crypto";
6262
+ import crypto9 from "crypto";
5530
6263
  import Fastify from "fastify";
5531
6264
  function buildHtml() {
5532
6265
  return `<!doctype html>
@@ -5868,6 +6601,10 @@ function buildHtml() {
5868
6601
  <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>
5869
6602
  <span>\u95EE\u7B54\u65E5\u5FD7</span>
5870
6603
  </button>
6604
+ <button class="nav-item" data-view="persons">
6605
+ <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>
6606
+ <span>\u4E2A\u4EBA\u6863\u6848</span>
6607
+ </button>
5871
6608
  <button class="nav-item" data-view="settings">
5872
6609
  <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>
5873
6610
  <span>\u8BBE\u7F6E</span>
@@ -5899,6 +6636,10 @@ function buildHtml() {
5899
6636
  <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>
5900
6637
  <span>\u4EFB\u52A1</span>
5901
6638
  </button>
6639
+ <button class="mobile-nav-item" data-view="persons">
6640
+ <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>
6641
+ <span>\u6863\u6848</span>
6642
+ </button>
5902
6643
  <button class="mobile-nav-item" data-view="settings">
5903
6644
  <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>
5904
6645
  <span>\u8BBE\u7F6E</span>
@@ -5995,6 +6736,18 @@ function buildHtml() {
5995
6736
  <div class="content-panel glass"><div id="qa-logs-list"></div></div>
5996
6737
  </div>
5997
6738
 
6739
+ <div class="view" id="view-persons">
6740
+ <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>
6741
+ <div class="content-panel glass" id="persons-list-panel"><div id="persons-list"></div></div>
6742
+ <div class="content-panel glass mt-lg" id="person-profile-panel" style="display:none;">
6743
+ <div class="panel-header">
6744
+ <h2 class="panel-title" id="person-profile-name"></h2>
6745
+ <button class="btn btn-sm" onclick="navigateTo('persons')">\u8FD4\u56DE\u5217\u8868</button>
6746
+ </div>
6747
+ <div id="person-profile-detail"></div>
6748
+ </div>
6749
+ </div>
6750
+
5998
6751
  <div class="view" id="view-settings">
5999
6752
  <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>
6000
6753
  <div class="settings-group glass" id="settings-config"></div>
@@ -6030,7 +6783,8 @@ function buildHtml() {
6030
6783
  let allFileJobs = [];
6031
6784
  let allCronJobs = [];
6032
6785
  let allQaLogs = [];
6033
- let selectedQaLogId = null;
6786
+ let allPersons = [];
6787
+ let selectedPersonId = null;
6034
6788
  let statusData = null;
6035
6789
 
6036
6790
  function fmt(value) { return value == null || value === "" ? "-" : String(value); }
@@ -6074,6 +6828,7 @@ function buildHtml() {
6074
6828
  if (view === "files") renderFilesView();
6075
6829
  if (view === "tasks") renderTasksView();
6076
6830
  if (view === "qa-logs") renderQaLogsView();
6831
+ if (view === "persons") renderPersonsView();
6077
6832
  }
6078
6833
 
6079
6834
  document.querySelectorAll(".nav-item, .mobile-nav-item").forEach(function(el) {
@@ -6116,6 +6871,55 @@ function buildHtml() {
6116
6871
  return result;
6117
6872
  }
6118
6873
 
6874
+ function toJsArgument(value) {
6875
+ return escapeHtml(JSON.stringify(fmt(value)));
6876
+ }
6877
+
6878
+ function findEntryEvidenceMessage(entry) {
6879
+ var evidence = entry.evidence || [];
6880
+ return evidence.length > 0 ? evidence[0].messageId : "";
6881
+ }
6882
+
6883
+ function findEntryEvidenceQuote(entry) {
6884
+ var evidence = entry.evidence || [];
6885
+ return evidence.length > 0 ? evidence[0].quote : "";
6886
+ }
6887
+
6888
+ async function correctPersonProfileEntry(personId, entryId, category, content, entryType, confidence, evidenceMessageId, quote) {
6889
+ var nextContent = prompt("\u4FEE\u6B63\u6863\u6848\u5185\u5BB9", content);
6890
+ if (!nextContent || !nextContent.trim()) return;
6891
+ 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";
6892
+ try {
6893
+ await postJson("/api/persons/" + encodeURIComponent(personId) + "/profile/entries/" + encodeURIComponent(entryId) + "/correct", {
6894
+ headers: { "content-type": "application/json" },
6895
+ body: JSON.stringify({
6896
+ category: category,
6897
+ content: nextContent.trim(),
6898
+ entryType: entryType,
6899
+ confidence: confidence,
6900
+ evidenceMessageId: evidenceMessageId,
6901
+ quote: quote || content,
6902
+ reason: reason
6903
+ })
6904
+ });
6905
+ showToast("\u6863\u6848\u6761\u76EE\u5DF2\u4FEE\u6B63", "success");
6906
+ await showPersonProfile(personId);
6907
+ } catch (error) {
6908
+ showToast(error instanceof Error ? error.message : String(error), "error");
6909
+ }
6910
+ }
6911
+
6912
+ async function deletePersonProfileEntry(personId, entryId) {
6913
+ if (!confirm("\u786E\u8BA4\u5220\u9664\u8FD9\u6761\u6863\u6848\u6761\u76EE\uFF1F")) return;
6914
+ try {
6915
+ await deleteJson("/api/persons/" + encodeURIComponent(personId) + "/profile/entries/" + encodeURIComponent(entryId));
6916
+ showToast("\u6863\u6848\u6761\u76EE\u5DF2\u5220\u9664", "success");
6917
+ await showPersonProfile(personId);
6918
+ } catch (error) {
6919
+ showToast(error instanceof Error ? error.message : String(error), "error");
6920
+ }
6921
+ }
6922
+
6119
6923
  function renderMetrics(status) {
6120
6924
  var gatewayClass = status.gateway.configured ? "status-dot online" : "status-dot offline";
6121
6925
  var gatewayText = status.gateway.connection === "running" ? "\u8FD0\u884C\u4E2D" : (!status.gateway.configured ? "\u672A\u914D\u7F6E" : "\u5F85\u542F\u52A8");
@@ -6377,6 +7181,96 @@ function buildHtml() {
6377
7181
  container.innerHTML = html;
6378
7182
  }
6379
7183
 
7184
+ function renderPersonsView() {
7185
+ var listEl = document.getElementById("persons-list");
7186
+ var profilePanel = document.getElementById("person-profile-panel");
7187
+ if (profilePanel) profilePanel.style.display = "none";
7188
+ if (!allPersons || allPersons.length === 0) {
7189
+ 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>';
7190
+ return;
7191
+ }
7192
+ 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>';
7193
+ for (var i = 0; i < allPersons.length; i++) {
7194
+ var p = allPersons[i];
7195
+ html += '<tr data-view-person="' + escapeHtml(p.id) + '" style="cursor:pointer;">' +
7196
+ '<td><span style="font-weight:500;">' + escapeHtml(p.primaryName) + '</span></td>' +
7197
+ '<td>' + escapeHtml(p.profileEntryCount) + '</td>' +
7198
+ '<td>' + escapeHtml(p.messageCount) + '</td></tr>';
7199
+ }
7200
+ html += '</tbody></table>';
7201
+ listEl.innerHTML = html;
7202
+ }
7203
+
7204
+ async function showPersonProfile(id) {
7205
+ selectedPersonId = id;
7206
+ var listEl = document.getElementById("persons-list-panel");
7207
+ var profilePanel = document.getElementById("person-profile-panel");
7208
+ var detailEl = document.getElementById("person-profile-detail");
7209
+ if (listEl) listEl.style.display = "none";
7210
+ if (profilePanel) profilePanel.style.display = "block";
7211
+ detailEl.innerHTML = '<div class="empty-state">\u6B63\u5728\u52A0\u8F7D\u4E2A\u4EBA\u6863\u6848...</div>';
7212
+ try {
7213
+ var profile = await fetchJson("/api/persons/" + encodeURIComponent(id) + "/profile");
7214
+ var messages = await fetchJson("/api/persons/" + encodeURIComponent(id) + "/messages?limit=50");
7215
+ renderPersonProfile(id, profile, messages.items || []);
7216
+ } catch (error) {
7217
+ detailEl.innerHTML = '<div class="empty-state">\u52A0\u8F7D\u5931\u8D25\uFF1A' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
7218
+ }
7219
+ }
7220
+
7221
+ function renderPersonProfile(id, profile, messages) {
7222
+ var el = document.getElementById("person-profile-detail");
7223
+ var nameEl = document.getElementById("person-profile-name");
7224
+ if (nameEl) nameEl.textContent = profile.person.primaryName;
7225
+ var html = '';
7226
+ var identities = profile.identities || [];
7227
+ if (identities.length > 0) {
7228
+ html += '<div class="mb-md"><span style="color:var(--text-muted);font-size:13px;">\u8EAB\u4EFD\uFF1A</span> ';
7229
+ html += identities.map(function(id) { return escapeHtml(id.displayName) + ' (' + escapeHtml(id.platform) + ')'; }).join('\uFF0C');
7230
+ html += '</div>';
7231
+ }
7232
+ var entries = profile.entries || [];
7233
+ if (entries.length === 0) {
7234
+ html += '<div class="empty-state">\u8FD8\u6CA1\u6709\u6863\u6848\u6761\u76EE\u3002</div>';
7235
+ } else {
7236
+ html += '<div class="grid-2">';
7237
+ for (var i = 0; i < entries.length; i++) {
7238
+ var entry = entries[i];
7239
+ var evidence = entry.evidence || [];
7240
+ html += '<div class="content-panel" style="background:rgba(255,255,255,0.03);padding:var(--space-md);">' +
7241
+ '<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm);align-items:center;flex-wrap:wrap;">' +
7242
+ '<span class="tag">' + escapeHtml(entry.category) + '</span>' +
7243
+ '<span class="tag ' + (entry.entryType === 'fact' ? 'tag-success' : 'tag-info') + '">' + escapeHtml(entry.entryType) + '</span>' +
7244
+ '<span style="font-size:12px;color:var(--text-muted);">' + escapeHtml(Math.round(entry.confidence * 100)) + '%</span>' +
7245
+ '<span style="flex:1;"></span>' +
7246
+ '<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>' +
7247
+ '<button class="btn btn-sm btn-danger" onclick="deletePersonProfileEntry(' + toJsArgument(id) + ',' + toJsArgument(entry.id) + ')">\u5220\u9664</button>' +
7248
+ '</div>' +
7249
+ '<div style="font-weight:500;margin-bottom:var(--space-xs);">' + escapeHtml(entry.content) + '</div>';
7250
+ if (evidence.length > 0) {
7251
+ html += '<div style="font-size:12px;color:var(--text-muted);">\u8BC1\u636E\uFF1A' +
7252
+ evidence.map(function(e) { return '<span style="display:block;margin-top:2px;">"' + escapeHtml(e.quote) + '" (' + escapeHtml(e.reason) + ')</span>'; }).join('') +
7253
+ '</div>';
7254
+ }
7255
+ html += '</div>';
7256
+ }
7257
+ html += '</div>';
7258
+ }
7259
+ if (messages && messages.length > 0) {
7260
+ html += '<h3 style="margin:var(--space-lg) 0 var(--space-sm);font-size:16px;">\u6700\u8FD1\u6D88\u606F</h3>';
7261
+ html += '<div class="message-list">';
7262
+ for (var j = 0; j < Math.min(messages.length, 10); j++) {
7263
+ var msg = messages[j];
7264
+ html += '<div class="message-card"><div class="message-meta">' +
7265
+ '<span>' + escapeHtml(formatDateTime(msg.sentAt)) + '</span>' +
7266
+ '<span>' + escapeHtml(displayChatName(msg.chatName, msg.platform)) + '</span>' +
7267
+ '</div><div class="message-text">' + escapeHtml(msg.text) + '</div></div>';
7268
+ }
7269
+ html += '</div>';
7270
+ }
7271
+ el.innerHTML = html;
7272
+ }
7273
+
6380
7274
  function renderSettings(status) {
6381
7275
  var el = document.getElementById("settings-config");
6382
7276
  var html = '<h3 style="font-size:16px;font-weight:600;margin-bottom:var(--space-md);">\u7CFB\u7EDF\u914D\u7F6E</h3>';
@@ -6407,6 +7301,7 @@ function buildHtml() {
6407
7301
  await loadSection("/api/file-jobs", function(data) { allFileJobs = data.items || []; });
6408
7302
  await loadSection("/api/qa-logs?limit=20", function(data) { allQaLogs = data.items || []; });
6409
7303
  await loadSection("/api/cron-jobs", function(data) { allCronJobs = data.items || []; });
7304
+ await loadSection("/api/persons", function(data) { allPersons = data.items || []; });
6410
7305
  if (currentView === "messages") renderMessagesView();
6411
7306
  if (currentView === "episodes") renderEpisodesView();
6412
7307
  if (currentView === "files") renderFilesView();
@@ -6415,6 +7310,10 @@ function buildHtml() {
6415
7310
  renderQaLogsView();
6416
7311
  if (selectedQaLogId) void showQaLogDetail(selectedQaLogId);
6417
7312
  }
7313
+ if (currentView === "persons") {
7314
+ renderPersonsView();
7315
+ if (selectedPersonId) void showPersonProfile(selectedPersonId);
7316
+ }
6418
7317
  }
6419
7318
 
6420
7319
  async function processNow() {
@@ -6441,6 +7340,11 @@ function buildHtml() {
6441
7340
  void showQaLogDetail(qaLogId);
6442
7341
  return;
6443
7342
  }
7343
+ var personId = target.dataset.viewPerson || target.closest('[data-view-person]')?.dataset.viewPerson;
7344
+ if (personId) {
7345
+ void showPersonProfile(personId);
7346
+ return;
7347
+ }
6444
7348
  var id = target.dataset.deleteCronJob;
6445
7349
  if (!id) return;
6446
7350
  target.disabled = true;
@@ -6460,7 +7364,7 @@ function buildHtml() {
6460
7364
  </body>
6461
7365
  </html>`;
6462
7366
  }
6463
- function parseLimit(value, fallback, max) {
7367
+ function parseLimit2(value, fallback, max) {
6464
7368
  const rawLimit = Number(value ?? fallback);
6465
7369
  return Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), max) : fallback;
6466
7370
  }
@@ -6484,6 +7388,16 @@ function parseCookies(header) {
6484
7388
  function isAuthorizedWebAction(request, token) {
6485
7389
  return parseCookies(request.headers.cookie).chattercatcher_web_token === token;
6486
7390
  }
7391
+ function readStringField(input, key) {
7392
+ if (!input || typeof input !== "object" || Array.isArray(input)) return void 0;
7393
+ const value = input[key];
7394
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
7395
+ }
7396
+ function readNumberField(input, key, fallback) {
7397
+ if (!input || typeof input !== "object" || Array.isArray(input)) return fallback;
7398
+ const value = input[key];
7399
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
7400
+ }
6487
7401
  function toQaLogListItem(log) {
6488
7402
  const { trace: _trace, ...item } = log;
6489
7403
  return item;
@@ -6497,11 +7411,12 @@ function createWebApp(config, options = {}) {
6497
7411
  const fileJobs = new FileJobRepository(database);
6498
7412
  const qaLogs = new QaLogRepository(database);
6499
7413
  const cronJobs = new CronJobRepository(database);
7414
+ const profiles = new ProfileRepository(database);
6500
7415
  let webActionToken = "";
6501
7416
  const tokenReady = (async () => {
6502
7417
  const secrets = await loadSecrets();
6503
7418
  if (!secrets.web.actionToken) {
6504
- secrets.web.actionToken = crypto8.randomBytes(32).toString("hex");
7419
+ secrets.web.actionToken = crypto9.randomBytes(32).toString("hex");
6505
7420
  await saveSecrets(secrets);
6506
7421
  }
6507
7422
  webActionToken = getWebActionToken(secrets);
@@ -6539,32 +7454,32 @@ function createWebApp(config, options = {}) {
6539
7454
  items: messages.listChats()
6540
7455
  }));
6541
7456
  app.get("/api/files", async (request) => {
6542
- const limit = parseLimit(request.query.limit, 50, 200);
7457
+ const limit = parseLimit2(request.query.limit, 50, 200);
6543
7458
  return {
6544
7459
  items: messages.listFiles(limit)
6545
7460
  };
6546
7461
  });
6547
7462
  app.get("/api/file-jobs", async (request) => {
6548
- const limit = parseLimit(request.query.limit, 50, 200);
7463
+ const limit = parseLimit2(request.query.limit, 50, 200);
6549
7464
  const status = request.query.status;
6550
7465
  return {
6551
7466
  items: fileJobs.list(limit, status === "processing" || status === "indexed" || status === "failed" ? { status } : {})
6552
7467
  };
6553
7468
  });
6554
7469
  app.get("/api/messages/recent", async (request) => {
6555
- const limit = parseLimit(request.query.limit, 20, 100);
7470
+ const limit = parseLimit2(request.query.limit, 20, 100);
6556
7471
  return {
6557
7472
  items: messages.listRecentMessages(limit)
6558
7473
  };
6559
7474
  });
6560
7475
  app.get("/api/episodes", async (request) => {
6561
- const limit = parseLimit(request.query.limit, 20, 100);
7476
+ const limit = parseLimit2(request.query.limit, 20, 100);
6562
7477
  return {
6563
7478
  items: episodes.listRecentEpisodes(limit)
6564
7479
  };
6565
7480
  });
6566
7481
  app.get("/api/qa-logs", async (request) => {
6567
- const limit = parseLimit(request.query.limit, 20, 100);
7482
+ const limit = parseLimit2(request.query.limit, 20, 100);
6568
7483
  return {
6569
7484
  items: qaLogs.listRecent(limit).map(toQaLogListItem)
6570
7485
  };
@@ -6579,7 +7494,7 @@ function createWebApp(config, options = {}) {
6579
7494
  return log;
6580
7495
  });
6581
7496
  app.get("/api/cron-jobs", async (request) => {
6582
- const limit = parseLimit(request.query.limit, 50, 200);
7497
+ const limit = parseLimit2(request.query.limit, 50, 200);
6583
7498
  return {
6584
7499
  items: cronJobs.list(limit)
6585
7500
  };
@@ -6599,6 +7514,105 @@ function createWebApp(config, options = {}) {
6599
7514
  const ok = cronJobs.deleteByChat(id, job.chatId);
6600
7515
  return { ok };
6601
7516
  });
7517
+ app.get("/api/persons", async (_request) => {
7518
+ const persons = profiles.listPersons();
7519
+ const items = persons.map((person) => {
7520
+ const profile = profiles.getPersonProfile(person.id, { includeEvidence: false, includeInferred: true });
7521
+ return {
7522
+ id: person.id,
7523
+ primaryName: person.primaryName,
7524
+ profileEntryCount: profile?.entries.length ?? 0,
7525
+ messageCount: database.prepare(
7526
+ "SELECT COUNT(1) AS count FROM messages WHERE person_id = ?"
7527
+ ).get(person.id).count
7528
+ };
7529
+ });
7530
+ return { items };
7531
+ });
7532
+ app.get("/api/persons/:id/profile", async (request, reply) => {
7533
+ const id = request.params.id;
7534
+ const profile = profiles.getPersonProfile(id, { includeEvidence: true, includeInferred: true });
7535
+ if (!profile) {
7536
+ reply.code(404);
7537
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6210\u5458\u3002" };
7538
+ }
7539
+ return profile;
7540
+ });
7541
+ app.get("/api/persons/:id/messages", async (request) => {
7542
+ const id = request.params.id;
7543
+ const limit = parseLimit2(request.query.limit, 50, 200);
7544
+ const rows = database.prepare(
7545
+ `SELECT m.id, m.platform, m.platform_message_id AS platformMessageId, m.sender_id AS senderId,
7546
+ m.sender_name AS senderName, m.message_type AS messageType, m.text, m.sent_at AS sentAt,
7547
+ c.name AS chatName, c.platform_chat_id AS platformChatId
7548
+ FROM messages m
7549
+ JOIN chats c ON c.id = m.chat_id
7550
+ WHERE m.person_id = ?
7551
+ ORDER BY m.sent_at DESC, m.created_at DESC
7552
+ LIMIT ?`
7553
+ ).all(id, limit);
7554
+ return { items: rows };
7555
+ });
7556
+ app.post("/api/persons/:id/profile/entries/:entryId/correct", async (request, reply) => {
7557
+ await tokenReady;
7558
+ if (!isAuthorizedWebAction(request, webActionToken)) {
7559
+ reply.code(403);
7560
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
7561
+ }
7562
+ const { id, entryId } = request.params;
7563
+ const existing = profiles.getProfileEntry(entryId);
7564
+ if (!existing || existing.personId !== id || existing.status !== "active") {
7565
+ reply.code(404);
7566
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6863\u6848\u6761\u76EE\u3002" };
7567
+ }
7568
+ const body = request.body;
7569
+ const category = readStringField(body, "category") ?? existing.category;
7570
+ const content = readStringField(body, "content");
7571
+ const entryType = readStringField(body, "entryType") ?? existing.entryType;
7572
+ const evidenceMessageId = readStringField(body, "evidenceMessageId");
7573
+ const quote = readStringField(body, "quote");
7574
+ const reason = readStringField(body, "reason") ?? "\u7528\u6237\u5728 Web UI \u663E\u5F0F\u4FEE\u6B63";
7575
+ if (!content || !evidenceMessageId || !quote) {
7576
+ reply.code(400);
7577
+ return { ok: false, message: "\u4FEE\u6B63\u5FC5\u987B\u5305\u542B content\u3001evidenceMessageId \u548C quote\u3002" };
7578
+ }
7579
+ if (entryType !== "fact" && entryType !== "inferred") {
7580
+ reply.code(400);
7581
+ return { ok: false, message: "entryType \u5FC5\u987B\u662F fact \u6216 inferred\u3002" };
7582
+ }
7583
+ if (category === existing.category && content === existing.content) {
7584
+ reply.code(400);
7585
+ return { ok: false, message: "\u4FEE\u6B63\u5185\u5BB9\u6CA1\u6709\u53D8\u5316\u3002" };
7586
+ }
7587
+ const entryIdNew = profiles.replaceProfileEntry({
7588
+ supersedeEntryId: entryId,
7589
+ input: {
7590
+ personId: id,
7591
+ category,
7592
+ content,
7593
+ entryType,
7594
+ confidence: Math.min(1, Math.max(0, readNumberField(body, "confidence", existing.confidence))),
7595
+ source: "explicit_user_request",
7596
+ evidence: [{ messageId: evidenceMessageId, quote, reason }]
7597
+ }
7598
+ });
7599
+ return { ok: true, entryId: entryIdNew };
7600
+ });
7601
+ app.delete("/api/persons/:id/profile/entries/:entryId", async (request, reply) => {
7602
+ await tokenReady;
7603
+ if (!isAuthorizedWebAction(request, webActionToken)) {
7604
+ reply.code(403);
7605
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
7606
+ }
7607
+ const { id, entryId } = request.params;
7608
+ const existing = profiles.getProfileEntry(entryId);
7609
+ if (!existing || existing.personId !== id || existing.status !== "active") {
7610
+ reply.code(404);
7611
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6863\u6848\u6761\u76EE\u3002" };
7612
+ }
7613
+ profiles.markProfileEntryDeleted(entryId);
7614
+ return { ok: true };
7615
+ });
6602
7616
  app.post("/api/process/messages", async (request, reply) => {
6603
7617
  await tokenReady;
6604
7618
  if (!isAuthorizedWebAction(request, webActionToken)) {