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,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* libero MCP server — stdio transport entry point (S10 §3.3).
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* libero-mcp # 默认 DB 在 ~/.libero/libero.db
|
|
7
|
+
* DB_PATH=/path/to/libero.db libero-mcp # 自定义 DB 路径
|
|
8
|
+
*/
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { initDb, closeDb } from "../data/db.js";
|
|
13
|
+
import { buildServer } from "./server-core.js";
|
|
14
|
+
// 默认 DB 在用户家目录的 ~/.libero/libero.db(跨平台、不污染 cwd、符合 XDG-ish 约定)
|
|
15
|
+
// 2026-07-17:原默认 "./data/libero.db" 是相对路径,hermes 启动 libero-mcp 时 cwd 无 ./data/
|
|
16
|
+
// → better-sqlite3 抛 SQLITE_CANTOPEN → 用户看到"缺少数据库目录"。改绝对路径根治。
|
|
17
|
+
const DB_PATH = process.env.DB_PATH ?? join(homedir(), ".libero", "libero.db");
|
|
18
|
+
if (!DB_PATH) {
|
|
19
|
+
console.error("FATAL: DB_PATH environment variable not set.");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const db = initDb(DB_PATH);
|
|
23
|
+
console.error(`[libero-mcp] DB initialized at ${DB_PATH}`);
|
|
24
|
+
const server = buildServer(db);
|
|
25
|
+
const transport = new StdioServerTransport();
|
|
26
|
+
await server.connect(transport);
|
|
27
|
+
console.error(`[libero-mcp] Server connected via stdio`);
|
|
28
|
+
function shutdown() {
|
|
29
|
+
console.error("[libero-mcp] Shutting down…");
|
|
30
|
+
closeDb();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
process.on("SIGINT", shutdown);
|
|
34
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function createApTools(repo) {
|
|
2
|
+
return {
|
|
3
|
+
credit(input) {
|
|
4
|
+
return repo.credit(input);
|
|
5
|
+
},
|
|
6
|
+
async balance(input) {
|
|
7
|
+
const balance = await repo.balance(input.user_id);
|
|
8
|
+
return { balance };
|
|
9
|
+
},
|
|
10
|
+
spend(input) {
|
|
11
|
+
return repo.spend(input);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INT-ap-rewards — 换算曲线纯逻辑(无 IO)。
|
|
3
|
+
* Spec: libero-homm/docs/specs/INT-ap-rewards.md §3/§4
|
|
4
|
+
* 原则:单类型第 n 次递减(高频归零防刷,低频保底);多类型独立计数;周期跟动作频率。
|
|
5
|
+
* 数值全部占位可调,调参依据 = 粘性数据,不拍脑袋。
|
|
6
|
+
*/
|
|
7
|
+
export const REWARD_CONFIG = {
|
|
8
|
+
timer: { period: "daily", curve: [5, 4, 3, 2, 1], floor: 0 },
|
|
9
|
+
schedule: { period: "daily", curve: [4, 3, 2, 1], floor: 0 },
|
|
10
|
+
task: { period: "daily", curve: [3, 2, 1], floor: 0 },
|
|
11
|
+
review: { period: "weekly", curve: [5, 3, 1], floor: 1 },
|
|
12
|
+
};
|
|
13
|
+
export const BOUNTY_QUESTS = [
|
|
14
|
+
{ id: "timer-1", action_type: "timer", target: 1, bonus: 3 },
|
|
15
|
+
{ id: "block-3", action_type: "schedule", target: 3, bonus: 3 },
|
|
16
|
+
{ id: "review-1", action_type: "review", target: 1, bonus: 3 },
|
|
17
|
+
];
|
|
18
|
+
export function rewardForNth(type, nth) {
|
|
19
|
+
const cfg = REWARD_CONFIG[type];
|
|
20
|
+
return nth <= cfg.curve.length ? cfg.curve[nth - 1] : cfg.floor;
|
|
21
|
+
}
|
|
22
|
+
/** 计时实绩门槛(分钟):默认 10,env 可调(调参旋钮,spec §2 用户拍板) */
|
|
23
|
+
export function timerMinMinutes(env = process.env) {
|
|
24
|
+
const v = Number(env.LIBERO_REWARD_TIMER_MIN);
|
|
25
|
+
return Number.isFinite(v) && v > 0 ? v : 10;
|
|
26
|
+
}
|
|
27
|
+
export function dayKey(d) {
|
|
28
|
+
const y = d.getFullYear();
|
|
29
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
30
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
31
|
+
return `D:${y}-${m}-${day}`;
|
|
32
|
+
}
|
|
33
|
+
/** ISO 周(周一为周首,含 1/1 的周归属周四所在年) */
|
|
34
|
+
export function isoWeekKey(d) {
|
|
35
|
+
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
36
|
+
const dayNum = date.getUTCDay() || 7; // 周日=7
|
|
37
|
+
date.setUTCDate(date.getUTCDate() + 4 - dayNum); // 最近周四
|
|
38
|
+
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
|
39
|
+
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
40
|
+
return `W:${date.getUTCFullYear()}-${String(week).padStart(2, "0")}`;
|
|
41
|
+
}
|
|
42
|
+
export function periodKeyFor(type, now) {
|
|
43
|
+
return REWARD_CONFIG[type].period === "daily" ? dayKey(now) : isoWeekKey(now);
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Create categories tools backed by the given repository.
|
|
2
|
+
* ADR-0001: Dependency-injected — the caller provides the repo, not getDb(). */
|
|
3
|
+
export function createCategoryTools(repo) {
|
|
4
|
+
return {
|
|
5
|
+
async getAllCategories() {
|
|
6
|
+
return repo.getAll();
|
|
7
|
+
},
|
|
8
|
+
async getCategoryById(id) {
|
|
9
|
+
return repo.getById(id);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export function chatLlmFromEnv(env = process.env) {
|
|
2
|
+
const baseUrl = env.LIBERO_CHAT_BASE_URL?.trim();
|
|
3
|
+
const apiKey = env.LIBERO_CHAT_API_KEY?.trim();
|
|
4
|
+
const model = env.LIBERO_CHAT_MODEL?.trim();
|
|
5
|
+
if (!baseUrl || !apiKey || !model)
|
|
6
|
+
return null;
|
|
7
|
+
return new OpenAiCompatChatLlm({ baseUrl, apiKey, model });
|
|
8
|
+
}
|
|
9
|
+
function toOpenAiMessages(messages) {
|
|
10
|
+
return messages.map((m) => {
|
|
11
|
+
if (m.role === "tool") {
|
|
12
|
+
return {
|
|
13
|
+
role: "tool",
|
|
14
|
+
tool_call_id: m.toolCallId,
|
|
15
|
+
name: m.name,
|
|
16
|
+
content: m.content,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (m.role === "assistant") {
|
|
20
|
+
const tool_calls = m.toolCalls?.map((c) => ({
|
|
21
|
+
id: c.id,
|
|
22
|
+
type: "function",
|
|
23
|
+
function: {
|
|
24
|
+
name: c.name,
|
|
25
|
+
arguments: JSON.stringify(c.arguments ?? {}),
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
return {
|
|
29
|
+
role: "assistant",
|
|
30
|
+
content: m.content,
|
|
31
|
+
...(tool_calls && tool_calls.length > 0 ? { tool_calls } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return { role: m.role, content: m.content };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function parseArgs(raw) {
|
|
38
|
+
if (typeof raw === "string") {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
export class OpenAiCompatChatLlm {
|
|
56
|
+
cfg;
|
|
57
|
+
constructor(cfg) {
|
|
58
|
+
this.cfg = cfg;
|
|
59
|
+
}
|
|
60
|
+
async complete(input) {
|
|
61
|
+
const url = `${this.cfg.baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timeout = setTimeout(() => controller.abort(), 90000);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
Authorization: `Bearer ${this.cfg.apiKey}`,
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
model: this.cfg.model,
|
|
73
|
+
messages: toOpenAiMessages(input.messages),
|
|
74
|
+
tools: input.tools.map((t) => ({
|
|
75
|
+
type: "function",
|
|
76
|
+
function: {
|
|
77
|
+
name: t.name,
|
|
78
|
+
description: t.description,
|
|
79
|
+
parameters: t.parameters ?? {
|
|
80
|
+
type: "object",
|
|
81
|
+
additionalProperties: true,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
})),
|
|
85
|
+
}),
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const detail = (await res.text()).slice(0, 240);
|
|
90
|
+
throw new Error(`chat llm HTTP ${res.status}${detail ? `: ${detail}` : ""}`);
|
|
91
|
+
}
|
|
92
|
+
const data = (await res.json());
|
|
93
|
+
const msg = data.choices?.[0]?.message;
|
|
94
|
+
const toolCalls = (msg?.tool_calls ?? [])
|
|
95
|
+
.filter((c) => c.function?.name)
|
|
96
|
+
.map((c, i) => ({
|
|
97
|
+
id: c.id || `call_${i}`,
|
|
98
|
+
name: c.function.name,
|
|
99
|
+
arguments: parseArgs(c.function.arguments),
|
|
100
|
+
}));
|
|
101
|
+
const text = msg?.content?.trim() ? msg.content : undefined;
|
|
102
|
+
return {
|
|
103
|
+
...(text ? { text } : {}),
|
|
104
|
+
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** 20 轮 = 40 条 user/assistant。ponytail: 截断不摘要,爆了再上 summarizer。 */
|
|
2
|
+
export const CHAT_HISTORY_MAX_MESSAGES = 40;
|
|
3
|
+
export function loadRecent(db, conversationKey, limit = CHAT_HISTORY_MAX_MESSAGES) {
|
|
4
|
+
const rows = db
|
|
5
|
+
.prepare("SELECT role, content FROM chat_turns WHERE conversation_key = ? ORDER BY id DESC LIMIT ?")
|
|
6
|
+
.all(conversationKey, limit);
|
|
7
|
+
rows.reverse();
|
|
8
|
+
return rows.map((r) => {
|
|
9
|
+
if (r.role === "assistant") {
|
|
10
|
+
return { role: "assistant", content: r.content };
|
|
11
|
+
}
|
|
12
|
+
return { role: "user", content: r.content };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function appendTurn(db, conversationKey, userText, assistantText) {
|
|
16
|
+
const now = new Date().toISOString();
|
|
17
|
+
const ins = db.prepare("INSERT INTO chat_turns (conversation_key, role, content, created_at) VALUES (?, ?, ?, ?)");
|
|
18
|
+
const prune = db.prepare(`
|
|
19
|
+
DELETE FROM chat_turns
|
|
20
|
+
WHERE conversation_key = ?
|
|
21
|
+
AND id NOT IN (
|
|
22
|
+
SELECT id FROM (
|
|
23
|
+
SELECT id FROM chat_turns
|
|
24
|
+
WHERE conversation_key = ?
|
|
25
|
+
ORDER BY id DESC
|
|
26
|
+
LIMIT ?
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
`);
|
|
30
|
+
db.transaction(() => {
|
|
31
|
+
ins.run(conversationKey, "user", userText, now);
|
|
32
|
+
ins.run(conversationKey, "assistant", assistantText, now);
|
|
33
|
+
prune.run(conversationKey, conversationKey, CHAT_HISTORY_MAX_MESSAGES);
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { invokeRegisteredTool } from "../rest/invoke-tool.js";
|
|
2
|
+
import { buildChatSystemPrompt } from "./system-prompt.js";
|
|
3
|
+
import { toChatToolParameters } from "./tool-schema.js";
|
|
4
|
+
import { assertUniqueLlmToolNames, fromLlmToolName, toLlmToolName, } from "./tool-names.js";
|
|
5
|
+
import { getDb } from "../../../data/db.js";
|
|
6
|
+
import { appendTurn, loadRecent } from "./history.js";
|
|
7
|
+
import { ensurePortrait } from "./portrait.js";
|
|
8
|
+
function dbOrNull() {
|
|
9
|
+
try {
|
|
10
|
+
return getDb();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export const CHAT_MAX_ROUNDS = 8;
|
|
17
|
+
function isManagedChild(db, managerId, childId) {
|
|
18
|
+
const row = db
|
|
19
|
+
.prepare("SELECT managed_by FROM user_identity WHERE libero_user_id = ?")
|
|
20
|
+
.get(childId);
|
|
21
|
+
return !!row && row.managed_by === managerId;
|
|
22
|
+
}
|
|
23
|
+
export class ChatLoopError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "ChatLoopError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function asRecord(args) {
|
|
30
|
+
if (args && typeof args === "object" && !Array.isArray(args)) {
|
|
31
|
+
return args;
|
|
32
|
+
}
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
export async function runChatTurn(opts) {
|
|
36
|
+
const byName = new Map(opts.registry.map((t) => [t.name, t]));
|
|
37
|
+
const registryNames = opts.registry.map((t) => t.name);
|
|
38
|
+
assertUniqueLlmToolNames(registryNames);
|
|
39
|
+
const tools = opts.registry.map((t) => ({
|
|
40
|
+
name: toLlmToolName(t.name),
|
|
41
|
+
description: `${t.name}: ${t.description}`,
|
|
42
|
+
parameters: toChatToolParameters(t.inputSchema),
|
|
43
|
+
}));
|
|
44
|
+
const db = dbOrNull();
|
|
45
|
+
const prior = db && opts.conversationKey ? loadRecent(db, opts.conversationKey) : [];
|
|
46
|
+
const hommLink = opts.mintLink ? opts.mintLink(opts.userId) : undefined;
|
|
47
|
+
let system = buildChatSystemPrompt(hommLink);
|
|
48
|
+
if (db) {
|
|
49
|
+
const portrait = await ensurePortrait({
|
|
50
|
+
db,
|
|
51
|
+
llm: opts.llm,
|
|
52
|
+
userId: opts.userId,
|
|
53
|
+
});
|
|
54
|
+
if (portrait) {
|
|
55
|
+
system += `\n用户画像(习惯与设置,非近期流水):${portrait}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const messages = [
|
|
59
|
+
{ role: "system", content: system },
|
|
60
|
+
...prior,
|
|
61
|
+
{ role: "user", content: opts.message },
|
|
62
|
+
];
|
|
63
|
+
const toolsCalled = [];
|
|
64
|
+
for (let round = 0; round < CHAT_MAX_ROUNDS; round++) {
|
|
65
|
+
const out = await opts.llm.complete({ messages, tools });
|
|
66
|
+
const calls = out.toolCalls ?? [];
|
|
67
|
+
if (calls.length > 0) {
|
|
68
|
+
messages.push({
|
|
69
|
+
role: "assistant",
|
|
70
|
+
content: out.text ?? null,
|
|
71
|
+
toolCalls: calls,
|
|
72
|
+
});
|
|
73
|
+
for (const call of calls) {
|
|
74
|
+
const toolName = fromLlmToolName(call.name, registryNames);
|
|
75
|
+
toolsCalled.push(toolName);
|
|
76
|
+
// 身份护栏(2026-08-20 生产教训):LLM 不得指定身份——user_id/target_user_id
|
|
77
|
+
// 一律剥掉,由服务端注入。否则计时等写入会落到影子账户(如 "current"),
|
|
78
|
+
// 用户侧表现为"会话丢失、AP 丢失"。
|
|
79
|
+
const rawArgs = asRecord(call.arguments);
|
|
80
|
+
const requestedTarget = typeof rawArgs.target_user_id === "string"
|
|
81
|
+
? rawArgs.target_user_id.trim()
|
|
82
|
+
: "";
|
|
83
|
+
delete rawArgs.user_id;
|
|
84
|
+
delete rawArgs.target_user_id;
|
|
85
|
+
if (requestedTarget &&
|
|
86
|
+
db &&
|
|
87
|
+
isManagedChild(db, opts.userId, requestedTarget)) {
|
|
88
|
+
rawArgs.target_user_id = requestedTarget;
|
|
89
|
+
}
|
|
90
|
+
const invoked = await invokeRegisteredTool({
|
|
91
|
+
byName,
|
|
92
|
+
toolName,
|
|
93
|
+
rawArgs,
|
|
94
|
+
userId: opts.userId,
|
|
95
|
+
});
|
|
96
|
+
const content = invoked.ok
|
|
97
|
+
? JSON.stringify(invoked.data)
|
|
98
|
+
: JSON.stringify({ error: invoked.message, code: invoked.code });
|
|
99
|
+
messages.push({
|
|
100
|
+
role: "tool",
|
|
101
|
+
toolCallId: call.id,
|
|
102
|
+
name: call.name,
|
|
103
|
+
content,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const text = out.text?.trim();
|
|
109
|
+
if (text) {
|
|
110
|
+
if (db && opts.conversationKey) {
|
|
111
|
+
appendTurn(db, opts.conversationKey, opts.message, text);
|
|
112
|
+
}
|
|
113
|
+
return { reply: text, toolsCalled };
|
|
114
|
+
}
|
|
115
|
+
throw new ChatLoopError("llm returned empty reply");
|
|
116
|
+
}
|
|
117
|
+
// 工具环打满时再要一句纯文本,避免微信「刚才没办完(tool loop exceeded)」
|
|
118
|
+
messages.push({
|
|
119
|
+
role: "user",
|
|
120
|
+
content: "停止调工具,用中文直接回复用户:已办成什么、还缺什么确认。",
|
|
121
|
+
});
|
|
122
|
+
const wrap = await opts.llm.complete({ messages, tools: [] });
|
|
123
|
+
const wrapText = wrap.text?.trim();
|
|
124
|
+
if (wrapText) {
|
|
125
|
+
if (db && opts.conversationKey) {
|
|
126
|
+
appendTurn(db, opts.conversationKey, opts.message, wrapText);
|
|
127
|
+
}
|
|
128
|
+
return { reply: wrapText, toolsCalled };
|
|
129
|
+
}
|
|
130
|
+
throw new ChatLoopError("tool loop exceeded");
|
|
131
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export const PORTRAIT_MAX_CHARS = 200;
|
|
2
|
+
export const PORTRAIT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
3
|
+
const ACTIVITY_SETTING_KEYS = new Set(["last_inactivity_nudge_at"]);
|
|
4
|
+
export function clipPortrait(text) {
|
|
5
|
+
return Array.from(text.trim()).slice(0, PORTRAIT_MAX_CHARS).join("");
|
|
6
|
+
}
|
|
7
|
+
export function composePortraitLine(opts) {
|
|
8
|
+
const parts = [opts.display_name];
|
|
9
|
+
const fa = opts.settings.focus_areas;
|
|
10
|
+
if (Array.isArray(fa) && fa.length > 0)
|
|
11
|
+
parts.push(fa.map(String).join("、"));
|
|
12
|
+
if (typeof opts.settings.goals === "string" && opts.settings.goals.trim()) {
|
|
13
|
+
parts.push(opts.settings.goals.trim());
|
|
14
|
+
}
|
|
15
|
+
if (opts.expectations?.trim())
|
|
16
|
+
parts.push(opts.expectations.trim());
|
|
17
|
+
for (const c of opts.children ?? []) {
|
|
18
|
+
const bits = [c.display_name?.trim() || "代管对象"];
|
|
19
|
+
const cfa = c.settings.focus_areas;
|
|
20
|
+
if (Array.isArray(cfa) && cfa.length > 0)
|
|
21
|
+
bits.push(cfa.map(String).join("、"));
|
|
22
|
+
if (typeof c.settings.goals === "string" && c.settings.goals.trim()) {
|
|
23
|
+
bits.push(c.settings.goals.trim());
|
|
24
|
+
}
|
|
25
|
+
parts.push(`代管${bits.join(" ")}`);
|
|
26
|
+
}
|
|
27
|
+
return clipPortrait(parts.join("|"));
|
|
28
|
+
}
|
|
29
|
+
export function settingsForPortrait(settings) {
|
|
30
|
+
if (!settings)
|
|
31
|
+
return {};
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of Object.entries(settings)) {
|
|
34
|
+
if (ACTIVITY_SETTING_KEYS.has(k) || v == null)
|
|
35
|
+
continue;
|
|
36
|
+
out[k] = v;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export function needsPortraitRefresh(row, now = new Date()) {
|
|
41
|
+
if (row.dirty)
|
|
42
|
+
return true;
|
|
43
|
+
if (!row.portrait)
|
|
44
|
+
return false;
|
|
45
|
+
if (!row.refreshedAt)
|
|
46
|
+
return true;
|
|
47
|
+
const t = Date.parse(row.refreshedAt);
|
|
48
|
+
if (Number.isNaN(t))
|
|
49
|
+
return true;
|
|
50
|
+
return now.getTime() - t >= PORTRAIT_TTL_MS;
|
|
51
|
+
}
|
|
52
|
+
export function markPortraitDirty(db, userId) {
|
|
53
|
+
db.prepare("UPDATE user_profile SET portrait_dirty = 1 WHERE id = ?").run(userId);
|
|
54
|
+
}
|
|
55
|
+
function loadRow(db, userId) {
|
|
56
|
+
return db
|
|
57
|
+
.prepare(`SELECT display_name, expectations, settings, portrait, portrait_refreshed_at, portrait_dirty
|
|
58
|
+
FROM user_profile WHERE id = ? AND deleted_at IS NULL`)
|
|
59
|
+
.get(userId);
|
|
60
|
+
}
|
|
61
|
+
export async function ensurePortrait(opts) {
|
|
62
|
+
const row = loadRow(opts.db, opts.userId);
|
|
63
|
+
if (!row)
|
|
64
|
+
return null;
|
|
65
|
+
if (!needsPortraitRefresh({
|
|
66
|
+
dirty: row.portrait_dirty,
|
|
67
|
+
portrait: row.portrait,
|
|
68
|
+
refreshedAt: row.portrait_refreshed_at,
|
|
69
|
+
}, opts.now)) {
|
|
70
|
+
return row.portrait;
|
|
71
|
+
}
|
|
72
|
+
const settings = row.settings
|
|
73
|
+
? settingsForPortrait(JSON.parse(row.settings))
|
|
74
|
+
: {};
|
|
75
|
+
const children = loadManagedChildren(opts.db, opts.userId);
|
|
76
|
+
const fallback = composePortraitLine({
|
|
77
|
+
display_name: row.display_name,
|
|
78
|
+
expectations: row.expectations,
|
|
79
|
+
settings,
|
|
80
|
+
children,
|
|
81
|
+
});
|
|
82
|
+
const payload = {
|
|
83
|
+
display_name: row.display_name,
|
|
84
|
+
expectations: row.expectations,
|
|
85
|
+
settings,
|
|
86
|
+
children: children.map((c) => c.display_name),
|
|
87
|
+
};
|
|
88
|
+
let text = "";
|
|
89
|
+
try {
|
|
90
|
+
const out = await opts.llm.complete({
|
|
91
|
+
tools: [],
|
|
92
|
+
messages: [
|
|
93
|
+
{
|
|
94
|
+
role: "system",
|
|
95
|
+
content: "把用户的习惯和个人设置压成一行画像,不超过200字。格式:称呼|领域|诉求|固定节奏|代管。只写稳定习惯与设置,不要写近期做过的具体事、不要写某天日程。只输出画像正文。",
|
|
96
|
+
},
|
|
97
|
+
{ role: "user", content: JSON.stringify(payload) },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
text = clipPortrait(out.text ?? "");
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
text = "";
|
|
104
|
+
}
|
|
105
|
+
const saved = text || fallback || row.portrait;
|
|
106
|
+
const nowIso = (opts.now ?? new Date()).toISOString();
|
|
107
|
+
opts.db
|
|
108
|
+
.prepare(`UPDATE user_profile
|
|
109
|
+
SET portrait = ?, portrait_refreshed_at = ?, portrait_dirty = 0
|
|
110
|
+
WHERE id = ?`)
|
|
111
|
+
.run(saved, nowIso, opts.userId);
|
|
112
|
+
return saved;
|
|
113
|
+
}
|
|
114
|
+
function loadManagedChildren(db, managerId) {
|
|
115
|
+
try {
|
|
116
|
+
const rows = db
|
|
117
|
+
.prepare(`SELECT ui.display_name, up.settings
|
|
118
|
+
FROM user_identity ui
|
|
119
|
+
LEFT JOIN user_profile up
|
|
120
|
+
ON up.id = ui.libero_user_id AND up.deleted_at IS NULL
|
|
121
|
+
WHERE ui.managed_by = ?`)
|
|
122
|
+
.all(managerId);
|
|
123
|
+
return rows.map((r) => ({
|
|
124
|
+
display_name: r.display_name,
|
|
125
|
+
settings: r.settings
|
|
126
|
+
? settingsForPortrait(JSON.parse(r.settings))
|
|
127
|
+
: {},
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Short system prompt for MC-CHAT tracer. Full skill files are not loaded. */
|
|
2
|
+
const DEFAULT_HOMM_LINK = "https://homm.kuibu.studio/camp.html?t=tk_main";
|
|
3
|
+
export function buildChatSystemPrompt(hommLink = DEFAULT_HOMM_LINK) {
|
|
4
|
+
return `你是 libero 教练助理。用户的账在服务端工具里。
|
|
5
|
+
问今天/日程/时间表时调用 schedule.timeline,并把返回的 rendered_text 原样给用户,不要改格式。
|
|
6
|
+
需要列表时用 timeblock.list。不要编造用户没有的时间块。
|
|
7
|
+
建日程用 scheduler.createSchedule(title + start_time + end_time,ISO 带时区)。只用于还没发生的计划。
|
|
8
|
+
补录已经发生的事用 timeblock.create(status=completed,尽量带 category_id)。先 schedule.timeline 或 timeblock.list,同一标题+起止已存在就不要再创建。
|
|
9
|
+
问「今天」时不要把 to 截到「现在」导致晚上的块查丢;默认窗口即可,或 to 用当天结束。
|
|
10
|
+
给孩子建档:profile.createUser(display_name,可选 focus_areas/goals)。同一家长下同名会复用已有档案并自动代管,不要反复建、不要再调 setManager。
|
|
11
|
+
查/写孩子账:target_user_id 填 createUser 返回的 libero_user_id(仅代管的孩子)。问孩子今天日程用 schedule.timeline + target_user_id。不要把孩子的事写到家长自己账上。
|
|
12
|
+
提前提醒用 scheduler.setIntent:intent_type=pre_start_reminder、schedule_id、trigger={kind:"absolute",at:ISO}、action_prompt(必填,提醒正文)。不要用 prompt 字段名。
|
|
13
|
+
不要填 user_id(由服务注入)。办完后用一两句话确认给用户。
|
|
14
|
+
|
|
15
|
+
INT-ap-rewards(用户面只说"任务 + AP",不解释曲线/递减规则):
|
|
16
|
+
- 会话首次回复前调 bounties.status:有未完成任务就在回答末尾顺带一句「今天悬赏:专注计时 {n}/1、时间块 {n}/3、复盘 {n}/1,做日常攒 AP」;同一会话只播一次;三项全完成或调用失败就闭嘴不提。
|
|
17
|
+
- 工具返回里带 ap 字段({amount, bonus?})时,回复中顺带一句「+{amount} AP 已入账」(有 bonus 再加「,悬赏 +{bonus}」)。没带 ap 字段就不要编造 AP 数字。
|
|
18
|
+
- 本次对话完成了计时(timer.stop)、确认/补录了时间块或复盘收尾后,回答末尾另起一行完整附上(要么整行原样、要么不加,禁止只写图标不写链接):
|
|
19
|
+
⚔️ 攒 AP 出击:${hommLink}
|
|
20
|
+
- 复盘对话真正收尾时(用户确认到这/方案定了):先调一次 review.complete 再发收尾语,顺带一句「复盘 +{amount} AP」(amount 用返回值;同一次复盘只调一次)。`;
|
|
21
|
+
}
|
|
22
|
+
/** @deprecated 兼容旧引用;等价 buildChatSystemPrompt() */
|
|
23
|
+
export const CHAT_SYSTEM_PROMPT = buildChatSystemPrompt();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible function names: ^[a-zA-Z0-9_-]+$
|
|
3
|
+
* Registry names use dots (schedule.timeline). Map at the LLM boundary only.
|
|
4
|
+
*/
|
|
5
|
+
export function toLlmToolName(name) {
|
|
6
|
+
return name.replace(/\./g, "_");
|
|
7
|
+
}
|
|
8
|
+
export function fromLlmToolName(wire, registryNames) {
|
|
9
|
+
const names = [...registryNames];
|
|
10
|
+
if (names.includes(wire))
|
|
11
|
+
return wire;
|
|
12
|
+
const mapped = names.filter((n) => toLlmToolName(n) === wire);
|
|
13
|
+
if (mapped.length === 1)
|
|
14
|
+
return mapped[0];
|
|
15
|
+
return wire;
|
|
16
|
+
}
|
|
17
|
+
export function assertUniqueLlmToolNames(names) {
|
|
18
|
+
const seen = new Map();
|
|
19
|
+
for (const n of names) {
|
|
20
|
+
const w = toLlmToolName(n);
|
|
21
|
+
const prev = seen.get(w);
|
|
22
|
+
if (prev && prev !== n) {
|
|
23
|
+
throw new Error(`LLM tool name collision: ${prev} and ${n} → ${w}`);
|
|
24
|
+
}
|
|
25
|
+
seen.set(w, n);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert ToolModule.inputSchema (Zod raw shape or ZodType) → JSON Schema
|
|
3
|
+
* for OpenAI-compatible tool calling. Without this, models guess args and loop.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
function isZodType(x) {
|
|
7
|
+
return (!!x &&
|
|
8
|
+
typeof x === "object" &&
|
|
9
|
+
"safeParse" in x &&
|
|
10
|
+
typeof x.safeParse === "function");
|
|
11
|
+
}
|
|
12
|
+
export function toChatToolParameters(inputSchema) {
|
|
13
|
+
const schema = isZodType(inputSchema)
|
|
14
|
+
? inputSchema
|
|
15
|
+
: z.object(inputSchema);
|
|
16
|
+
const json = z.toJSONSchema(schema);
|
|
17
|
+
// OpenAI-compat clients don't need draft $schema; strip to keep payload small.
|
|
18
|
+
const { $schema: _s, ...rest } = json;
|
|
19
|
+
return rest;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function nowUTC() {
|
|
2
|
+
return new Date().toISOString();
|
|
3
|
+
}
|
|
4
|
+
export function nowLocal(offsetMinutes) {
|
|
5
|
+
const d = new Date();
|
|
6
|
+
// Bug 4 修复(2026-07-31):旧实现多算了一次 getTimezoneOffset():
|
|
7
|
+
// 旧代码 utc = d.getTime() + d.getTimezoneOffset()*60000 // ← 把 UTC 毫秒减回本地墙钟当 UTC
|
|
8
|
+
// 旧代码 local = utc + offset*60000 // ← 又加 offset
|
|
9
|
+
// 在生产 TZ=+08 服务器不传参时 offset=480,被双偏移抵消,返回 UTC 墙钟+08:00 后缀
|
|
10
|
+
// (错位 8 小时)。实测:7/30 22:02:51 北京时间触发的复盘 cron 收到 "14:02:51+08:00"。
|
|
11
|
+
// 正确逻辑:UTC 毫秒 + offset = 该时区墙钟时间(视为 UTC 格式化)+ 拼后缀。
|
|
12
|
+
const offset = offsetMinutes ?? -d.getTimezoneOffset();
|
|
13
|
+
if (offset < -720 || offset > 720) {
|
|
14
|
+
throw new Error(`Timezone offset ${offset} out of range (must be ±720 minutes)`);
|
|
15
|
+
}
|
|
16
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
17
|
+
const abs = Math.abs(offset);
|
|
18
|
+
const h = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
19
|
+
const m = String(abs % 60).padStart(2, "0");
|
|
20
|
+
const shifted = new Date(d.getTime() + offset * 60000);
|
|
21
|
+
const iso = shifted.toISOString().replace("Z", "");
|
|
22
|
+
return `${iso}${sign}${h}:${m}`;
|
|
23
|
+
}
|
|
24
|
+
export function formatTime(isoString, format) {
|
|
25
|
+
const p = parseTime(isoString);
|
|
26
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
27
|
+
return format
|
|
28
|
+
.replace("YYYY", String(p.year))
|
|
29
|
+
.replace("MM", pad(p.month))
|
|
30
|
+
.replace("DD", pad(p.day))
|
|
31
|
+
.replace("HH", pad(p.hour))
|
|
32
|
+
.replace("mm", pad(p.minute))
|
|
33
|
+
.replace("ss", pad(p.second));
|
|
34
|
+
}
|
|
35
|
+
export function parseTime(isoString) {
|
|
36
|
+
// Match ISO 8601 with timezone offset
|
|
37
|
+
const m = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([+-]\d{2}:\d{2}|Z)$/);
|
|
38
|
+
if (!m) {
|
|
39
|
+
throw new Error(`Invalid ISO time string: ${isoString}`);
|
|
40
|
+
}
|
|
41
|
+
let offsetMinutes = 0;
|
|
42
|
+
if (m[7] === "Z") {
|
|
43
|
+
offsetMinutes = 0;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const sign = m[7][0] === "+" ? 1 : -1;
|
|
47
|
+
const [h, min] = m[7].slice(1).split(":").map(Number);
|
|
48
|
+
offsetMinutes = sign * (h * 60 + min);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
year: parseInt(m[1]),
|
|
52
|
+
month: parseInt(m[2]),
|
|
53
|
+
day: parseInt(m[3]),
|
|
54
|
+
hour: parseInt(m[4]),
|
|
55
|
+
minute: parseInt(m[5]),
|
|
56
|
+
second: parseInt(m[6]),
|
|
57
|
+
offsetMinutes,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function getLocalOffset() {
|
|
61
|
+
return -new Date().getTimezoneOffset();
|
|
62
|
+
}
|
|
63
|
+
/** Convert any ISO string (UTC or with offset) to local timezone ISO string with offset suffix. */
|
|
64
|
+
export function toLocalIso(isoString) {
|
|
65
|
+
const d = new Date(isoString);
|
|
66
|
+
if (isNaN(d.getTime())) {
|
|
67
|
+
throw new Error(`Invalid ISO time string: ${isoString}`);
|
|
68
|
+
}
|
|
69
|
+
const offset = getLocalOffset();
|
|
70
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
71
|
+
const abs = Math.abs(offset);
|
|
72
|
+
const h = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
73
|
+
const m = String(abs % 60).padStart(2, "0");
|
|
74
|
+
// Shift UTC timestamp by local offset to get "fake UTC" representing local time
|
|
75
|
+
const local = new Date(d.getTime() + offset * 60000);
|
|
76
|
+
const iso = local.toISOString().replace("Z", "");
|
|
77
|
+
return `${iso}${sign}${h}:${m}`;
|
|
78
|
+
}
|