libero3 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/COLD_START.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +43 -0
  4. package/data/migrations/001_initial_schema.sql +43 -0
  5. package/data/migrations/002_seed_categories.sql +18 -0
  6. package/data/migrations/003_add_rating.sql +4 -0
  7. package/data/migrations/004_timer_sessions.sql +21 -0
  8. package/data/migrations/005_profile_settings.sql +5 -0
  9. package/data/migrations/006_add_timeblock_status.sql +1 -0
  10. package/data/migrations/007_reminders.sql +17 -0
  11. package/data/migrations/008_timer_target_ms.sql +5 -0
  12. package/data/migrations/009_tasks.sql +22 -0
  13. package/data/migrations/010_timer_task_id.sql +4 -0
  14. package/data/migrations/011_profile_default_confirmed.sql +4 -0
  15. package/data/migrations/012_identity_tables.sql +68 -0
  16. package/data/migrations/013_identity_managed_by.sql +3 -0
  17. package/data/migrations/014_task_difficulty_priority.sql +6 -0
  18. package/data/migrations/015_timeblock_asked_at.sql +5 -0
  19. package/data/migrations/016_intent_columns.sql +8 -0
  20. package/data/migrations/017_ap_wallet.sql +25 -0
  21. package/data/migrations/018_ap_rewards.sql +21 -0
  22. package/data/migrations/019_ap_bounty.sql +10 -0
  23. package/data/migrations/020_chat_turns.sql +10 -0
  24. package/data/migrations/021_portrait.sql +4 -0
  25. package/data/migrations/022_family_emoji_single.sql +3 -0
  26. package/data/migrations/023_ap_balance_view_log.sql +9 -0
  27. package/data/schema.sql +75 -0
  28. package/dist/data/db.js +83 -0
  29. package/dist/data/migrations/001_initial_schema.sql +43 -0
  30. package/dist/data/migrations/002_seed_categories.sql +18 -0
  31. package/dist/data/migrations/003_add_rating.sql +4 -0
  32. package/dist/data/migrations/004_timer_sessions.sql +21 -0
  33. package/dist/data/migrations/005_profile_settings.sql +5 -0
  34. package/dist/data/migrations/006_add_timeblock_status.sql +1 -0
  35. package/dist/data/migrations/007_reminders.sql +17 -0
  36. package/dist/data/migrations/008_timer_target_ms.sql +5 -0
  37. package/dist/data/migrations/009_tasks.sql +22 -0
  38. package/dist/data/migrations/010_timer_task_id.sql +4 -0
  39. package/dist/data/migrations/011_profile_default_confirmed.sql +4 -0
  40. package/dist/data/migrations/012_identity_tables.sql +68 -0
  41. package/dist/data/migrations/013_identity_managed_by.sql +3 -0
  42. package/dist/data/migrations/014_task_difficulty_priority.sql +6 -0
  43. package/dist/data/migrations/015_timeblock_asked_at.sql +5 -0
  44. package/dist/data/migrations/016_intent_columns.sql +8 -0
  45. package/dist/data/migrations/017_ap_wallet.sql +25 -0
  46. package/dist/data/migrations/018_ap_rewards.sql +21 -0
  47. package/dist/data/migrations/019_ap_bounty.sql +10 -0
  48. package/dist/data/migrations/020_chat_turns.sql +10 -0
  49. package/dist/data/migrations/021_portrait.sql +4 -0
  50. package/dist/data/migrations/022_family_emoji_single.sql +3 -0
  51. package/dist/data/migrations/023_ap_balance_view_log.sql +9 -0
  52. package/dist/data/schema.sql +75 -0
  53. package/dist/knowledge/_registry/dimensions.yaml +10 -0
  54. package/dist/knowledge/_registry/personas.yaml +7 -0
  55. package/dist/knowledge/_schema/analysis-model.schema.js +32 -0
  56. package/dist/knowledge/_schema/entry.schema.js +45 -0
  57. package/dist/knowledge/_schema/persona.schema.js +23 -0
  58. package/dist/knowledge/_schema/scenario-pack.schema.js +29 -0
  59. package/dist/knowledge/analysis-models/review-default.yaml +16 -0
  60. package/dist/knowledge/analysis-models/review-summer-plan.yaml +15 -0
  61. package/dist/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
  62. package/dist/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
  63. package/dist/knowledge/brain-science/brain-env-engineering.yaml +22 -0
  64. package/dist/knowledge/brain-science/brain-extended-mind.yaml +22 -0
  65. package/dist/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
  66. package/dist/knowledge/brain-science/brain-peak-end.yaml +29 -0
  67. package/dist/knowledge/brain-science/brain-pomodoro.yaml +31 -0
  68. package/dist/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
  69. package/dist/knowledge/brain-science/brain-single-variable.yaml +15 -0
  70. package/dist/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
  71. package/dist/knowledge/energy/energy-active-recovery.yaml +22 -0
  72. package/dist/knowledge/energy/energy-deliberate-rest.yaml +22 -0
  73. package/dist/knowledge/energy/energy-exercise-embedded.yaml +22 -0
  74. package/dist/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
  75. package/dist/knowledge/energy/energy-four-energy-radar.yaml +22 -0
  76. package/dist/knowledge/energy/energy-intensity-recovery.yaml +22 -0
  77. package/dist/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
  78. package/dist/knowledge/src/repo/fs-yaml.js +252 -0
  79. package/dist/knowledge/src/repo/types.js +1 -0
  80. package/dist/knowledge/time/time-batch-windows.yaml +22 -0
  81. package/dist/knowledge/time/time-conscious-abandon.yaml +22 -0
  82. package/dist/knowledge/time/time-deep-work-block.yaml +22 -0
  83. package/dist/knowledge/time/time-finite-commitment.yaml +22 -0
  84. package/dist/knowledge/time/time-forced-whitespace.yaml +22 -0
  85. package/dist/knowledge/time/time-gtd-next-action.yaml +22 -0
  86. package/dist/knowledge/time/time-three-tier-list.yaml +22 -0
  87. package/dist/mcp/cold-start.js +43 -0
  88. package/dist/mcp/contract/v1/rest.js +56 -0
  89. package/dist/mcp/rest-server.js +52 -0
  90. package/dist/mcp/server-core.js +1334 -0
  91. package/dist/mcp/server.js +34 -0
  92. package/dist/mcp/src/ap/index.js +14 -0
  93. package/dist/mcp/src/ap/rewards.js +44 -0
  94. package/dist/mcp/src/categories/index.js +12 -0
  95. package/dist/mcp/src/chat/from-env.js +111 -0
  96. package/dist/mcp/src/chat/history.js +35 -0
  97. package/dist/mcp/src/chat/loop.js +131 -0
  98. package/dist/mcp/src/chat/portrait.js +133 -0
  99. package/dist/mcp/src/chat/system-prompt.js +23 -0
  100. package/dist/mcp/src/chat/tool-names.js +27 -0
  101. package/dist/mcp/src/chat/tool-schema.js +20 -0
  102. package/dist/mcp/src/chat/types.js +1 -0
  103. package/dist/mcp/src/clock/index.js +78 -0
  104. package/dist/mcp/src/coachmod/index.js +135 -0
  105. package/dist/mcp/src/host-adapters/hermes-cron.js +185 -0
  106. package/dist/mcp/src/host-adapters/index.js +61 -0
  107. package/dist/mcp/src/host-adapters/types.js +7 -0
  108. package/dist/mcp/src/identity/equivalent-ids.js +41 -0
  109. package/dist/mcp/src/identity/in-memory.js +100 -0
  110. package/dist/mcp/src/identity/resolve.js +60 -0
  111. package/dist/mcp/src/identity/types.js +4 -0
  112. package/dist/mcp/src/matrix/index.js +15 -0
  113. package/dist/mcp/src/profile/confirm-default.js +48 -0
  114. package/dist/mcp/src/profile/index.js +298 -0
  115. package/dist/mcp/src/profile-confirm.js +28 -0
  116. package/dist/mcp/src/profile-helpers.js +109 -0
  117. package/dist/mcp/src/reminder/hermes-channel.js +40 -0
  118. package/dist/mcp/src/reminder/index.js +70 -0
  119. package/dist/mcp/src/reminder/push-channel.js +10 -0
  120. package/dist/mcp/src/repo/factory.js +34 -0
  121. package/dist/mcp/src/repo/in-memory.js +469 -0
  122. package/dist/mcp/src/repo/sqlite-ap.js +188 -0
  123. package/dist/mcp/src/repo/sqlite-helpers.js +39 -0
  124. package/dist/mcp/src/repo/sqlite-identity.js +129 -0
  125. package/dist/mcp/src/repo/sqlite.js +754 -0
  126. package/dist/mcp/src/repo/types.js +4 -0
  127. package/dist/mcp/src/rest/app.js +121 -0
  128. package/dist/mcp/src/rest/invoke-tool.js +98 -0
  129. package/dist/mcp/src/rest/short-token.js +38 -0
  130. package/dist/mcp/src/rest/tokens.js +55 -0
  131. package/dist/mcp/src/scheduler/index.js +319 -0
  132. package/dist/mcp/src/stats/index.js +29 -0
  133. package/dist/mcp/src/stickiness/report.js +174 -0
  134. package/dist/mcp/src/stickiness/types.js +1 -0
  135. package/dist/mcp/src/task/index.js +412 -0
  136. package/dist/mcp/src/timeblock/index.js +206 -0
  137. package/dist/mcp/src/timeline/index.js +705 -0
  138. package/dist/mcp/src/timer/index.js +234 -0
  139. package/dist/mcp/src/user-id-resolver.js +30 -0
  140. package/dist/mcp/src/weixin/app.js +97 -0
  141. package/dist/mcp/src/weixin/custom-send.js +82 -0
  142. package/dist/mcp/src/weixin/handle-text.js +49 -0
  143. package/dist/mcp/src/weixin/reminder-loop.js +39 -0
  144. package/dist/mcp/src/weixin/signature.js +13 -0
  145. package/dist/mcp/src/weixin/tencent-asr.js +101 -0
  146. package/dist/mcp/src/weixin/xml.js +37 -0
  147. package/dist/mcp/weixin-server.js +158 -0
  148. package/knowledge/_registry/dimensions.yaml +10 -0
  149. package/knowledge/_registry/personas.yaml +7 -0
  150. package/knowledge/_schema/analysis-model.schema.ts +36 -0
  151. package/knowledge/_schema/entry.schema.ts +50 -0
  152. package/knowledge/_schema/persona.schema.ts +30 -0
  153. package/knowledge/_schema/scenario-pack.schema.ts +33 -0
  154. package/knowledge/analysis-models/review-default.yaml +16 -0
  155. package/knowledge/analysis-models/review-summer-plan.yaml +15 -0
  156. package/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
  157. package/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
  158. package/knowledge/brain-science/brain-env-engineering.yaml +22 -0
  159. package/knowledge/brain-science/brain-extended-mind.yaml +22 -0
  160. package/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
  161. package/knowledge/brain-science/brain-peak-end.yaml +29 -0
  162. package/knowledge/brain-science/brain-pomodoro.yaml +31 -0
  163. package/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
  164. package/knowledge/brain-science/brain-single-variable.yaml +15 -0
  165. package/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
  166. package/knowledge/energy/energy-active-recovery.yaml +22 -0
  167. package/knowledge/energy/energy-deliberate-rest.yaml +22 -0
  168. package/knowledge/energy/energy-exercise-embedded.yaml +22 -0
  169. package/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
  170. package/knowledge/energy/energy-four-energy-radar.yaml +22 -0
  171. package/knowledge/energy/energy-intensity-recovery.yaml +22 -0
  172. package/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
  173. package/knowledge/src/repo/fs-yaml.ts +282 -0
  174. package/knowledge/src/repo/types.ts +24 -0
  175. package/knowledge/tests/fixtures/base/_registry/dimensions.yaml +4 -0
  176. package/knowledge/tests/fixtures/base/time/valid-entry.yaml +24 -0
  177. package/knowledge/tests/fixtures/dup/_registry/dimensions.yaml +4 -0
  178. package/knowledge/tests/fixtures/dup/time/entry-a.yaml +13 -0
  179. package/knowledge/tests/fixtures/dup/time/entry-b.yaml +13 -0
  180. package/knowledge/tests/fixtures/unregistered/_registry/dimensions.yaml +7 -0
  181. package/knowledge/tests/fixtures/unregistered/time/anxiety-entry.yaml +13 -0
  182. package/knowledge/tests/repo/fs-yaml-knowledge-repo.test.ts +333 -0
  183. package/knowledge/time/time-batch-windows.yaml +22 -0
  184. package/knowledge/time/time-conscious-abandon.yaml +22 -0
  185. package/knowledge/time/time-deep-work-block.yaml +22 -0
  186. package/knowledge/time/time-finite-commitment.yaml +22 -0
  187. package/knowledge/time/time-forced-whitespace.yaml +22 -0
  188. package/knowledge/time/time-gtd-next-action.yaml +22 -0
  189. package/knowledge/time/time-three-tier-list.yaml +22 -0
  190. package/knowledge/validate.ts +50 -0
  191. package/package.json +76 -0
  192. package/scripts/postinstall-cold-start.js +45 -0
@@ -0,0 +1,234 @@
1
+ import { toLocalIso } from "../clock/index.js";
2
+ import { needsConfirmIfMissing, } from "../profile-confirm.js";
3
+ import { lookupTargetUserLabel, resolveTargetUserLabel, } from "../profile-helpers.js";
4
+ // ── pure helpers (algorithm = verbatim copy of original memory logic) ──
5
+ function nowISO() {
6
+ return new Date().toISOString();
7
+ }
8
+ const IDLE_VIEW = {
9
+ status: "idle",
10
+ event: null,
11
+ startedAt: null,
12
+ pausedAt: null,
13
+ accumulatedMs: 0,
14
+ targetMs: null,
15
+ remainingMs: null,
16
+ };
17
+ function toView(session) {
18
+ if (session.status === "stopped")
19
+ return { ...IDLE_VIEW };
20
+ const targetMs = session.target_ms ?? null;
21
+ let remainingMs = null;
22
+ if (targetMs !== null) {
23
+ const elapsed = computeElapsed(session);
24
+ remainingMs = targetMs - elapsed;
25
+ }
26
+ return {
27
+ status: session.status,
28
+ event: session.event,
29
+ categoryId: session.category_id ?? undefined,
30
+ startedAt: toLocalIso(session.started_at),
31
+ pausedAt: session.status === "paused" ? toLocalIso(session.updated_at) : null,
32
+ accumulatedMs: session.accumulated_ms,
33
+ targetMs,
34
+ remainingMs,
35
+ };
36
+ }
37
+ function computeElapsed(session) {
38
+ if (session.status === "stopped")
39
+ return 0;
40
+ if (session.status === "paused")
41
+ return session.accumulated_ms;
42
+ // running
43
+ return (session.accumulated_ms +
44
+ (Date.now() - new Date(session.started_at).getTime()));
45
+ }
46
+ function labelFor(db, userId) {
47
+ if (db)
48
+ return lookupTargetUserLabel(db, userId);
49
+ return resolveTargetUserLabel(userId);
50
+ }
51
+ // ── factory ──
52
+ export function createTimerTools(repo, timeBlockRepo, db) {
53
+ const start = async (userId, event, categoryId, taskId, targetUserId) => {
54
+ if (!event || event.trim() === "") {
55
+ throw new Error("事件名不能为空");
56
+ }
57
+ const effective = targetUserId ?? userId;
58
+ const userExists = await repo.userExists(effective);
59
+ const gate = needsConfirmIfMissing(userExists, effective);
60
+ if (gate)
61
+ return gate;
62
+ const active = await repo.getActive(effective);
63
+ if (active) {
64
+ throw new Error(`当前正在计时「${active.event}」,请先停止后再开始新的计时`);
65
+ }
66
+ const session = await repo.create({
67
+ user_id: effective,
68
+ event: event.trim(),
69
+ category_id: categoryId,
70
+ started_at: nowISO(),
71
+ task_id: taskId,
72
+ });
73
+ return {
74
+ ...toView(session),
75
+ target_user_label: labelFor(db, effective),
76
+ };
77
+ };
78
+ const pause = async (userId) => {
79
+ const active = await repo.getActive(userId);
80
+ if (!active || active.status !== "running") {
81
+ throw new Error("当前没有运行中的计时");
82
+ }
83
+ const segmentMs = Date.now() - new Date(active.started_at).getTime();
84
+ const accumulated = active.accumulated_ms + segmentMs;
85
+ const session = await repo.update(active.id, {
86
+ status: "paused",
87
+ accumulated_ms: accumulated,
88
+ });
89
+ return toView(session);
90
+ };
91
+ const resume = async (userId) => {
92
+ const active = await repo.getActive(userId);
93
+ if (active && active.status === "running") {
94
+ throw new Error("计时已在运行中");
95
+ }
96
+ if (!active || active.status !== "paused") {
97
+ throw new Error("没有可恢复的暂停计时");
98
+ }
99
+ const session = await repo.update(active.id, {
100
+ status: "running",
101
+ started_at: nowISO(),
102
+ });
103
+ return toView(session);
104
+ };
105
+ const stop = async (userId, options) => {
106
+ const active = await repo.getActive(userId);
107
+ if (!active ||
108
+ (active.status !== "running" && active.status !== "paused")) {
109
+ throw new Error("当前没有运行中的计时");
110
+ }
111
+ const endedAt = nowISO();
112
+ const totalMs = computeElapsed(active);
113
+ const durationMin = Math.round((totalMs / 60000) * 100) / 100;
114
+ await repo.update(active.id, {
115
+ status: "stopped",
116
+ ended_at: endedAt,
117
+ });
118
+ let timeblockId;
119
+ // S42 (2026-08-07): 回查匹配的 planned timeblock → 改 completed
120
+ // 用户场景:先排了 planned timeblock(如「羽毛球 9:00-11:00」),后开计时;
121
+ // 停计时期望自动标完成(带 ✓ + 划线),而不是 create 新的。
122
+ // 匹配规则:同 user_id + 同 title + 时间重叠 + status=planned
123
+ //
124
+ // ⚠️ 时区陷阱:planned timeblock 的 start_time/end_time 可能是 +08:00 格式(用户传入),
125
+ // 而 timer.session 的 started_at 是 Z 格式(nowISO())。findOverlapping 用字符串比较,
126
+ // 混合时区会误判("2026-08-07T09:00:00+08:00" vs "2026-08-07T05:00:00Z" 字符串比较 09 > 05)。
127
+ // 解法:调用前把 started_at/endedAt 都转 ISO UTC(Z 格式),与 line 578-581 其它 list 查询姿势一致。
128
+ if (timeBlockRepo) {
129
+ const startUtc = new Date(active.started_at).toISOString();
130
+ const endUtc = new Date(endedAt).toISOString();
131
+ const candidates = await timeBlockRepo.findOverlapping({
132
+ user_id: active.user_id,
133
+ start: startUtc,
134
+ end: endUtc,
135
+ });
136
+ const match = candidates.find((tb) => tb.status === "planned" &&
137
+ (tb.title === active.event ||
138
+ tb.title.includes(active.event) ||
139
+ active.event.includes(tb.title)));
140
+ if (match) {
141
+ const updated = await timeBlockRepo.update(match.id, {
142
+ end_time: endedAt,
143
+ status: "completed",
144
+ ...(options?.rating !== undefined ? { rating: options.rating } : {}),
145
+ ...(options?.rating_reason !== undefined
146
+ ? { rating_reason: options.rating_reason }
147
+ : {}),
148
+ });
149
+ timeblockId = updated.id;
150
+ }
151
+ }
152
+ // 没匹配到 planned → 走原 create 路径(仅 rating 时)
153
+ if (!timeblockId && options?.rating !== undefined && timeBlockRepo) {
154
+ const tb = await timeBlockRepo.create({
155
+ user_id: active.user_id,
156
+ title: active.event,
157
+ category_id: active.category_id ?? undefined,
158
+ start_time: active.started_at,
159
+ end_time: endedAt,
160
+ rating: options.rating,
161
+ rating_reason: options.rating_reason,
162
+ status: "completed",
163
+ });
164
+ timeblockId = tb.id;
165
+ }
166
+ const record = {
167
+ event: active.event,
168
+ categoryId: active.category_id ?? undefined,
169
+ startedAt: toLocalIso(active.started_at),
170
+ endedAt: toLocalIso(endedAt),
171
+ elapsedMs: totalMs,
172
+ durationMin,
173
+ timeblockId,
174
+ completedTaskId: active.task_id ?? undefined,
175
+ };
176
+ return record;
177
+ };
178
+ const status = async (userId) => {
179
+ const active = await repo.getActive(userId);
180
+ if (!active)
181
+ return { ...IDLE_VIEW };
182
+ return toView(active);
183
+ };
184
+ const statusAll = async () => {
185
+ const sessions = await repo.getAllActive();
186
+ return sessions.map((s) => ({
187
+ ...toView(s),
188
+ userId: s.user_id,
189
+ }));
190
+ };
191
+ const elapsed = async (userId) => {
192
+ const active = await repo.getActive(userId);
193
+ if (!active)
194
+ return 0;
195
+ return computeElapsed(active);
196
+ };
197
+ // ── S26: pomodoro v1 ──
198
+ const startFocus = async (userId, event, focusMin, categoryId, taskId) => {
199
+ const min = focusMin ?? 25;
200
+ if (min <= 0) {
201
+ throw new Error("focusMin 必须为正");
202
+ }
203
+ if (!event || event.trim() === "") {
204
+ throw new Error("事件名不能为空");
205
+ }
206
+ const userExists = await repo.userExists(userId);
207
+ if (!userExists) {
208
+ throw new Error("用户不存在");
209
+ }
210
+ const active = await repo.getActive(userId);
211
+ if (active) {
212
+ throw new Error(`当前正在计时「${active.event}」,请先停止后再开始新的计时`);
213
+ }
214
+ const session = await repo.create({
215
+ user_id: userId,
216
+ event: event.trim(),
217
+ category_id: categoryId,
218
+ started_at: nowISO(),
219
+ target_ms: Math.round(min * 60000),
220
+ task_id: taskId,
221
+ });
222
+ return toView(session);
223
+ };
224
+ const remaining = async (userId) => {
225
+ const active = await repo.getActive(userId);
226
+ if (!active)
227
+ return { ms: null, over: false };
228
+ if (active.target_ms == null)
229
+ return { ms: null, over: false };
230
+ const ms = active.target_ms - computeElapsed(active);
231
+ return { ms, over: ms < 0 };
232
+ };
233
+ return { start, pause, resume, stop, status, statusAll, elapsed, startFocus, remaining };
234
+ }
@@ -0,0 +1,30 @@
1
+ const SYSTEM_IDS = ["libero", "default", "test", "user_01"];
2
+ /**
3
+ * Resolve a default user_id for MCP tools when the LLM omits user_id.
4
+ */
5
+ export function resolveDefaultUserId({ db, env, }) {
6
+ const envMap = env ?? process.env;
7
+ const fromEnv = envMap.LIBERO_DEFAULT_USER_ID?.trim();
8
+ if (fromEnv) {
9
+ return { userId: fromEnv, source: "env" };
10
+ }
11
+ const placeholders = SYSTEM_IDS.map(() => "?").join(",");
12
+ // ESCAPE 通过参数传 '\',让 LIKE 里的 _ 当字面量
13
+ // (默认 _ 是单字符通配,会误排除 test-foo 这种 id)
14
+ const row = db
15
+ .prepare(`SELECT id FROM user_profile
16
+ WHERE deleted_at IS NULL
17
+ AND id NOT IN (${placeholders})
18
+ AND id NOT LIKE ? ESCAPE ?
19
+ ORDER BY created_at ASC
20
+ LIMIT 1`)
21
+ .get(...SYSTEM_IDS, "test\\_%", "\\");
22
+ if (row?.id) {
23
+ return { userId: row.id, source: "db" };
24
+ }
25
+ return { userId: null, source: "none" };
26
+ }
27
+ /** Startup log to stderr (visible in gateway / pm2 logs). */
28
+ export function logResolvedDefaultUserId(result) {
29
+ console.error(`[libero-mcp] default user_id=${result.userId ?? "none"} source=${result.source}`);
30
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * WeChat 服务号 webhook: URL 验证;文本先被动回「我先看看」,日程异步再推。
3
+ */
4
+ import { appendFileSync } from "node:fs";
5
+ import { Hono } from "hono";
6
+ import { weixinSignatureOk } from "./signature.js";
7
+ import { parseWeixinXml, weixinPassiveTextReply } from "./xml.js";
8
+ const seenMsgIds = new Set();
9
+ function rememberMsg(id) {
10
+ if (!id)
11
+ return true;
12
+ if (seenMsgIds.has(id))
13
+ return false;
14
+ seenMsgIds.add(id);
15
+ if (seenMsgIds.size > 500) {
16
+ const first = seenMsgIds.values().next().value;
17
+ if (first)
18
+ seenMsgIds.delete(first);
19
+ }
20
+ return true;
21
+ }
22
+ function wlog(path, line) {
23
+ const msg = `[libero-weixin] ${line}`;
24
+ console.error(msg);
25
+ if (!path)
26
+ return;
27
+ try {
28
+ appendFileSync(path, `${new Date().toISOString()} ${line}\n`);
29
+ }
30
+ catch {
31
+ /* ignore log IO */
32
+ }
33
+ }
34
+ export function createWeixinApp(opts) {
35
+ const app = new Hono();
36
+ const log = (line) => wlog(opts.logPath, line);
37
+ app.use("*", async (c, next) => {
38
+ log(`${c.req.method} ${c.req.path}`);
39
+ await next();
40
+ });
41
+ const signed = (c) => weixinSignatureOk({
42
+ token: opts.token,
43
+ signature: c.req.query("signature") ?? "",
44
+ timestamp: c.req.query("timestamp") ?? "",
45
+ nonce: c.req.query("nonce") ?? "",
46
+ });
47
+ const getHandshake = (c) => {
48
+ if (!signed(c)) {
49
+ log("GET reject signature");
50
+ return c.text("", 403);
51
+ }
52
+ log("GET echostr ok");
53
+ return c.text(c.req.query("echostr") ?? "", 200);
54
+ };
55
+ const postMessage = async (c) => {
56
+ if (!signed(c)) {
57
+ log(`POST reject signature path=${c.req.path}`);
58
+ return c.text("", 403);
59
+ }
60
+ const xml = await c.req.text();
61
+ const msg = parseWeixinXml(xml);
62
+ log(`POST path=${c.req.path} bytes=${xml.length} qs=${["signature", "timestamp", "nonce", "msg_signature", "encrypt_type"].filter((k) => c.req.query(k)).join(",")} type=${msg?.msgType ?? "none"} enc=${msg?.encrypted ?? false} content_len=${msg?.content.length ?? 0}`);
63
+ if (msg &&
64
+ (msg.msgType === "text" || msg.msgType === "voice") &&
65
+ (msg.msgType === "voice" ? !!msg.mediaId : !!msg.content) &&
66
+ rememberMsg(msg.msgId) &&
67
+ opts.onText) {
68
+ void Promise.resolve(opts.onText(msg)).catch((err) => {
69
+ const m = err instanceof Error ? err.message : String(err);
70
+ log(`onText failed: ${m}`);
71
+ });
72
+ if (msg.openid && msg.ghAccount) {
73
+ return c.body(weixinPassiveTextReply({
74
+ toOpenid: msg.openid,
75
+ fromGh: msg.ghAccount,
76
+ content: "我先看看",
77
+ }), 200, { "Content-Type": "application/xml; charset=utf-8" });
78
+ }
79
+ }
80
+ return c.text("success", 200);
81
+ };
82
+ app.get("/wechat", getHandshake);
83
+ app.get("/wechat/", getHandshake);
84
+ app.post("/wechat", postMessage);
85
+ app.post("/wechat/", postMessage);
86
+ app.get("/", (c) => {
87
+ if (c.req.query("echostr") || c.req.query("signature"))
88
+ return getHandshake(c);
89
+ return c.text("libero-weixin ok");
90
+ });
91
+ app.post("/", postMessage);
92
+ app.notFound((c) => {
93
+ log(`404 ${c.req.method} ${c.req.path}`);
94
+ return c.text("success", 200);
95
+ });
96
+ return app;
97
+ }
@@ -0,0 +1,82 @@
1
+ /** 留余量给 JSON 转义 */
2
+ export const WEIXIN_TEXT_MAX_BYTES = 2000;
3
+ export function splitUtf8Chunks(text, maxBytes = WEIXIN_TEXT_MAX_BYTES) {
4
+ if (Buffer.byteLength(text, "utf8") <= maxBytes)
5
+ return [text];
6
+ const parts = [];
7
+ let buf = "";
8
+ for (const ch of text) {
9
+ const next = buf + ch;
10
+ if (Buffer.byteLength(next, "utf8") > maxBytes) {
11
+ if (buf)
12
+ parts.push(buf);
13
+ buf = ch;
14
+ }
15
+ else {
16
+ buf = next;
17
+ }
18
+ }
19
+ if (buf)
20
+ parts.push(buf);
21
+ return parts;
22
+ }
23
+ export function createWeixinSender(opts) {
24
+ let cache = null;
25
+ const fetchImpl = opts.fetchImpl ?? fetch;
26
+ async function accessToken() {
27
+ const now = Date.now();
28
+ if (cache && cache.expireAt > now + 60_000)
29
+ return cache.token;
30
+ const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential` +
31
+ `&appid=${encodeURIComponent(opts.appId)}` +
32
+ `&secret=${encodeURIComponent(opts.appSecret)}`;
33
+ const res = await fetchImpl(url);
34
+ const data = (await res.json());
35
+ if (!data.access_token) {
36
+ throw new Error(`weixin token: ${data.errmsg ?? res.status}`);
37
+ }
38
+ cache = {
39
+ token: data.access_token,
40
+ expireAt: now + (data.expires_in ?? 7200) * 1000,
41
+ };
42
+ return cache.token;
43
+ }
44
+ return {
45
+ async sendText(openid, text) {
46
+ const token = await accessToken();
47
+ const chunks = splitUtf8Chunks(text);
48
+ for (const clipped of chunks) {
49
+ if (!clipped)
50
+ continue;
51
+ const res = await fetchImpl(`https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${encodeURIComponent(token)}`, {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify({
55
+ touser: openid,
56
+ msgtype: "text",
57
+ text: { content: clipped },
58
+ }),
59
+ });
60
+ const data = (await res.json());
61
+ if (data.errcode && data.errcode !== 0) {
62
+ throw new Error(`weixin custom send: ${data.errcode} ${data.errmsg ?? ""}`);
63
+ }
64
+ }
65
+ },
66
+ async downloadMedia(mediaId) {
67
+ const token = await accessToken();
68
+ const res = await fetchImpl(`https://api.weixin.qq.com/cgi-bin/media/get?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`);
69
+ const ctype = res.headers.get("content-type") ?? "";
70
+ if (ctype.includes("application/json")) {
71
+ const data = (await res.json());
72
+ throw new Error(`weixin media: ${data.errmsg ?? res.status}`);
73
+ }
74
+ const bytes = Buffer.from(await res.arrayBuffer());
75
+ if (bytes.length < 32 && bytes.toString("utf8").includes("errcode")) {
76
+ throw new Error(`weixin media: ${bytes.toString("utf8").slice(0, 200)}`);
77
+ }
78
+ const format = ctype.includes("silk") ? "silk" : "amr";
79
+ return { bytes, format };
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,49 @@
1
+ import { runChatTurn } from "../chat/loop.js";
2
+ export async function handleWeixinText(opts) {
3
+ let content = opts.msg.content.trim();
4
+ if (opts.msg.msgType === "voice") {
5
+ const mediaId = opts.msg.mediaId?.trim();
6
+ if (!mediaId)
7
+ return;
8
+ if (!opts.asr || !opts.sender.downloadMedia) {
9
+ await opts.sender.sendText(opts.msg.openid, "语音还没接通(缺腾讯云 ASR 配置),先打字发我。");
10
+ return;
11
+ }
12
+ try {
13
+ const media = await opts.sender.downloadMedia(mediaId);
14
+ const format = (opts.msg.format || media.format || "amr").toLowerCase();
15
+ content = (await opts.asr.transcribe({ bytes: media.bytes, format })).trim();
16
+ }
17
+ catch (err) {
18
+ const m = err instanceof Error ? err.message : String(err);
19
+ await opts.sender.sendText(opts.msg.openid, `语音没转成字(${m})。再发一条,或改打字。`);
20
+ throw err;
21
+ }
22
+ if (!content) {
23
+ await opts.sender.sendText(opts.msg.openid, "没听清,再说一遍或改打字。");
24
+ return;
25
+ }
26
+ }
27
+ if (!content)
28
+ return;
29
+ if (!opts.llm) {
30
+ await opts.sender.sendText(opts.msg.openid, "助理暂时不可用,过一会儿再试。");
31
+ return;
32
+ }
33
+ try {
34
+ const turn = await runChatTurn({
35
+ llm: opts.llm,
36
+ registry: opts.registry,
37
+ userId: opts.userId,
38
+ message: content,
39
+ conversationKey: `weixin:${opts.msg.openid}`,
40
+ mintLink: opts.mintLink,
41
+ });
42
+ await opts.sender.sendText(opts.msg.openid, turn.reply);
43
+ }
44
+ catch (err) {
45
+ const m = err instanceof Error ? err.message : String(err);
46
+ await opts.sender.sendText(opts.msg.openid, `刚才没办完(${m})。你再说一遍,或先问「今天日程」核对。`);
47
+ throw err;
48
+ }
49
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * INT-weixin 提醒推送循环:到点提醒经客服消息真推送(48h 互动窗口内)。
3
+ * 生产背景(2026-08-20):提醒只落库没人发,用户设了"午睡后叫我"没被叫。
4
+ * 失败不标记 sent → 留 pending,由既有"过期补发"被动路径兜底(下次交互补提)。
5
+ */
6
+ /** 超过该时长的旧到期提醒不再主动推(不深夜轰炸隔夜账),交被动补提。 */
7
+ export const REMINDER_PUSH_MAX_AGE_MS = 2 * 60 * 60 * 1000;
8
+ export async function tickReminderPush(deps) {
9
+ const nowMs = deps.now ? Date.parse(deps.now) : Date.now();
10
+ const due = await deps.getDue(deps.userId, new Date(nowMs).toISOString());
11
+ let sent = 0;
12
+ let failed = 0;
13
+ for (const r of due) {
14
+ // recurring intent 归 adapter(cron)管:remind_at 是近似占位,不推(防误爆)
15
+ if (r.trigger_kind === "recurring")
16
+ continue;
17
+ const age = nowMs - Date.parse(r.remind_at);
18
+ if (!(age >= 0) || age > REMINDER_PUSH_MAX_AGE_MS)
19
+ continue; // 未到/过期保护
20
+ if (deps.openids.length === 0) {
21
+ failed++; // 无活跃窗口(48h 没互动):留 pending
22
+ continue;
23
+ }
24
+ let delivered = false;
25
+ for (const openid of deps.openids) {
26
+ try {
27
+ await deps.sendText(openid, r.content);
28
+ await deps.markSent(r.id);
29
+ delivered = true;
30
+ break;
31
+ }
32
+ catch {
33
+ // 窗口外/接口失败 → 试下一个 openid
34
+ }
35
+ }
36
+ delivered ? sent++ : failed++;
37
+ }
38
+ return { sent, failed };
39
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * WeChat 服务号 URL 验证:token + timestamp + nonce 字典序拼接后 sha1,对照 signature。
3
+ */
4
+ import { createHash } from "node:crypto";
5
+ export function weixinSignatureOk(opts) {
6
+ if (!opts.token || !opts.signature || !opts.timestamp || !opts.nonce) {
7
+ return false;
8
+ }
9
+ const hashed = createHash("sha1")
10
+ .update([opts.token, opts.timestamp, opts.nonce].sort().join(""))
11
+ .digest("hex");
12
+ return hashed === opts.signature;
13
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 腾讯云一句话识别。无 SDK:TC3 签名 + fetch。
3
+ * https://cloud.tencent.com/document/product/1093/35646
4
+ */
5
+ import { createHash, createHmac } from "node:crypto";
6
+ function sha256Hex(data) {
7
+ return createHash("sha256").update(data, "utf8").digest("hex");
8
+ }
9
+ function hmac(key, msg) {
10
+ return createHmac("sha256", key).update(msg, "utf8").digest();
11
+ }
12
+ export function tc3Authorization(opts) {
13
+ const date = new Date(opts.timestamp * 1000).toISOString().slice(0, 10);
14
+ const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${opts.host}\n`;
15
+ const signedHeaders = "content-type;host";
16
+ const canonicalRequest = [
17
+ "POST",
18
+ "/",
19
+ "",
20
+ canonicalHeaders,
21
+ signedHeaders,
22
+ sha256Hex(opts.payload),
23
+ ].join("\n");
24
+ const credentialScope = `${date}/${opts.service}/tc3_request`;
25
+ const stringToSign = [
26
+ "TC3-HMAC-SHA256",
27
+ String(opts.timestamp),
28
+ credentialScope,
29
+ sha256Hex(canonicalRequest),
30
+ ].join("\n");
31
+ const secretDate = hmac(`TC3${opts.secretKey}`, date);
32
+ const secretService = hmac(secretDate, opts.service);
33
+ const secretSigning = hmac(secretService, "tc3_request");
34
+ const signature = createHmac("sha256", secretSigning)
35
+ .update(stringToSign, "utf8")
36
+ .digest("hex");
37
+ return (`TC3-HMAC-SHA256 Credential=${opts.secretId}/${credentialScope}, ` +
38
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`);
39
+ }
40
+ function engineForFormat(format, override) {
41
+ if (override?.trim())
42
+ return override.trim();
43
+ return format.toLowerCase() === "silk" ? "16k_zh" : "8k_zh";
44
+ }
45
+ export async function sentenceRecognize(opts) {
46
+ const host = "asr.tencentcloudapi.com";
47
+ const region = opts.region ?? "ap-guangzhou";
48
+ const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
49
+ const voiceFormat = (opts.format || "amr").toLowerCase();
50
+ const payload = JSON.stringify({
51
+ EngSerViceType: engineForFormat(voiceFormat, opts.engine),
52
+ SourceType: 1,
53
+ VoiceFormat: voiceFormat,
54
+ Data: opts.bytes.toString("base64"),
55
+ DataLen: opts.bytes.length,
56
+ });
57
+ const authorization = tc3Authorization({
58
+ secretId: opts.secretId,
59
+ secretKey: opts.secretKey,
60
+ host,
61
+ service: "asr",
62
+ timestamp,
63
+ payload,
64
+ });
65
+ const fetchImpl = opts.fetchImpl ?? fetch;
66
+ const res = await fetchImpl(`https://${host}/`, {
67
+ method: "POST",
68
+ headers: {
69
+ "Content-Type": "application/json; charset=utf-8",
70
+ Host: host,
71
+ "X-TC-Action": "SentenceRecognition",
72
+ "X-TC-Version": "2019-06-14",
73
+ "X-TC-Timestamp": String(timestamp),
74
+ "X-TC-Region": region,
75
+ Authorization: authorization,
76
+ },
77
+ body: payload,
78
+ });
79
+ const raw = (await res.json());
80
+ const err = raw.Response?.Error;
81
+ if (err) {
82
+ throw new Error(err.Message || err.Code || "tencent asr error");
83
+ }
84
+ return (raw.Response?.Result ?? "").trim();
85
+ }
86
+ export function tencentAsrFromEnv(env = process.env) {
87
+ const secretId = env.TENCENT_SECRET_ID?.trim();
88
+ const secretKey = env.TENCENT_SECRET_KEY?.trim();
89
+ if (!secretId || !secretKey)
90
+ return null;
91
+ return {
92
+ transcribe: (input) => sentenceRecognize({
93
+ secretId,
94
+ secretKey,
95
+ bytes: input.bytes,
96
+ format: input.format,
97
+ region: env.TENCENT_ASR_REGION,
98
+ engine: env.TENCENT_ASR_ENGINE,
99
+ }),
100
+ };
101
+ }
@@ -0,0 +1,37 @@
1
+ function xmlField(xml, tag) {
2
+ const cdata = new RegExp(`<${tag}>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${tag}>`, "i");
3
+ const plain = new RegExp(`<${tag}>\\s*([^<]*)\\s*</${tag}>`, "i");
4
+ const m = xml.match(cdata) ?? xml.match(plain);
5
+ return (m?.[1] ?? "").trim();
6
+ }
7
+ export function parseWeixinXml(xml) {
8
+ if (!xml || typeof xml !== "string")
9
+ return null;
10
+ const msgType = xmlField(xml, "MsgType").toLowerCase();
11
+ const openid = xmlField(xml, "FromUserName");
12
+ const encrypted = /<Encrypt>/i.test(xml);
13
+ if (!openid && !encrypted)
14
+ return null;
15
+ return {
16
+ msgType: msgType || (encrypted ? "encrypt" : ""),
17
+ openid,
18
+ ghAccount: xmlField(xml, "ToUserName"),
19
+ content: xmlField(xml, "Content"),
20
+ ...(xmlField(xml, "MediaId")
21
+ ? { mediaId: xmlField(xml, "MediaId") }
22
+ : {}),
23
+ ...(xmlField(xml, "Format") ? { format: xmlField(xml, "Format") } : {}),
24
+ msgId: xmlField(xml, "MsgId") || xmlField(xml, "CreateTime"),
25
+ encrypted,
26
+ };
27
+ }
28
+ export function weixinPassiveTextReply(opts) {
29
+ const now = Math.floor(Date.now() / 1000);
30
+ return (`<xml>` +
31
+ `<ToUserName><![CDATA[${opts.toOpenid}]]></ToUserName>` +
32
+ `<FromUserName><![CDATA[${opts.fromGh}]]></FromUserName>` +
33
+ `<CreateTime>${now}</CreateTime>` +
34
+ `<MsgType><![CDATA[text]]></MsgType>` +
35
+ `<Content><![CDATA[${opts.content}]]></Content>` +
36
+ `</xml>`);
37
+ }