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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MC-0b REST adapter: HTTP ↔ ToolModule registry. No business logic copy.
|
|
3
|
+
* MC-CHAT: POST /api/v1/chat hangs off the same app + auth.
|
|
4
|
+
*/
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { resolveBearer } from "./tokens.js";
|
|
7
|
+
import { chatRequestBodySchema } from "../../contract/v1/rest.js";
|
|
8
|
+
import { invokeRegisteredTool } from "./invoke-tool.js";
|
|
9
|
+
import { issueShortToken } from "./short-token.js";
|
|
10
|
+
import { runChatTurn } from "../chat/loop.js";
|
|
11
|
+
function errorJson(code, message, status) {
|
|
12
|
+
return { body: { ok: false, error: { code, message } }, status };
|
|
13
|
+
}
|
|
14
|
+
export function createRestApp(opts) {
|
|
15
|
+
const byName = new Map(opts.registry.map((t) => [t.name, t]));
|
|
16
|
+
const READ_ALLOWED_TOOLS = new Set(["ap.balance"]);
|
|
17
|
+
const nowSec = () => Math.floor((opts.now ? opts.now() : Date.now()) / 1000);
|
|
18
|
+
const resolve = (auth) => resolveBearer(auth, { tokenMap: opts.tokenMap, linkSecret: opts.linkSecret, nowSec: nowSec() });
|
|
19
|
+
const linkBase = opts.linkBaseUrl ?? "https://homm.kuibu.studio/camp.html";
|
|
20
|
+
const ttlSec = opts.ttlSec ?? 86400;
|
|
21
|
+
const mintLink = opts.linkSecret
|
|
22
|
+
? (userId) => `${linkBase}?t=${issueShortToken({ userId, scope: "read", ttlSec }, opts.linkSecret, nowSec())}`
|
|
23
|
+
: undefined;
|
|
24
|
+
const app = new Hono();
|
|
25
|
+
app.get("/api/v1/health", (c) => c.json({ ok: true }));
|
|
26
|
+
app.post("/api/v1/chat", async (c) => {
|
|
27
|
+
const bearer = resolve(c.req.header("Authorization"));
|
|
28
|
+
if (!bearer) {
|
|
29
|
+
const e = errorJson("unauthorized", "missing or invalid bearer token", 401);
|
|
30
|
+
return c.json(e.body, e.status);
|
|
31
|
+
}
|
|
32
|
+
if (bearer.scope === "read") {
|
|
33
|
+
const e = errorJson("forbidden", "token scope is read-only", 403);
|
|
34
|
+
return c.json(e.body, e.status);
|
|
35
|
+
}
|
|
36
|
+
const userId = bearer.userId;
|
|
37
|
+
if (!opts.chatLlm) {
|
|
38
|
+
const e = errorJson("unavailable", "chat model is not configured (set LIBERO_CHAT_BASE_URL, LIBERO_CHAT_API_KEY, LIBERO_CHAT_MODEL)", 503);
|
|
39
|
+
return c.json(e.body, e.status);
|
|
40
|
+
}
|
|
41
|
+
let raw = {};
|
|
42
|
+
try {
|
|
43
|
+
const text = await c.req.text();
|
|
44
|
+
if (text.trim())
|
|
45
|
+
raw = JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
const e = errorJson("invalid_input", "body must be JSON object", 400);
|
|
49
|
+
return c.json(e.body, e.status);
|
|
50
|
+
}
|
|
51
|
+
const parsed = chatRequestBodySchema.safeParse(raw);
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
const e = errorJson("invalid_input", parsed.error.issues.map((i) => i.message).join("; ") || "invalid input", 400);
|
|
54
|
+
return c.json(e.body, e.status);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const turn = await runChatTurn({
|
|
58
|
+
llm: opts.chatLlm,
|
|
59
|
+
registry: opts.registry,
|
|
60
|
+
userId,
|
|
61
|
+
message: parsed.data.message,
|
|
62
|
+
conversationKey: `rest:${userId}`,
|
|
63
|
+
mintLink,
|
|
64
|
+
});
|
|
65
|
+
return c.json({
|
|
66
|
+
ok: true,
|
|
67
|
+
reply: turn.reply,
|
|
68
|
+
tools_called: turn.toolsCalled,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : "llm error";
|
|
73
|
+
const e = errorJson("llm_error", msg, 502);
|
|
74
|
+
return c.json(e.body, e.status);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
app.post("/api/v1/tools/:toolName", async (c) => {
|
|
78
|
+
const bearer = resolve(c.req.header("Authorization"));
|
|
79
|
+
if (!bearer) {
|
|
80
|
+
const e = errorJson("unauthorized", "missing or invalid bearer token", 401);
|
|
81
|
+
return c.json(e.body, e.status);
|
|
82
|
+
}
|
|
83
|
+
const toolName = c.req.param("toolName");
|
|
84
|
+
if (bearer.scope === "read" && !READ_ALLOWED_TOOLS.has(toolName)) {
|
|
85
|
+
const e = errorJson("forbidden", "token scope is read-only", 403);
|
|
86
|
+
return c.json(e.body, e.status);
|
|
87
|
+
}
|
|
88
|
+
const userId = bearer.userId;
|
|
89
|
+
let raw = {};
|
|
90
|
+
try {
|
|
91
|
+
const text = await c.req.text();
|
|
92
|
+
if (text.trim())
|
|
93
|
+
raw = JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
const e = errorJson("invalid_input", "body must be JSON object", 400);
|
|
97
|
+
return c.json(e.body, e.status);
|
|
98
|
+
}
|
|
99
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
100
|
+
const e = errorJson("invalid_input", "body must be JSON object", 400);
|
|
101
|
+
return c.json(e.body, e.status);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const invoked = await invokeRegisteredTool({
|
|
105
|
+
byName,
|
|
106
|
+
toolName,
|
|
107
|
+
rawArgs: raw,
|
|
108
|
+
userId,
|
|
109
|
+
});
|
|
110
|
+
if (!invoked.ok) {
|
|
111
|
+
return c.json({ ok: false, error: { code: invoked.code, message: invoked.message } }, invoked.httpStatus);
|
|
112
|
+
}
|
|
113
|
+
return c.json({ ok: true, data: invoked.data });
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const e = errorJson("internal", err instanceof Error ? err.message : "internal error", 500);
|
|
117
|
+
return c.json(e.body, e.status);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return app;
|
|
121
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool invoke for REST POST /tools/:name and the chat loop.
|
|
3
|
+
* No business logic — zod + handler + unwrap MCP content.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
function inputToZod(schema) {
|
|
7
|
+
if (schema && typeof schema === "object" && "safeParse" in schema) {
|
|
8
|
+
return schema;
|
|
9
|
+
}
|
|
10
|
+
return z.object(schema);
|
|
11
|
+
}
|
|
12
|
+
export function schemaHasUserId(schema) {
|
|
13
|
+
if (schema && typeof schema === "object" && !("safeParse" in schema)) {
|
|
14
|
+
return Object.prototype.hasOwnProperty.call(schema, "user_id");
|
|
15
|
+
}
|
|
16
|
+
const shape = schema.shape;
|
|
17
|
+
return !!(shape && Object.prototype.hasOwnProperty.call(shape, "user_id"));
|
|
18
|
+
}
|
|
19
|
+
export function unwrapMcpResult(result) {
|
|
20
|
+
if (!result || typeof result !== "object") {
|
|
21
|
+
return { isError: false, data: result };
|
|
22
|
+
}
|
|
23
|
+
const r = result;
|
|
24
|
+
const text = r.content?.[0]?.text;
|
|
25
|
+
if (typeof text === "string") {
|
|
26
|
+
try {
|
|
27
|
+
return { isError: !!r.isError, data: JSON.parse(text) };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { isError: !!r.isError, data: text };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { isError: !!r.isError, data: result };
|
|
34
|
+
}
|
|
35
|
+
export function invokeRegisteredTool(opts) {
|
|
36
|
+
const tool = opts.byName.get(opts.toolName);
|
|
37
|
+
if (!tool) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
code: "not_found",
|
|
41
|
+
message: `unknown tool: ${opts.toolName}`,
|
|
42
|
+
httpStatus: 404,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const args = { ...opts.rawArgs };
|
|
46
|
+
if (tool.needsUserIdInjection || schemaHasUserId(tool.inputSchema)) {
|
|
47
|
+
if (typeof args.user_id === "string" && args.user_id !== opts.userId) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
code: "forbidden",
|
|
51
|
+
message: "user_id does not match bearer token",
|
|
52
|
+
httpStatus: 403,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
args.user_id = opts.userId;
|
|
56
|
+
}
|
|
57
|
+
const parsed = inputToZod(tool.inputSchema).safeParse(args);
|
|
58
|
+
if (!parsed.success) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
code: "invalid_input",
|
|
62
|
+
message: parsed.error.issues.map((i) => i.message).join("; ") || "invalid input",
|
|
63
|
+
httpStatus: 400,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return Promise.resolve(tool.handler(parsed.data))
|
|
67
|
+
.then((result) => {
|
|
68
|
+
const unwrapped = unwrapMcpResult(result);
|
|
69
|
+
if (unwrapped.isError) {
|
|
70
|
+
const data = unwrapped.data;
|
|
71
|
+
const msg = data &&
|
|
72
|
+
typeof data === "object" &&
|
|
73
|
+
"error" in data &&
|
|
74
|
+
typeof data.error === "string"
|
|
75
|
+
? data.error
|
|
76
|
+
: "tool error";
|
|
77
|
+
const code = data &&
|
|
78
|
+
typeof data === "object" &&
|
|
79
|
+
"code" in data &&
|
|
80
|
+
data.code === "forbidden"
|
|
81
|
+
? "forbidden"
|
|
82
|
+
: "tool_error";
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
code,
|
|
86
|
+
message: msg,
|
|
87
|
+
httpStatus: (code === "forbidden" ? 403 : 400),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, data: unwrapped.data };
|
|
91
|
+
})
|
|
92
|
+
.catch((err) => ({
|
|
93
|
+
ok: false,
|
|
94
|
+
code: "internal",
|
|
95
|
+
message: err instanceof Error ? err.message : "internal error",
|
|
96
|
+
httpStatus: 500,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 无状态短 token:v1.<base64url(payload)>.<base64url(hmac-sha256)>
|
|
3
|
+
* payload = { u: userId, s: scope, e: expEpochSec }。秘钥只从调用方传入,绝不入日志。
|
|
4
|
+
*/
|
|
5
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
6
|
+
const PREFIX = "v1";
|
|
7
|
+
function sign(body, secret) {
|
|
8
|
+
return createHmac("sha256", secret).update(`${PREFIX}.${body}`).digest().toString("base64url");
|
|
9
|
+
}
|
|
10
|
+
export function issueShortToken(input, secret, nowSec = Math.floor(Date.now() / 1000)) {
|
|
11
|
+
const payload = { u: input.userId, s: input.scope, e: nowSec + input.ttlSec };
|
|
12
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
13
|
+
return `${PREFIX}.${body}.${sign(body, secret)}`;
|
|
14
|
+
}
|
|
15
|
+
export function verifyShortToken(token, secret, nowSec = Math.floor(Date.now() / 1000)) {
|
|
16
|
+
const parts = token.split(".");
|
|
17
|
+
if (parts.length !== 3 || parts[0] !== PREFIX)
|
|
18
|
+
return null;
|
|
19
|
+
const [, body, sig] = parts;
|
|
20
|
+
const expected = sign(body, secret);
|
|
21
|
+
const a = Buffer.from(sig);
|
|
22
|
+
const b = Buffer.from(expected);
|
|
23
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
24
|
+
return null;
|
|
25
|
+
let payload;
|
|
26
|
+
try {
|
|
27
|
+
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const { u, s, e } = payload;
|
|
33
|
+
if (typeof u !== "string" || (s !== "read" && s !== "full") || typeof e !== "number")
|
|
34
|
+
return null;
|
|
35
|
+
if (e <= nowSec)
|
|
36
|
+
return null;
|
|
37
|
+
return { userId: u, scope: s, exp: e };
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function parseRestTokens(env = process.env) {
|
|
2
|
+
const raw = env.LIBERO_REST_TOKENS;
|
|
3
|
+
if (!raw || !raw.trim()) {
|
|
4
|
+
throw new Error("LIBERO_REST_TOKENS is required (JSON object { token: libero_user_id })");
|
|
5
|
+
}
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error("LIBERO_REST_TOKENS must be valid JSON");
|
|
12
|
+
}
|
|
13
|
+
if (parsed === null ||
|
|
14
|
+
typeof parsed !== "object" ||
|
|
15
|
+
Array.isArray(parsed)) {
|
|
16
|
+
throw new Error("LIBERO_REST_TOKENS must be a JSON object");
|
|
17
|
+
}
|
|
18
|
+
const map = {};
|
|
19
|
+
for (const [token, userId] of Object.entries(parsed)) {
|
|
20
|
+
if (!token || typeof userId !== "string" || !userId.trim()) {
|
|
21
|
+
throw new Error("LIBERO_REST_TOKENS entries must be token → non-empty user id");
|
|
22
|
+
}
|
|
23
|
+
map[token] = userId;
|
|
24
|
+
}
|
|
25
|
+
if (Object.keys(map).length === 0) {
|
|
26
|
+
throw new Error("LIBERO_REST_TOKENS must contain at least one token");
|
|
27
|
+
}
|
|
28
|
+
return map;
|
|
29
|
+
}
|
|
30
|
+
export function userIdForBearer(authorization, tokenMap) {
|
|
31
|
+
if (!authorization)
|
|
32
|
+
return null;
|
|
33
|
+
const m = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
|
|
34
|
+
if (!m)
|
|
35
|
+
return null;
|
|
36
|
+
return tokenMap[m[1]] ?? null;
|
|
37
|
+
}
|
|
38
|
+
import { verifyShortToken } from "./short-token.js";
|
|
39
|
+
export function resolveBearer(authorization, opts) {
|
|
40
|
+
if (!authorization)
|
|
41
|
+
return null;
|
|
42
|
+
const m = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
|
|
43
|
+
if (!m)
|
|
44
|
+
return null;
|
|
45
|
+
const token = m[1];
|
|
46
|
+
const mapped = opts.tokenMap[token];
|
|
47
|
+
if (mapped)
|
|
48
|
+
return { userId: mapped, scope: "full" };
|
|
49
|
+
if (opts.linkSecret) {
|
|
50
|
+
const v = verifyShortToken(token, opts.linkSecret, opts.nowSec);
|
|
51
|
+
if (v)
|
|
52
|
+
return { userId: v.userId, scope: v.scope };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { effectiveUserId } from "../profile-helpers.js";
|
|
2
|
+
// ── S32 reply_contract: trigger logic + schema ──
|
|
3
|
+
// 跟 SKILL §332-335 同仓演进:改触发条件 = 改这里 + rebuild MCP,不改 plugin。
|
|
4
|
+
/** Slot 模板(数据驱动,改措辞不重 build plugin)。 */
|
|
5
|
+
const ASK_DECOMPOSE_SLOT = {
|
|
6
|
+
cue_pattern: "拆成|帮你拆|要不要.*拆|拆解",
|
|
7
|
+
append_if_missing: "这段时间挺长,要不要我帮你拆成几个能上手的部分?",
|
|
8
|
+
};
|
|
9
|
+
const MEETING_KEYWORDS = /开会|站会|周会|sync|1on1|会议|meeting/is;
|
|
10
|
+
const EXAM_KEYWORDS = /考试|测验|备考|exam/is;
|
|
11
|
+
const TRIGGER_CATEGORIES = new Set(["工作", "学习", "创作"]);
|
|
12
|
+
/**
|
|
13
|
+
* 从标题关键词推断分类名。返回 TRIGGER_CATEGORIES 中的值或 null。
|
|
14
|
+
* ponytail: 关键词覆盖不全(只覆盖高频表达),但作为后备够用。
|
|
15
|
+
* 后续可迭代到 MCP inputSchema 加 category_name 参数 + skill 必传。
|
|
16
|
+
*/
|
|
17
|
+
function inferCategoryFromTitle(title) {
|
|
18
|
+
// 工作类关键词
|
|
19
|
+
if (/写代码|编码|码代码|debug|code\s?review|修bug|重构|写脚本|跑测试|联调|写功能|开发|编程|写程序|写插件|写模块|写接口|写服务|写新功能|写报告|写文档|写方案|写计划|写总结/.test(title)) {
|
|
20
|
+
return "工作";
|
|
21
|
+
}
|
|
22
|
+
// 创作类关键词
|
|
23
|
+
if (/剪|拍|vlog|视频|创作|画|设计|写文章|写博客|写小说|写故事|写歌|编曲|录音|直播|做图|修图|剪辑|后期/.test(title)) {
|
|
24
|
+
return "创作";
|
|
25
|
+
}
|
|
26
|
+
// 学习类关键词
|
|
27
|
+
if (/学习|看书|复习|预习|刷题|做题|练习|背单词|学|读论文|看教程|上网课|培训|读书会/.test(title)) {
|
|
28
|
+
return "学习";
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 判定建块回复是否该含 ask_decompose slot(S32 §2.2)。
|
|
34
|
+
* 三条件:category_name ∈ {工作,学习,创作} && duration > 60min && 非会议非考试。
|
|
35
|
+
*
|
|
36
|
+
* §4.5 静默失败声明:category_name 缺失且无 categoryRepo 时返回 false(向后兼容,不报错)。
|
|
37
|
+
* 方案 B:当 category_name 缺失时,通过 categoryRepo 从 category_id resolve 名字。
|
|
38
|
+
* ponytail: 方案 C — category 都拿不到时,用标题关键词后备推断(覆盖 90% 漏触发)。
|
|
39
|
+
*/
|
|
40
|
+
export async function shouldAskDecompose(input, categoryRepo) {
|
|
41
|
+
// 优先用 input.category_name(向后兼容,万一调用方传了)
|
|
42
|
+
let categoryName = input.category_name;
|
|
43
|
+
if (!categoryName && categoryRepo && input.category_id) {
|
|
44
|
+
const cat = await categoryRepo.getById(input.category_id);
|
|
45
|
+
categoryName = cat?.name;
|
|
46
|
+
}
|
|
47
|
+
// ponytail: 标题关键词后备——AI 不传 category 时从 title 推断
|
|
48
|
+
if (!categoryName) {
|
|
49
|
+
const inferred = inferCategoryFromTitle(input.title);
|
|
50
|
+
if (inferred)
|
|
51
|
+
categoryName = inferred;
|
|
52
|
+
}
|
|
53
|
+
if (!categoryName || !TRIGGER_CATEGORIES.has(categoryName))
|
|
54
|
+
return false;
|
|
55
|
+
const durationMin = (new Date(input.end_time).getTime() - new Date(input.start_time).getTime()) /
|
|
56
|
+
60_000;
|
|
57
|
+
if (durationMin <= 60)
|
|
58
|
+
return false;
|
|
59
|
+
if (MEETING_KEYWORDS.test(input.title) || EXAM_KEYWORDS.test(input.title))
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Subtract `minutes` from an ISO-8601 timestamp string.
|
|
65
|
+
* Pure string math — no Date timezone drift.
|
|
66
|
+
*/
|
|
67
|
+
function subtractMinutes(iso, minutes) {
|
|
68
|
+
const ms = new Date(iso).getTime() - minutes * 60_000;
|
|
69
|
+
return new Date(ms).toISOString();
|
|
70
|
+
}
|
|
71
|
+
export function createSchedulerTools(repo, reminderRepo, categoryRepo,
|
|
72
|
+
/**
|
|
73
|
+
* MV-COACH-5 slice 1: setIntent/cancelIntent 用的宿主适配层。
|
|
74
|
+
* 可选——不传则 setIntent 写 DB status=pending(降级路径)。
|
|
75
|
+
*/
|
|
76
|
+
activeAdapter,
|
|
77
|
+
/**
|
|
78
|
+
* MV-COACH-5 slice 1: raw DB 句柄,用于直接写 reminders 表的 intent_* 列
|
|
79
|
+
* (reminderRepo.create 不认 intent_* 字段也不接 cron_expr 占位 remind_at)。
|
|
80
|
+
*/
|
|
81
|
+
intentDb) {
|
|
82
|
+
return {
|
|
83
|
+
async createSchedule(input) {
|
|
84
|
+
// Reuse timeblock.create validation
|
|
85
|
+
if (!input.title || input.title.trim() === "") {
|
|
86
|
+
throw new Error("事件名不能为空");
|
|
87
|
+
}
|
|
88
|
+
if (input.start_time > input.end_time) {
|
|
89
|
+
throw new Error("开始时间不能晚于结束时间");
|
|
90
|
+
}
|
|
91
|
+
const ledgerUserId = effectiveUserId(input);
|
|
92
|
+
const userExists = await repo.userExists(ledgerUserId);
|
|
93
|
+
if (!userExists) {
|
|
94
|
+
throw new Error("用户不存在");
|
|
95
|
+
}
|
|
96
|
+
const existing = await repo.findExactWindow({
|
|
97
|
+
user_id: ledgerUserId,
|
|
98
|
+
title: input.title,
|
|
99
|
+
start: input.start_time,
|
|
100
|
+
end: input.end_time,
|
|
101
|
+
status: "planned",
|
|
102
|
+
});
|
|
103
|
+
if (existing) {
|
|
104
|
+
return { ...existing, reply_contract: { required_slots: [] } };
|
|
105
|
+
}
|
|
106
|
+
// Force status='planned' — schedules are always planned
|
|
107
|
+
const block = await repo.create({
|
|
108
|
+
...input,
|
|
109
|
+
user_id: ledgerUserId,
|
|
110
|
+
status: "planned",
|
|
111
|
+
});
|
|
112
|
+
// Auto-create reminder unless skip_reminder=true or no reminderRepo
|
|
113
|
+
if (!input.skip_reminder && reminderRepo) {
|
|
114
|
+
try {
|
|
115
|
+
await reminderRepo.create({
|
|
116
|
+
user_id: input.user_id,
|
|
117
|
+
content: `日程提醒:${input.title} ${input.start_time}`,
|
|
118
|
+
remind_at: subtractMinutes(input.start_time, 15),
|
|
119
|
+
channel: input.channel,
|
|
120
|
+
timeblock_id: block.id,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// §4.5 静默折叠:reminder 是附加功能,建失败不应阻断日程创建。
|
|
125
|
+
// 吞掉的错误类型:推送服务不可用 / reminder repo 写入异常。
|
|
126
|
+
// createSchedule 主链路(日程已入库)不受影响,调用方可从 reminder 表查缺失记录。
|
|
127
|
+
console.warn("[createSchedule] 自动建提醒失败,日程已正常创建");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// S32: reply_contract — 声明这轮回复该有的结构 slot
|
|
131
|
+
const reply_contract = await shouldAskDecompose(input, categoryRepo)
|
|
132
|
+
? { required_slots: ["ask_decompose"], slot_schema: { ask_decompose: ASK_DECOMPOSE_SLOT } }
|
|
133
|
+
: { required_slots: [] };
|
|
134
|
+
// MV-COACH-3 Slice 4 v2 + 2026-07-29 重构:
|
|
135
|
+
// 主 session 一次性建全 2 个独立 cron(开工 + 追问),每个单职责、互不依赖。
|
|
136
|
+
// ⚠️ 不再让开工 cron 现场建追问 cron——cron-session 是受限环境(无 terminal/code 审批),
|
|
137
|
+
// 现场建子 cron 会卡死,连主提醒都被吞掉(2026-07-29 教训)。
|
|
138
|
+
// ⚠️ 2026-07-29 第二个教训:cron-session 也没有平台用户上下文,LLM 会乱猜 user_id
|
|
139
|
+
// (实测查了 amos 而非真正的 libero),所以 user_id 必须由主 session 焊进 cron prompt。
|
|
140
|
+
const title = input.title;
|
|
141
|
+
const startTime = input.start_time;
|
|
142
|
+
const userId = input.user_id;
|
|
143
|
+
const durationMin = input.duration_minutes;
|
|
144
|
+
const followUpOffsetMin = durationMin ? Math.floor(durationMin / 2) : 30;
|
|
145
|
+
// Bug 2(2026-07-31):一次性日程默认 is_recurring=false → cron 焊 repeat=1
|
|
146
|
+
// 实测教训:cron 名字叫「今天 14:00」但 Repeat: ∞,第二天还在响。
|
|
147
|
+
const isRecurring = input.is_recurring === true;
|
|
148
|
+
const repeatClause = isRecurring ? "" : " repeat=1";
|
|
149
|
+
const repeatNote = isRecurring
|
|
150
|
+
? ""
|
|
151
|
+
: `\n ⚠️ Bug 2 教训(2026-07-31):这是一次性日程(用户说"今天/今晚/明天/后天"),cron 必须加 repeat=1——不加会变成每日循环,日程已过期还在响。`;
|
|
152
|
+
const next_action_required = {
|
|
153
|
+
current_step: 1,
|
|
154
|
+
must_do_next: `立刻调 2 次 cronjob(action=create, deliver=origin),分别建【开工 cron】和【追问 cron】——主 session 一次建全,不靠 cron 现场建子 cron(2026-07-29 教训:cron-session 受限环境建子 cron 会卡死,连主提醒都吞掉)。\n` +
|
|
155
|
+
`① 开工 cron:schedule = 日程开始时间提前 15min,格式 "分 时 * * *"(如 10:30 开始 → "15 10 * * *")。短日程例外:日程本身就是 +5min 这种短时,直接用用户给的时间,不提前。${isRecurring ? "" : "一次性日程必须传 repeat=1(Bug 2)。"}\n` +
|
|
156
|
+
`② 追问 cron:schedule = 日程开始时间 + ${followUpOffsetMin}min(如 10:30 开始 + ${followUpOffsetMin}min),同样格式 "分 时 * * *"。${isRecurring ? "" : "一次性日程同样必须传 repeat=1。"}\n` +
|
|
157
|
+
`⚠️ 两条 cron 的 prompt 里所有 timeblock.list/update 调用都必须带上 user_id: "${userId}"——cron-session 没有平台用户上下文,不焊 user_id 会查错用户(2026-07-29 实测:LLM 乱猜成 amos 导致状态机失效)。\n` +
|
|
158
|
+
`📋 完整调用示例:cronjob(action="create", name="...", schedule="15 10 * * *", prompt="...", deliver="origin"${repeatClause})`,
|
|
159
|
+
cron_prompt_template: `【开工 cron prompt 模板】(schedule=日程开始时间-15min,${isRecurring ? "循环" : "一次性 → 必须 repeat=1"})\n` +
|
|
160
|
+
`cronjob(\n` +
|
|
161
|
+
` action="create",\n` +
|
|
162
|
+
` name="${title} 提前提醒",\n` +
|
|
163
|
+
` schedule="<开工时间-15min 的 '分 时 * * *'>",\n` +
|
|
164
|
+
` prompt="[Libero 教练闭环 - 提前提醒] 距离用户约定的 ${title} 开工时间还有 15 分钟。\\n执行 2 步:\\n1. 推送提前提醒(不超过 2 行):🔔 15 分钟后是你约定的 ${title}(${startTime} 开始)。准备好就开始,要开始了跟我说一声。\\n ⚠️ 不要问'要不要帮你开番茄钟',不要调 timer.startFocus——计时是用户主动发起的动作。\\n2. 调 timeblock.list({user_id:'${userId}', status:'planned'}) 找到 start_time 是 ${startTime} 的这条 schedule,拿 id,再调 timeblock.update(id=..., status:'unverified')。\\n ⚠️ user_id 必须用 '${userId}'——你(cron-session)没有用户上下文,乱猜会查错。\\n⚠️ 不要建任何新 cron——追问 cron 已由主 session 建好,你只需推提醒 + 标状态。",\n` +
|
|
165
|
+
` deliver="origin"${repeatClause}\n` +
|
|
166
|
+
`)${repeatNote}\n\n` +
|
|
167
|
+
`【追问 cron prompt 模板】(schedule=日程开始时间+${followUpOffsetMin}min,${isRecurring ? "循环" : "一次性 → 必须 repeat=1"})\n` +
|
|
168
|
+
`cronjob(\n` +
|
|
169
|
+
` action="create",\n` +
|
|
170
|
+
` name="${title} 追问",\n` +
|
|
171
|
+
` schedule="<追问时间的 '分 时 * * *'>",\n` +
|
|
172
|
+
` prompt="[Libero 教练闭环 - 追问] 用户 ${startTime} 的 ${title} 到现在没确认执行。\\n执行 2 步:\\n1. 温和追问一次(不带评判):看到 ${startTime} 那段好像还没动——是忘了、卡住、还是计划需要调整?\\n2. 调 timeblock.list({user_id:'${userId}', status:'unverified'}) 找到 start_time 是 ${startTime} 这条,查用户回应:答执行了→timeblock.update(status:'completed')+回'👍 记下了。要不要起个番茄钟?'(⚠️ Bug 3:只升 status,禁止自动调 timer.startFocus,"开始了"是确认执行不是请求开计时);答没执行→timeblock.remove(id)软删+回'了解,已撤掉这条';不答→保持 unverified,什么都不做。\\n ⚠️ user_id 必须用 '${userId}'。\\n ⚠️ Bug 1 教训(2026-07-31):如果 timeblock.list 找不到 start_time 是 ${startTime} 的 unverified 记录(说明用户已确认执行升 completed,或已软删)→ **静默退出,绝对不要 timeblock.update 任何东西**。后端代码层也已加守卫拒绝 completed→unverified,但你仍应避免发起这种调用。\\n⚠️ 这是唯一一次追问,无论用户回不回,不再追加,也不再建任何新 cron。",\n` +
|
|
173
|
+
` deliver="origin"${repeatClause}\n` +
|
|
174
|
+
`)${repeatNote}`,
|
|
175
|
+
reminder_note: "⚠️ 提醒 ≠ 计时。开工 cron 只推提醒('时间快到了'),绝对不帮用户开番茄钟。" +
|
|
176
|
+
"用户主动说'开始了'才调 timer.startFocus。收到提醒 ≠ 用户开始执行。",
|
|
177
|
+
should_ask_review: true,
|
|
178
|
+
};
|
|
179
|
+
return { ...block, reply_contract, next_action_required };
|
|
180
|
+
},
|
|
181
|
+
async listSchedules(filters) {
|
|
182
|
+
return repo.list({
|
|
183
|
+
user_id: filters.user_id,
|
|
184
|
+
status: "planned",
|
|
185
|
+
from: filters.from,
|
|
186
|
+
to: filters.to,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
async checkConflict(input) {
|
|
190
|
+
return repo.findOverlapping({
|
|
191
|
+
user_id: input.user_id,
|
|
192
|
+
start: input.start_time,
|
|
193
|
+
end: input.end_time,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
/**
|
|
197
|
+
* MV-COACH-5 §4.1 setIntent——注册意图(提醒/追问/复盘等)。
|
|
198
|
+
* 脑层(skill)调这个,不直接调宿主的 cronjob。
|
|
199
|
+
* 通用核心记意图到 reminders 表(升格为意图队列),调 activeAdapter 翻译成宿主机制。
|
|
200
|
+
*
|
|
201
|
+
* §4.5 静默折叠声明:
|
|
202
|
+
* - activeAdapter=null(无宿主推送能力)→ 写 DB status=pending,不抛错(降级路径)
|
|
203
|
+
* - adapter.publish 失败 → status=failed,不静默(错误显式上报)
|
|
204
|
+
*/
|
|
205
|
+
async setIntent(input) {
|
|
206
|
+
if (!reminderRepo) {
|
|
207
|
+
throw new Error("scheduler.setIntent 需要 reminderRepo(未注入)");
|
|
208
|
+
}
|
|
209
|
+
if (!intentDb) {
|
|
210
|
+
throw new Error("scheduler.setIntent 需要 intentDb(raw DB 句柄,用于写 intent_* 列)");
|
|
211
|
+
}
|
|
212
|
+
if (!input.action_prompt || input.action_prompt.trim() === "") {
|
|
213
|
+
throw new Error("action_prompt 不能为空");
|
|
214
|
+
}
|
|
215
|
+
if ((input.intent_type === "pre_start_reminder" ||
|
|
216
|
+
input.intent_type === "missed_check") &&
|
|
217
|
+
!input.schedule_id) {
|
|
218
|
+
throw new Error(`${input.intent_type} 需要 schedule_id(关联的 timeblock)`);
|
|
219
|
+
}
|
|
220
|
+
const nowIso = new Date().toISOString();
|
|
221
|
+
const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
222
|
+
const triggerPayload = JSON.stringify(input.trigger);
|
|
223
|
+
const triggerKind = input.trigger.kind;
|
|
224
|
+
// remind_at 必须语义真实:微信推送循环按它到点触发(2026-09-02 修复:不再占位"现在")
|
|
225
|
+
const remindAt = computeRemindAt(input.trigger, input.schedule_id, undefined, nowIso);
|
|
226
|
+
// 直接 INSERT 整行(绕过 reminderRepo.create 的 remind_at ISO 校验 + 不认 intent_* 字段)
|
|
227
|
+
intentDb
|
|
228
|
+
.prepare(`INSERT INTO reminders
|
|
229
|
+
(id, user_id, timeblock_id, content, remind_at, channel, status, attempts, created_at, updated_at,
|
|
230
|
+
intent_type, trigger_kind, trigger_payload)
|
|
231
|
+
VALUES (?, ?, ?, ?, ?, '', 'pending', 0, ?, ?,
|
|
232
|
+
?, ?, ?)`)
|
|
233
|
+
.run(intentId, input.user_id, input.schedule_id ?? null, input.action_prompt, remindAt, nowIso, nowIso, input.intent_type, triggerKind, triggerPayload);
|
|
234
|
+
const db = intentDb;
|
|
235
|
+
// 调 activeAdapter
|
|
236
|
+
if (!activeAdapter) {
|
|
237
|
+
return { intent_id: intentId, dispatched_via: "pending" };
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const intent = {
|
|
241
|
+
id: intentId,
|
|
242
|
+
user_id: input.user_id,
|
|
243
|
+
intent_type: input.intent_type,
|
|
244
|
+
schedule_id: input.schedule_id,
|
|
245
|
+
trigger: input.trigger,
|
|
246
|
+
action_prompt: input.action_prompt,
|
|
247
|
+
status: "pending",
|
|
248
|
+
created_at: nowIso,
|
|
249
|
+
updated_at: nowIso,
|
|
250
|
+
};
|
|
251
|
+
const { host_job_id } = await activeAdapter.publish(intent);
|
|
252
|
+
db.prepare(`UPDATE reminders SET status='dispatched', host_job_id=?, host_kind=?, updated_at=? WHERE id=?`).run(host_job_id, activeAdapter.kind, new Date().toISOString(), intentId);
|
|
253
|
+
return {
|
|
254
|
+
intent_id: intentId,
|
|
255
|
+
dispatched_via: activeAdapter.kind,
|
|
256
|
+
host_job_id,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
db.prepare(`UPDATE reminders SET status='failed', host_kind=?, updated_at=? WHERE id=?`).run(activeAdapter.kind, new Date().toISOString(), intentId);
|
|
261
|
+
throw new Error(`adapter.publish 失败(intent ${intentId} 标 failed):${e.message}`);
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
/**
|
|
265
|
+
* MV-COACH-5 §4.2 cancelIntent——撤销意图。
|
|
266
|
+
* 用户删日程时连带撤提醒/追问。
|
|
267
|
+
*/
|
|
268
|
+
async cancelIntent(input) {
|
|
269
|
+
if (!intentDb) {
|
|
270
|
+
throw new Error("scheduler.cancelIntent 需要 intentDb");
|
|
271
|
+
}
|
|
272
|
+
const row = intentDb
|
|
273
|
+
.prepare("SELECT host_job_id, status FROM reminders WHERE id=?")
|
|
274
|
+
.get(input.intent_id);
|
|
275
|
+
if (!row) {
|
|
276
|
+
throw new Error(`intent ${input.intent_id} 不存在`);
|
|
277
|
+
}
|
|
278
|
+
// 调 adapter.cancel(有 host_job_id 才调)
|
|
279
|
+
if (row.host_job_id && activeAdapter) {
|
|
280
|
+
try {
|
|
281
|
+
await activeAdapter.cancel(row.host_job_id);
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
console.warn(`[cancelIntent] adapter.cancel 失败(继续标本地 cancelled):${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
intentDb
|
|
288
|
+
.prepare(`UPDATE reminders SET status='cancelled', updated_at=? WHERE id=?`)
|
|
289
|
+
.run(new Date().toISOString(), input.intent_id);
|
|
290
|
+
return { cancelled: true };
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 从 trigger 算 remind_at。
|
|
296
|
+
* 2026-09-02 生产事故修复:占位写"现在"导致微信推送循环(INT-weixin reminder-loop)
|
|
297
|
+
* 建账即推送——absolute/relative 必须落到真实触发时刻(归一化 Z);
|
|
298
|
+
* recurring 归 adapter(cron),remind_at 保持近似并由推送循环按 trigger_kind 跳过。
|
|
299
|
+
* reminders 表 remind_at NOT NULL。
|
|
300
|
+
*/
|
|
301
|
+
function parseOffsetMs(offset) {
|
|
302
|
+
const m = /^(\d+)\s*([mhd])$/i.exec(offset.trim());
|
|
303
|
+
if (!m)
|
|
304
|
+
return 0;
|
|
305
|
+
const n = Number(m[1]);
|
|
306
|
+
return n * (m[2].toLowerCase() === "m" ? 60_000 : m[2].toLowerCase() === "h" ? 3_600_000 : 86_400_000);
|
|
307
|
+
}
|
|
308
|
+
function computeRemindAt(trigger, scheduleId, _repo, nowIso) {
|
|
309
|
+
if (trigger.kind === "absolute") {
|
|
310
|
+
const t = Date.parse(trigger.at);
|
|
311
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : nowIso;
|
|
312
|
+
}
|
|
313
|
+
if (trigger.kind === "relative") {
|
|
314
|
+
const offset = parseOffsetMs(trigger.offset ?? "");
|
|
315
|
+
return new Date(Date.parse(nowIso) + (offset > 0 ? offset : 0)).toISOString();
|
|
316
|
+
}
|
|
317
|
+
// recurring:真正触发靠 adapter cron
|
|
318
|
+
return nowIso;
|
|
319
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { effectiveUserId } from "../profile-helpers.js";
|
|
2
|
+
export function createStatsTools(repo) {
|
|
3
|
+
return {
|
|
4
|
+
async summarize(filters) {
|
|
5
|
+
if (!filters.user_id) {
|
|
6
|
+
throw new Error("user_id 为必填参数");
|
|
7
|
+
}
|
|
8
|
+
if (filters.from !== undefined &&
|
|
9
|
+
filters.to !== undefined &&
|
|
10
|
+
filters.from > filters.to) {
|
|
11
|
+
throw new Error("开始时间不能晚于结束时间");
|
|
12
|
+
}
|
|
13
|
+
const userId = effectiveUserId(filters);
|
|
14
|
+
return repo.summarize({ ...filters, user_id: userId });
|
|
15
|
+
},
|
|
16
|
+
async dailyBreakdown(filters) {
|
|
17
|
+
if (!filters.user_id) {
|
|
18
|
+
throw new Error("user_id 为必填参数");
|
|
19
|
+
}
|
|
20
|
+
if (filters.from !== undefined &&
|
|
21
|
+
filters.to !== undefined &&
|
|
22
|
+
filters.from > filters.to) {
|
|
23
|
+
throw new Error("开始时间不能晚于结束时间");
|
|
24
|
+
}
|
|
25
|
+
const userId = effectiveUserId(filters);
|
|
26
|
+
return repo.dailyBreakdown({ ...filters, user_id: userId });
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|