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.
- package/COLD_START.md +31 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/data/migrations/001_initial_schema.sql +43 -0
- package/data/migrations/002_seed_categories.sql +18 -0
- package/data/migrations/003_add_rating.sql +4 -0
- package/data/migrations/004_timer_sessions.sql +21 -0
- package/data/migrations/005_profile_settings.sql +5 -0
- package/data/migrations/006_add_timeblock_status.sql +1 -0
- package/data/migrations/007_reminders.sql +17 -0
- package/data/migrations/008_timer_target_ms.sql +5 -0
- package/data/migrations/009_tasks.sql +22 -0
- package/data/migrations/010_timer_task_id.sql +4 -0
- package/data/migrations/011_profile_default_confirmed.sql +4 -0
- package/data/migrations/012_identity_tables.sql +68 -0
- package/data/migrations/013_identity_managed_by.sql +3 -0
- package/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/data/migrations/015_timeblock_asked_at.sql +5 -0
- package/data/migrations/016_intent_columns.sql +8 -0
- package/data/migrations/017_ap_wallet.sql +25 -0
- package/data/migrations/018_ap_rewards.sql +21 -0
- package/data/migrations/019_ap_bounty.sql +10 -0
- package/data/migrations/020_chat_turns.sql +10 -0
- package/data/migrations/021_portrait.sql +4 -0
- package/data/migrations/022_family_emoji_single.sql +3 -0
- package/data/migrations/023_ap_balance_view_log.sql +9 -0
- package/data/schema.sql +75 -0
- package/dist/data/db.js +83 -0
- package/dist/data/migrations/001_initial_schema.sql +43 -0
- package/dist/data/migrations/002_seed_categories.sql +18 -0
- package/dist/data/migrations/003_add_rating.sql +4 -0
- package/dist/data/migrations/004_timer_sessions.sql +21 -0
- package/dist/data/migrations/005_profile_settings.sql +5 -0
- package/dist/data/migrations/006_add_timeblock_status.sql +1 -0
- package/dist/data/migrations/007_reminders.sql +17 -0
- package/dist/data/migrations/008_timer_target_ms.sql +5 -0
- package/dist/data/migrations/009_tasks.sql +22 -0
- package/dist/data/migrations/010_timer_task_id.sql +4 -0
- package/dist/data/migrations/011_profile_default_confirmed.sql +4 -0
- package/dist/data/migrations/012_identity_tables.sql +68 -0
- package/dist/data/migrations/013_identity_managed_by.sql +3 -0
- package/dist/data/migrations/014_task_difficulty_priority.sql +6 -0
- package/dist/data/migrations/015_timeblock_asked_at.sql +5 -0
- package/dist/data/migrations/016_intent_columns.sql +8 -0
- package/dist/data/migrations/017_ap_wallet.sql +25 -0
- package/dist/data/migrations/018_ap_rewards.sql +21 -0
- package/dist/data/migrations/019_ap_bounty.sql +10 -0
- package/dist/data/migrations/020_chat_turns.sql +10 -0
- package/dist/data/migrations/021_portrait.sql +4 -0
- package/dist/data/migrations/022_family_emoji_single.sql +3 -0
- package/dist/data/migrations/023_ap_balance_view_log.sql +9 -0
- package/dist/data/schema.sql +75 -0
- package/dist/knowledge/_registry/dimensions.yaml +10 -0
- package/dist/knowledge/_registry/personas.yaml +7 -0
- package/dist/knowledge/_schema/analysis-model.schema.js +32 -0
- package/dist/knowledge/_schema/entry.schema.js +45 -0
- package/dist/knowledge/_schema/persona.schema.js +23 -0
- package/dist/knowledge/_schema/scenario-pack.schema.js +29 -0
- package/dist/knowledge/analysis-models/review-default.yaml +16 -0
- package/dist/knowledge/analysis-models/review-summer-plan.yaml +15 -0
- package/dist/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
- package/dist/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
- package/dist/knowledge/brain-science/brain-env-engineering.yaml +22 -0
- package/dist/knowledge/brain-science/brain-extended-mind.yaml +22 -0
- package/dist/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
- package/dist/knowledge/brain-science/brain-peak-end.yaml +29 -0
- package/dist/knowledge/brain-science/brain-pomodoro.yaml +31 -0
- package/dist/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
- package/dist/knowledge/brain-science/brain-single-variable.yaml +15 -0
- package/dist/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
- package/dist/knowledge/energy/energy-active-recovery.yaml +22 -0
- package/dist/knowledge/energy/energy-deliberate-rest.yaml +22 -0
- package/dist/knowledge/energy/energy-exercise-embedded.yaml +22 -0
- package/dist/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
- package/dist/knowledge/energy/energy-four-energy-radar.yaml +22 -0
- package/dist/knowledge/energy/energy-intensity-recovery.yaml +22 -0
- package/dist/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
- package/dist/knowledge/src/repo/fs-yaml.js +252 -0
- package/dist/knowledge/src/repo/types.js +1 -0
- package/dist/knowledge/time/time-batch-windows.yaml +22 -0
- package/dist/knowledge/time/time-conscious-abandon.yaml +22 -0
- package/dist/knowledge/time/time-deep-work-block.yaml +22 -0
- package/dist/knowledge/time/time-finite-commitment.yaml +22 -0
- package/dist/knowledge/time/time-forced-whitespace.yaml +22 -0
- package/dist/knowledge/time/time-gtd-next-action.yaml +22 -0
- package/dist/knowledge/time/time-three-tier-list.yaml +22 -0
- package/dist/mcp/cold-start.js +43 -0
- package/dist/mcp/contract/v1/rest.js +56 -0
- package/dist/mcp/rest-server.js +52 -0
- package/dist/mcp/server-core.js +1334 -0
- package/dist/mcp/server.js +34 -0
- package/dist/mcp/src/ap/index.js +14 -0
- package/dist/mcp/src/ap/rewards.js +44 -0
- package/dist/mcp/src/categories/index.js +12 -0
- package/dist/mcp/src/chat/from-env.js +111 -0
- package/dist/mcp/src/chat/history.js +35 -0
- package/dist/mcp/src/chat/loop.js +131 -0
- package/dist/mcp/src/chat/portrait.js +133 -0
- package/dist/mcp/src/chat/system-prompt.js +23 -0
- package/dist/mcp/src/chat/tool-names.js +27 -0
- package/dist/mcp/src/chat/tool-schema.js +20 -0
- package/dist/mcp/src/chat/types.js +1 -0
- package/dist/mcp/src/clock/index.js +78 -0
- package/dist/mcp/src/coachmod/index.js +135 -0
- package/dist/mcp/src/host-adapters/hermes-cron.js +185 -0
- package/dist/mcp/src/host-adapters/index.js +61 -0
- package/dist/mcp/src/host-adapters/types.js +7 -0
- package/dist/mcp/src/identity/equivalent-ids.js +41 -0
- package/dist/mcp/src/identity/in-memory.js +100 -0
- package/dist/mcp/src/identity/resolve.js +60 -0
- package/dist/mcp/src/identity/types.js +4 -0
- package/dist/mcp/src/matrix/index.js +15 -0
- package/dist/mcp/src/profile/confirm-default.js +48 -0
- package/dist/mcp/src/profile/index.js +298 -0
- package/dist/mcp/src/profile-confirm.js +28 -0
- package/dist/mcp/src/profile-helpers.js +109 -0
- package/dist/mcp/src/reminder/hermes-channel.js +40 -0
- package/dist/mcp/src/reminder/index.js +70 -0
- package/dist/mcp/src/reminder/push-channel.js +10 -0
- package/dist/mcp/src/repo/factory.js +34 -0
- package/dist/mcp/src/repo/in-memory.js +469 -0
- package/dist/mcp/src/repo/sqlite-ap.js +188 -0
- package/dist/mcp/src/repo/sqlite-helpers.js +39 -0
- package/dist/mcp/src/repo/sqlite-identity.js +129 -0
- package/dist/mcp/src/repo/sqlite.js +754 -0
- package/dist/mcp/src/repo/types.js +4 -0
- package/dist/mcp/src/rest/app.js +121 -0
- package/dist/mcp/src/rest/invoke-tool.js +98 -0
- package/dist/mcp/src/rest/short-token.js +38 -0
- package/dist/mcp/src/rest/tokens.js +55 -0
- package/dist/mcp/src/scheduler/index.js +319 -0
- package/dist/mcp/src/stats/index.js +29 -0
- package/dist/mcp/src/stickiness/report.js +174 -0
- package/dist/mcp/src/stickiness/types.js +1 -0
- package/dist/mcp/src/task/index.js +412 -0
- package/dist/mcp/src/timeblock/index.js +206 -0
- package/dist/mcp/src/timeline/index.js +705 -0
- package/dist/mcp/src/timer/index.js +234 -0
- package/dist/mcp/src/user-id-resolver.js +30 -0
- package/dist/mcp/src/weixin/app.js +97 -0
- package/dist/mcp/src/weixin/custom-send.js +82 -0
- package/dist/mcp/src/weixin/handle-text.js +49 -0
- package/dist/mcp/src/weixin/reminder-loop.js +39 -0
- package/dist/mcp/src/weixin/signature.js +13 -0
- package/dist/mcp/src/weixin/tencent-asr.js +101 -0
- package/dist/mcp/src/weixin/xml.js +37 -0
- package/dist/mcp/weixin-server.js +158 -0
- package/knowledge/_registry/dimensions.yaml +10 -0
- package/knowledge/_registry/personas.yaml +7 -0
- package/knowledge/_schema/analysis-model.schema.ts +36 -0
- package/knowledge/_schema/entry.schema.ts +50 -0
- package/knowledge/_schema/persona.schema.ts +30 -0
- package/knowledge/_schema/scenario-pack.schema.ts +33 -0
- package/knowledge/analysis-models/review-default.yaml +16 -0
- package/knowledge/analysis-models/review-summer-plan.yaml +15 -0
- package/knowledge/brain-science/brain-deep-warmup.yaml +22 -0
- package/knowledge/brain-science/brain-emotion-granularity.yaml +22 -0
- package/knowledge/brain-science/brain-env-engineering.yaml +22 -0
- package/knowledge/brain-science/brain-extended-mind.yaml +22 -0
- package/knowledge/brain-science/brain-friction-barrier.yaml +22 -0
- package/knowledge/brain-science/brain-peak-end.yaml +29 -0
- package/knowledge/brain-science/brain-pomodoro.yaml +31 -0
- package/knowledge/brain-science/brain-revenge-bedtime.yaml +22 -0
- package/knowledge/brain-science/brain-single-variable.yaml +15 -0
- package/knowledge/brain-science/brain-tiny-habits.yaml +22 -0
- package/knowledge/energy/energy-active-recovery.yaml +22 -0
- package/knowledge/energy/energy-deliberate-rest.yaml +22 -0
- package/knowledge/energy/energy-exercise-embedded.yaml +22 -0
- package/knowledge/energy/energy-fixed-wake-time.yaml +22 -0
- package/knowledge/energy/energy-four-energy-radar.yaml +22 -0
- package/knowledge/energy/energy-intensity-recovery.yaml +22 -0
- package/knowledge/scenario-packs/pack-summer-kid.yaml +12 -0
- package/knowledge/src/repo/fs-yaml.ts +282 -0
- package/knowledge/src/repo/types.ts +24 -0
- package/knowledge/tests/fixtures/base/_registry/dimensions.yaml +4 -0
- package/knowledge/tests/fixtures/base/time/valid-entry.yaml +24 -0
- package/knowledge/tests/fixtures/dup/_registry/dimensions.yaml +4 -0
- package/knowledge/tests/fixtures/dup/time/entry-a.yaml +13 -0
- package/knowledge/tests/fixtures/dup/time/entry-b.yaml +13 -0
- package/knowledge/tests/fixtures/unregistered/_registry/dimensions.yaml +7 -0
- package/knowledge/tests/fixtures/unregistered/time/anxiety-entry.yaml +13 -0
- package/knowledge/tests/repo/fs-yaml-knowledge-repo.test.ts +333 -0
- package/knowledge/time/time-batch-windows.yaml +22 -0
- package/knowledge/time/time-conscious-abandon.yaml +22 -0
- package/knowledge/time/time-deep-work-block.yaml +22 -0
- package/knowledge/time/time-finite-commitment.yaml +22 -0
- package/knowledge/time/time-forced-whitespace.yaml +22 -0
- package/knowledge/time/time-gtd-next-action.yaml +22 -0
- package/knowledge/time/time-three-tier-list.yaml +22 -0
- package/knowledge/validate.ts +50 -0
- package/package.json +76 -0
- package/scripts/postinstall-cold-start.js +45 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
async function pickReusableOrphan(repo, orphans) {
|
|
3
|
+
if (orphans.length === 0)
|
|
4
|
+
return undefined;
|
|
5
|
+
for (let i = orphans.length - 1; i >= 0; i--) {
|
|
6
|
+
const p = await repo.getById(orphans[i].libero_user_id);
|
|
7
|
+
const s = p?.settings;
|
|
8
|
+
if (s && Object.keys(s).length > 0)
|
|
9
|
+
return orphans[i];
|
|
10
|
+
}
|
|
11
|
+
return orphans.at(-1);
|
|
12
|
+
}
|
|
13
|
+
/** Validate and delegate profile operations to the repository.
|
|
14
|
+
* ADR-0001: Dependency-injected — the caller provides the repo.
|
|
15
|
+
*
|
|
16
|
+
* S41(2026-08-06):新增可选 identityRepo 参数,用于 createUser 工具。
|
|
17
|
+
* 不传 = 不支持 createUser(向后兼容)。 */
|
|
18
|
+
export function createProfileTools(repo, identityRepo) {
|
|
19
|
+
const dirty = async (userId) => {
|
|
20
|
+
if (typeof repo.markPortraitDirty === "function") {
|
|
21
|
+
await repo.markPortraitDirty(userId);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
async get(userId) {
|
|
26
|
+
if (!userId) {
|
|
27
|
+
throw new Error("user_id 为必填参数");
|
|
28
|
+
}
|
|
29
|
+
return repo.getById(userId);
|
|
30
|
+
},
|
|
31
|
+
async upsert(input) {
|
|
32
|
+
if (!input.user_id) {
|
|
33
|
+
throw new Error("user_id 为必填参数");
|
|
34
|
+
}
|
|
35
|
+
const p = await repo.upsert(input);
|
|
36
|
+
await dirty(input.user_id);
|
|
37
|
+
return p;
|
|
38
|
+
},
|
|
39
|
+
/**
|
|
40
|
+
* MV-COACH-3 slice 2(2026-07-31):专门写 last_inactivity_nudge_at 时间戳。
|
|
41
|
+
*
|
|
42
|
+
* 解决问题:原本 LLM 触发"5 天没记录问候"后要自己调 profile.upsert 写这个时间戳,
|
|
43
|
+
* 但 LLM 经常忘调 → 5 天窗口防重复失效,可能 nag 多次。
|
|
44
|
+
* 专用工具 = 唯一入口,调别的没用,让"忘了写时间戳"在工具层不再可能。
|
|
45
|
+
*
|
|
46
|
+
* 关键:DEEP-MERGE 进 settings(不是 whole-replace),保留其它 settings key。
|
|
47
|
+
* (profile.upsert 的 settings 是 whole-replace per S13 §4,所以必须用专门工具。)
|
|
48
|
+
*
|
|
49
|
+
* nonexistent user_id 也会 auto-create profile row(防御 race / skill 顺序问题)。
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ S40 slice 2(2026-08-02):多 cron 并发时不要用 markInactivityNudge(无 atomic 保护)。
|
|
52
|
+
* 用 claimNudge —— 它有 atomic 防重复(window 内只有一方赢)。
|
|
53
|
+
* markInactivityNudge 留作单 cron 场景(产品叙事:单渠道 cron + nag 历史判定)。
|
|
54
|
+
*/
|
|
55
|
+
async markInactivityNudge(input) {
|
|
56
|
+
if (!input.user_id) {
|
|
57
|
+
throw new Error("user_id 为必填参数");
|
|
58
|
+
}
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
const existing = await repo.getById(input.user_id);
|
|
61
|
+
const existingSettings = existing?.settings != null
|
|
62
|
+
? existing.settings
|
|
63
|
+
: {};
|
|
64
|
+
const merged = {
|
|
65
|
+
...existingSettings,
|
|
66
|
+
last_inactivity_nudge_at: now,
|
|
67
|
+
};
|
|
68
|
+
return repo.upsert({
|
|
69
|
+
user_id: input.user_id,
|
|
70
|
+
settings: merged,
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
/**
|
|
74
|
+
* S40 slice 2 → slice 3(2026-08-03 升级):Atomic claim。
|
|
75
|
+
*
|
|
76
|
+
* 历史:slice 2 是 tool 层「读-检查-写」,集成测试量出 100% race(每次并发都双发)。
|
|
77
|
+
* slice 3 改为:repo 提供 claimNudgeAtomic(用 better-sqlite3 db.transaction 包整段)。
|
|
78
|
+
*
|
|
79
|
+
* 兼容降级:如果 repo 没实现 claimNudgeAtomic(如 InMemoryProfileRepo),
|
|
80
|
+
* 走旧版非原子逻辑——但会在 §4.5 静默折叠意义上「未声明」,所以打 WARN 日志。
|
|
81
|
+
*
|
|
82
|
+
* @4.5 显式声明:sqlite transaction 是真 atomic(阻塞 event loop + 写锁)。
|
|
83
|
+
* 在 in-memory repo 下无 atomic 保护——但 in-memory 只在测试场景,无生产风险。
|
|
84
|
+
*/
|
|
85
|
+
async claimNudge(input) {
|
|
86
|
+
if (!input.user_id) {
|
|
87
|
+
throw new Error("user_id 为必填参数");
|
|
88
|
+
}
|
|
89
|
+
const windowMinutes = input.window_minutes ?? 5;
|
|
90
|
+
// 优先用 repo 提供的 atomic 版本(sqlite production path)
|
|
91
|
+
if (typeof repo.claimNudgeAtomic === "function") {
|
|
92
|
+
return repo.claimNudgeAtomic({
|
|
93
|
+
user_id: input.user_id,
|
|
94
|
+
window_minutes: windowMinutes,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// 兼容降级(in-memory / 测试):非原子,无并发安全保证
|
|
98
|
+
// ⚠️ §4.5 声明:此分支不是 atomic,仅用于无并发的测试场景
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.warn("[claimNudge] repo.claimNudgeAtomic 未实现,降级到非原子路径。" +
|
|
101
|
+
"仅测试场景安全,生产必须用 SqliteProfileRepo。");
|
|
102
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
103
|
+
const now = new Date();
|
|
104
|
+
const nowIso = now.toISOString();
|
|
105
|
+
const existing = await repo.getById(input.user_id);
|
|
106
|
+
const existingSettings = existing?.settings != null
|
|
107
|
+
? existing.settings
|
|
108
|
+
: {};
|
|
109
|
+
const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
|
|
110
|
+
if (typeof lastNudgeAt === "string") {
|
|
111
|
+
const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
|
|
112
|
+
if (ageMs < windowMs) {
|
|
113
|
+
return { won: false, profile: existing };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const merged = {
|
|
117
|
+
...existingSettings,
|
|
118
|
+
last_inactivity_nudge_at: nowIso,
|
|
119
|
+
};
|
|
120
|
+
const profile = await repo.upsert({
|
|
121
|
+
user_id: input.user_id,
|
|
122
|
+
settings: merged,
|
|
123
|
+
});
|
|
124
|
+
return { won: true, profile };
|
|
125
|
+
},
|
|
126
|
+
/**
|
|
127
|
+
* S41(2026-08-06):建新 libero 用户(管理员代管模式)。
|
|
128
|
+
*
|
|
129
|
+
* 产品定位:「帮我建一个新用户」「我要给我儿子建档」时调用。
|
|
130
|
+
* 无 user_id:不查重(同名不冲突)。有 user_id(家长):同名孩子复用并自动 setManager。
|
|
131
|
+
*
|
|
132
|
+
* 必须传 identityRepo 才可用(slice 1:server-core 启动时注入)。
|
|
133
|
+
*
|
|
134
|
+
* 输入校验(§4.5 显式):
|
|
135
|
+
* - display_name 必填非空
|
|
136
|
+
* - platform 和 platform_user_id 必须同时给或同时不给
|
|
137
|
+
*
|
|
138
|
+
* 副作用:建 identity → 建 profile(空白)→ (可选)绑 alias
|
|
139
|
+
* 失败语义:identity 建成功但 profile 建失败 → 抛错,identity 留在表里
|
|
140
|
+
* (可接受:identity 没有 profile 也能用,下次 profile.get 会触发 auto-create;
|
|
141
|
+
* 不做事务回滚是 §3 #4 最小可逆单位原则。)
|
|
142
|
+
*/
|
|
143
|
+
async createUser(input) {
|
|
144
|
+
if (!identityRepo) {
|
|
145
|
+
throw new Error("createUser 需要 identityRepo(server-core 启动时未注入,本部署不支持多用户建档)");
|
|
146
|
+
}
|
|
147
|
+
if (!input.display_name || input.display_name.trim() === "") {
|
|
148
|
+
throw new Error("display_name 为必填参数且不能为空");
|
|
149
|
+
}
|
|
150
|
+
const display_name = input.display_name.trim();
|
|
151
|
+
const hasPlatform = !!input.platform;
|
|
152
|
+
const hasPlatformUserId = !!input.platform_user_id;
|
|
153
|
+
if (hasPlatform !== hasPlatformUserId) {
|
|
154
|
+
throw new Error("platform 和 platform_user_id 必须同时给或同时不给(绑定平台 alias 时两者都需要)");
|
|
155
|
+
}
|
|
156
|
+
const managerId = input.user_id?.trim() || "";
|
|
157
|
+
if (managerId) {
|
|
158
|
+
if (!(await identityRepo.getIdentity(managerId))) {
|
|
159
|
+
await identityRepo.createIdentity({
|
|
160
|
+
libero_user_id: managerId,
|
|
161
|
+
display_name: managerId,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const all = await identityRepo.listAllIdentities();
|
|
165
|
+
const mine = all.filter((i) => (i.display_name ?? "").trim() === display_name &&
|
|
166
|
+
i.managed_by === managerId);
|
|
167
|
+
const orphans = all.filter((i) => (i.display_name ?? "").trim() === display_name &&
|
|
168
|
+
!i.managed_by &&
|
|
169
|
+
i.libero_user_id !== managerId);
|
|
170
|
+
const hit = mine.at(-1) ?? (await pickReusableOrphan(repo, orphans));
|
|
171
|
+
if (hit) {
|
|
172
|
+
const settings = {};
|
|
173
|
+
if (input.focus_areas && input.focus_areas.length > 0) {
|
|
174
|
+
settings.focus_areas = input.focus_areas;
|
|
175
|
+
}
|
|
176
|
+
if (input.goals && input.goals.trim() !== "") {
|
|
177
|
+
settings.goals = input.goals;
|
|
178
|
+
}
|
|
179
|
+
const existing = await repo.getById(hit.libero_user_id);
|
|
180
|
+
const merged = {
|
|
181
|
+
...(existing?.settings ?? {}),
|
|
182
|
+
...settings,
|
|
183
|
+
};
|
|
184
|
+
await repo.upsert({
|
|
185
|
+
user_id: hit.libero_user_id,
|
|
186
|
+
display_name,
|
|
187
|
+
settings: Object.keys(merged).length > 0 ? merged : undefined,
|
|
188
|
+
});
|
|
189
|
+
if (hit.managed_by !== managerId) {
|
|
190
|
+
await identityRepo.setManager({
|
|
191
|
+
libero_user_id: hit.libero_user_id,
|
|
192
|
+
managed_by: managerId,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
await dirty(hit.libero_user_id);
|
|
196
|
+
await dirty(managerId);
|
|
197
|
+
return {
|
|
198
|
+
libero_user_id: hit.libero_user_id,
|
|
199
|
+
display_name,
|
|
200
|
+
reused: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const libero_user_id = `u_libero_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
205
|
+
await identityRepo.createIdentity({
|
|
206
|
+
libero_user_id,
|
|
207
|
+
display_name,
|
|
208
|
+
});
|
|
209
|
+
const settings = {};
|
|
210
|
+
if (input.focus_areas && input.focus_areas.length > 0) {
|
|
211
|
+
settings.focus_areas = input.focus_areas;
|
|
212
|
+
}
|
|
213
|
+
if (input.goals && input.goals.trim() !== "") {
|
|
214
|
+
settings.goals = input.goals;
|
|
215
|
+
}
|
|
216
|
+
await repo.upsert({
|
|
217
|
+
user_id: libero_user_id,
|
|
218
|
+
display_name,
|
|
219
|
+
settings: Object.keys(settings).length > 0 ? settings : undefined,
|
|
220
|
+
});
|
|
221
|
+
await dirty(libero_user_id);
|
|
222
|
+
if (managerId) {
|
|
223
|
+
await identityRepo.setManager({
|
|
224
|
+
libero_user_id,
|
|
225
|
+
managed_by: managerId,
|
|
226
|
+
});
|
|
227
|
+
await dirty(managerId);
|
|
228
|
+
}
|
|
229
|
+
let bound_alias;
|
|
230
|
+
if (hasPlatform && hasPlatformUserId) {
|
|
231
|
+
await identityRepo.bindAlias({
|
|
232
|
+
libero_user_id,
|
|
233
|
+
platform: input.platform,
|
|
234
|
+
platform_user_id: input.platform_user_id,
|
|
235
|
+
chat_id: input.chat_id,
|
|
236
|
+
});
|
|
237
|
+
bound_alias = {
|
|
238
|
+
platform: input.platform,
|
|
239
|
+
platform_user_id: input.platform_user_id,
|
|
240
|
+
chat_id: input.chat_id ?? null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
libero_user_id,
|
|
245
|
+
display_name,
|
|
246
|
+
bound_alias,
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
/**
|
|
250
|
+
* S43(2026-08-07):列出所有 libero 用户。
|
|
251
|
+
* 用途:扫描 cron 跑所有用户的状态升级和主动询问(不靠 LLM 从上下文猜)。
|
|
252
|
+
*
|
|
253
|
+
* 数据源:identity 表(libero 内部身份)。display_name 取 identity 表的。
|
|
254
|
+
* §4.5 静默折叠声明:返回所有 identity 行;若 identityRepo 未注入则抛(不静默返回空)。
|
|
255
|
+
*/
|
|
256
|
+
async listAllUsers() {
|
|
257
|
+
if (!identityRepo) {
|
|
258
|
+
throw new Error("listAllUsers 需要 identityRepo(server-core 启动时未注入,本部署不支持多用户扫描)");
|
|
259
|
+
}
|
|
260
|
+
const identities = await identityRepo.listAllIdentities();
|
|
261
|
+
return identities.map((i) => ({
|
|
262
|
+
libero_user_id: i.libero_user_id,
|
|
263
|
+
display_name: i.display_name,
|
|
264
|
+
}));
|
|
265
|
+
},
|
|
266
|
+
/**
|
|
267
|
+
* S44(2026-08-07):查某用户的代管人。
|
|
268
|
+
* 用途:扫描 cron 发现用户没自己渠道时,查 managed_by 把追问发给代管人。
|
|
269
|
+
*/
|
|
270
|
+
async getManager(input) {
|
|
271
|
+
if (!identityRepo) {
|
|
272
|
+
throw new Error("getManager 需要 identityRepo(本部署不支持代管转发)");
|
|
273
|
+
}
|
|
274
|
+
if (!input.user_id) {
|
|
275
|
+
throw new Error("user_id 为必填参数");
|
|
276
|
+
}
|
|
277
|
+
return identityRepo.getManager(input.user_id);
|
|
278
|
+
},
|
|
279
|
+
/**
|
|
280
|
+
* S44(2026-08-07):设置/清除某用户的代管人。
|
|
281
|
+
* managed_by=null 清除;非 null 必须指向存在的 libero_user_id。
|
|
282
|
+
*/
|
|
283
|
+
async setManager(input) {
|
|
284
|
+
if (!identityRepo) {
|
|
285
|
+
throw new Error("setManager 需要 identityRepo(本部署不支持代管转发)");
|
|
286
|
+
}
|
|
287
|
+
if (!input.user_id) {
|
|
288
|
+
throw new Error("user_id 为必填参数");
|
|
289
|
+
}
|
|
290
|
+
const result = await identityRepo.setManager({
|
|
291
|
+
libero_user_id: input.user_id,
|
|
292
|
+
managed_by: input.managed_by,
|
|
293
|
+
});
|
|
294
|
+
await dirty(input.user_id);
|
|
295
|
+
return result;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { platformDefaultDisplayName } from "./profile-helpers.js";
|
|
2
|
+
function buildNeedsConfirm(platformUserId) {
|
|
3
|
+
return {
|
|
4
|
+
status: "needs_confirm",
|
|
5
|
+
platform_user_id: platformUserId,
|
|
6
|
+
suggested_display_name: platformDefaultDisplayName(platformUserId),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Check user_profile for platformUserId.
|
|
11
|
+
* @returns null if confirmed/exists; NeedsConfirm if missing (caller must not write).
|
|
12
|
+
*/
|
|
13
|
+
export function assertProfileConfirmed(db, platformUserId) {
|
|
14
|
+
const row = db
|
|
15
|
+
.prepare(`SELECT id FROM user_profile WHERE id = ? AND deleted_at IS NULL`)
|
|
16
|
+
.get(platformUserId);
|
|
17
|
+
if (row)
|
|
18
|
+
return null;
|
|
19
|
+
return buildNeedsConfirm(platformUserId);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Same gate when caller only has a boolean from repo.userExists().
|
|
23
|
+
*/
|
|
24
|
+
export function needsConfirmIfMissing(exists, platformUserId) {
|
|
25
|
+
if (exists)
|
|
26
|
+
return null;
|
|
27
|
+
return buildNeedsConfirm(platformUserId);
|
|
28
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Slice 2: effective user = target_user_id ?? user_id */
|
|
2
|
+
export function effectiveUserId(input) {
|
|
3
|
+
return input.target_user_id ?? input.user_id;
|
|
4
|
+
}
|
|
5
|
+
/** Slice 5: label for brain replies — display_name or last 4 of id */
|
|
6
|
+
export function resolveTargetUserLabel(userId, displayName) {
|
|
7
|
+
const name = displayName?.trim();
|
|
8
|
+
if (name)
|
|
9
|
+
return name;
|
|
10
|
+
return userId.slice(-4) || userId;
|
|
11
|
+
}
|
|
12
|
+
export function lookupTargetUserLabel(db, userId) {
|
|
13
|
+
const row = db
|
|
14
|
+
.prepare(`SELECT display_name FROM user_profile WHERE id = ? AND deleted_at IS NULL`)
|
|
15
|
+
.get(userId);
|
|
16
|
+
return resolveTargetUserLabel(userId, row?.display_name);
|
|
17
|
+
}
|
|
18
|
+
function rowToProfile(row) {
|
|
19
|
+
return {
|
|
20
|
+
id: row.id,
|
|
21
|
+
display_name: row.display_name,
|
|
22
|
+
expectations: row.expectations,
|
|
23
|
+
settings: row.settings != null
|
|
24
|
+
? JSON.parse(row.settings)
|
|
25
|
+
: null,
|
|
26
|
+
created_at: row.created_at,
|
|
27
|
+
updated_at: row.updated_at,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Platform default display_name: e.g. wecom:woqoa1b2xyz → wecom_2xyz */
|
|
31
|
+
export function platformDefaultDisplayName(platformUserId) {
|
|
32
|
+
const colon = platformUserId.indexOf(":");
|
|
33
|
+
if (colon > 0) {
|
|
34
|
+
const platform = platformUserId.slice(0, colon);
|
|
35
|
+
const rest = platformUserId.slice(colon + 1);
|
|
36
|
+
const suffix = rest.slice(-4) || rest || "user";
|
|
37
|
+
return `${platform}_${suffix}`;
|
|
38
|
+
}
|
|
39
|
+
const suffix = platformUserId.slice(-4) || platformUserId || "user";
|
|
40
|
+
return suffix;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Exact match on display_name.
|
|
44
|
+
* 0 → null; 1 → Profile; 2+ → Profile[] (diagnostic).
|
|
45
|
+
*/
|
|
46
|
+
export function findProfileByDisplayName(db, displayName) {
|
|
47
|
+
const rows = db
|
|
48
|
+
.prepare(`SELECT id, display_name, expectations, settings, created_at, updated_at
|
|
49
|
+
FROM user_profile
|
|
50
|
+
WHERE display_name = ? AND deleted_at IS NULL`)
|
|
51
|
+
.all(displayName);
|
|
52
|
+
if (rows.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
if (rows.length === 1)
|
|
55
|
+
return rowToProfile(rows[0]);
|
|
56
|
+
return rows.map(rowToProfile);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensure a profile row exists for platformUserId (PK = platform id).
|
|
60
|
+
* Missing display_name → fill platform default (e.g. wecom_a1b2).
|
|
61
|
+
*/
|
|
62
|
+
export function getOrCreateDefaultProfile(db, platformUserId) {
|
|
63
|
+
const existing = db
|
|
64
|
+
.prepare(`SELECT id, display_name, expectations, settings, created_at, updated_at
|
|
65
|
+
FROM user_profile
|
|
66
|
+
WHERE id = ? AND deleted_at IS NULL`)
|
|
67
|
+
.get(platformUserId);
|
|
68
|
+
if (existing) {
|
|
69
|
+
if (!existing.display_name || existing.display_name.trim() === "") {
|
|
70
|
+
const filled = platformDefaultDisplayName(platformUserId);
|
|
71
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
72
|
+
db.prepare(`UPDATE user_profile SET display_name = ?, updated_at = ? WHERE id = ?`).run(filled, now, platformUserId);
|
|
73
|
+
return rowToProfile({ ...existing, display_name: filled, updated_at: now });
|
|
74
|
+
}
|
|
75
|
+
return rowToProfile(existing);
|
|
76
|
+
}
|
|
77
|
+
const displayName = platformDefaultDisplayName(platformUserId);
|
|
78
|
+
const now = new Date().toISOString().replace("T", " ").replace("Z", "");
|
|
79
|
+
db.prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
|
|
80
|
+
VALUES (?, ?, NULL, NULL, ?, ?)`).run(platformUserId, displayName, now, now);
|
|
81
|
+
return {
|
|
82
|
+
id: platformUserId,
|
|
83
|
+
display_name: displayName,
|
|
84
|
+
expectations: null,
|
|
85
|
+
settings: null,
|
|
86
|
+
created_at: now,
|
|
87
|
+
updated_at: now,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve target display name → user_id.
|
|
92
|
+
* platformUserId is reserved for caller context (not used for matching —
|
|
93
|
+
* display_name uniqueness is enforced by confirm_default).
|
|
94
|
+
*/
|
|
95
|
+
export function resolveTargetUser(db, input) {
|
|
96
|
+
void input.platformUserId;
|
|
97
|
+
const found = findProfileByDisplayName(db, input.targetDisplayName);
|
|
98
|
+
if (found === null) {
|
|
99
|
+
return { kind: "not_found", query: input.targetDisplayName };
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(found)) {
|
|
102
|
+
return { kind: "ambiguous", matches: found };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
kind: "resolved",
|
|
106
|
+
userId: found.id,
|
|
107
|
+
displayName: found.display_name,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HermesChannel — delivers reminders via `hermes send -t <target>` (T2).
|
|
3
|
+
*
|
|
4
|
+
* Uses CommandRunner abstraction (default: child_process.spawn, arrays only — no shell injection).
|
|
5
|
+
* DI `run` for testability; env HERMES_BIN overrides the binary path.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
/** Default runner: spawn `hermes` (HERMES_BIN override or PATH) with array args. */
|
|
9
|
+
function defaultRunner(args) {
|
|
10
|
+
const bin = process.env.HERMES_BIN || "hermes";
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(bin, args, { stdio: "ignore" });
|
|
13
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
14
|
+
child.on("error", () => resolve(1));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export class HermesChannel {
|
|
18
|
+
defaultTarget;
|
|
19
|
+
run;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.defaultTarget = opts?.defaultTarget;
|
|
22
|
+
this.run = opts?.run ?? defaultRunner;
|
|
23
|
+
}
|
|
24
|
+
async send(r) {
|
|
25
|
+
// LLM 注入优先:只有 platform:address 格式(含 ':')才算有效 direct target。
|
|
26
|
+
// 'session' / '' / 无 ':' → 视为无 direct target,走 defaultTarget 常量兜底。
|
|
27
|
+
const direct = r.channel && r.channel.includes(":") ? r.channel : null;
|
|
28
|
+
const target = direct ?? this.defaultTarget;
|
|
29
|
+
// §4.5 静默折叠:无送达目标 → warn + return false。
|
|
30
|
+
// 吞掉的原因:reminder 可能由无渠道上下文的途径创建(如 session fallback),
|
|
31
|
+
// 或 schema default='session' 且 LLM 未注入有效 channel。
|
|
32
|
+
if (!target) {
|
|
33
|
+
console.warn(`[HermesChannel] no target for reminder ${r.id}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const code = await this.run(["send", "-t", target, r.content]);
|
|
37
|
+
// 不抛——失败交 reminder-tick 的 releaseOrFail 重试/死信。
|
|
38
|
+
return code === 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { nowLocal } from "../clock/index.js";
|
|
2
|
+
import { needsConfirmIfMissing, } from "../profile-confirm.js";
|
|
3
|
+
import { lookupTargetUserLabel, resolveTargetUserLabel, } from "../profile-helpers.js";
|
|
4
|
+
/**
|
|
5
|
+
* Validate and delegate reminder operations to the repository.
|
|
6
|
+
* ADR-0001: Dependency-injected — the caller provides the repo.
|
|
7
|
+
*
|
|
8
|
+
* @param repo ReminderRepo implementation
|
|
9
|
+
* @param getNow Clock injection for determinism; defaults to clock.nowLocal()
|
|
10
|
+
* @param db optional — enables display_name lookup for target_user_label
|
|
11
|
+
*/
|
|
12
|
+
export function createReminderTools(repo, getNow, db) {
|
|
13
|
+
const now = getNow ?? (() => nowLocal());
|
|
14
|
+
const create = async (input) => {
|
|
15
|
+
if (!input.content || input.content.trim() === "") {
|
|
16
|
+
throw new Error("提醒内容不能为空");
|
|
17
|
+
}
|
|
18
|
+
// 2026-09-08 修复:曾用字符串比较,Z 结尾 UTC 会被误判"早于当前时间"(该坑
|
|
19
|
+
// 已写进 WorkBuddy SKILL 当铁律)。改为时间戳比较,任意 ISO 时区格式均可。
|
|
20
|
+
const remindAtMs = Date.parse(input.remind_at);
|
|
21
|
+
if (Number.isNaN(remindAtMs)) {
|
|
22
|
+
throw new Error("remind_at 无法解析为时间(需 ISO 8601)");
|
|
23
|
+
}
|
|
24
|
+
if (remindAtMs <= Date.parse(now())) {
|
|
25
|
+
throw new Error("提醒时间不能早于当前时间");
|
|
26
|
+
}
|
|
27
|
+
const userId = input.target_user_id ?? input.user_id;
|
|
28
|
+
const userOk = await repo.userExists(userId);
|
|
29
|
+
const gate = needsConfirmIfMissing(userOk, userId);
|
|
30
|
+
if (gate)
|
|
31
|
+
return gate;
|
|
32
|
+
const row = await repo.create({
|
|
33
|
+
user_id: userId,
|
|
34
|
+
content: input.content,
|
|
35
|
+
remind_at: input.remind_at,
|
|
36
|
+
channel: input.channel,
|
|
37
|
+
timeblock_id: input.timeblock_id,
|
|
38
|
+
});
|
|
39
|
+
const label = db
|
|
40
|
+
? lookupTargetUserLabel(db, userId)
|
|
41
|
+
: resolveTargetUserLabel(userId);
|
|
42
|
+
return { ...row, target_user_label: label };
|
|
43
|
+
};
|
|
44
|
+
const getDueReminders = async (input) => {
|
|
45
|
+
return repo.getDueReminders({
|
|
46
|
+
user_id: input.user_id,
|
|
47
|
+
until: input.until ?? now(),
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
const getNextDue = async (input) => {
|
|
51
|
+
return repo.getNextDue(input ?? {});
|
|
52
|
+
};
|
|
53
|
+
const getMissedReminders = async (input) => {
|
|
54
|
+
return repo.getMissedReminders({ ...input, now: now() });
|
|
55
|
+
};
|
|
56
|
+
const markSent = async (id) => {
|
|
57
|
+
return repo.markSent(id);
|
|
58
|
+
};
|
|
59
|
+
const cancelReminder = async (id) => {
|
|
60
|
+
return repo.cancelReminder(id);
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
create,
|
|
64
|
+
getDueReminders,
|
|
65
|
+
getNextDue,
|
|
66
|
+
getMissedReminders,
|
|
67
|
+
markSent,
|
|
68
|
+
cancelReminder,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogChannel — development fallback. Prints to console, always returns true.
|
|
3
|
+
* No real delivery; used when no credentials are configured.
|
|
4
|
+
*/
|
|
5
|
+
export class LogChannel {
|
|
6
|
+
async send(r) {
|
|
7
|
+
console.log(`[reminder] ${r.content} @ ${r.remind_at}`);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SqliteCategoryRepo, SqliteTimeBlockRepo, SqliteStatsRepo, SqliteTimerRepo, SqliteProfileRepo, SqliteReminderRepo, SqliteTimelineRepo, } from "./sqlite.js";
|
|
2
|
+
import { SqliteIdentityRepo } from "./sqlite-identity.js";
|
|
3
|
+
import { SqliteApRepo } from "./sqlite-ap.js";
|
|
4
|
+
/**
|
|
5
|
+
* Build the full repo set for a given Database connection.
|
|
6
|
+
*
|
|
7
|
+
* impl resolution order: opts.impl → process.env.LIBERO_REPO_IMPL → 'sqlite'.
|
|
8
|
+
* Throws on any non-'sqlite' value (Postgres is a placeholder, not yet implemented).
|
|
9
|
+
*/
|
|
10
|
+
export function createRepos(db, opts) {
|
|
11
|
+
const impl = resolveImpl(opts);
|
|
12
|
+
if (impl !== "sqlite") {
|
|
13
|
+
throw new Error(`LIBERO_REPO_IMPL='${impl}' not implemented yet (only 'sqlite' supported)`);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
categoryRepo: new SqliteCategoryRepo(db),
|
|
17
|
+
timeblockRepo: new SqliteTimeBlockRepo(db),
|
|
18
|
+
statsRepo: new SqliteStatsRepo(db),
|
|
19
|
+
timerRepo: new SqliteTimerRepo(db),
|
|
20
|
+
profileRepo: new SqliteProfileRepo(db),
|
|
21
|
+
reminderRepo: new SqliteReminderRepo(db),
|
|
22
|
+
timelineRepo: new SqliteTimelineRepo(db),
|
|
23
|
+
identityRepo: new SqliteIdentityRepo(db),
|
|
24
|
+
apRepo: new SqliteApRepo(db),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function resolveImpl(opts) {
|
|
28
|
+
if (opts?.impl)
|
|
29
|
+
return opts.impl;
|
|
30
|
+
const fromEnv = process.env.LIBERO_REPO_IMPL;
|
|
31
|
+
if (fromEnv)
|
|
32
|
+
return fromEnv;
|
|
33
|
+
return "sqlite";
|
|
34
|
+
}
|