@rien7/agent-center-host 0.0.0-stage → 0.1.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.
@@ -0,0 +1,882 @@
1
+ /**
2
+ * The tools an agent gets from Center, served by the host over MCP (`serveMcpTools`, a
3
+ * loopback HTTP server per agent) instead of attaching core's `/mcp` directly. Core has
4
+ * ~100 tools (~130 KB of schemas per request); the model gets five:
5
+ *
6
+ * - `tools_search({query?, domain?})` names + one-line descriptions, no schemas
7
+ * - `tool_describe({name})` one tool's full input schema
8
+ * - `tool_call({name, input})` run it (proxied to core /mcp with the agent's token)
9
+ * - `today_overview({date?})` a day's events, reminders and visits in one call
10
+ * - `agent_search_query({q, …})` full-text search over past conversations
11
+ *
12
+ * The agent's token (its scopes) is what really bounds what it can do; the host adds a
13
+ * small denylist of tools an agent has no business calling (sync plumbing, settings,
14
+ * host/admin ops).
15
+ */
16
+ import { coreToolTitle, domainTitles } from "./tool-titles.js";
17
+ /** Center with an agent's own token: core `/mcp` and `/v1/ops`. */
18
+ export class CenterApi {
19
+ server;
20
+ token;
21
+ fetcher;
22
+ rpcId = 0;
23
+ constructor(server, token, options = {}) {
24
+ this.server = server.replace(/\/+$/, "");
25
+ this.token = token;
26
+ this.fetcher = options.fetch ?? fetch;
27
+ }
28
+ async rpc(method, params, signal) {
29
+ const response = await this.fetcher(`${this.server}/mcp`, {
30
+ method: "POST",
31
+ headers: {
32
+ authorization: `Bearer ${this.token}`,
33
+ "content-type": "application/json",
34
+ accept: "application/json, text/event-stream",
35
+ },
36
+ body: JSON.stringify({
37
+ jsonrpc: "2.0",
38
+ id: ++this.rpcId,
39
+ method,
40
+ params,
41
+ }),
42
+ signal,
43
+ });
44
+ let text = await response.text();
45
+ // Streamable HTTP may answer as SSE: take the last data line.
46
+ if (response.headers.get("content-type")?.includes("text/event-stream"))
47
+ text =
48
+ text
49
+ .split("\n")
50
+ .filter((l) => l.startsWith("data:"))
51
+ .map((l) => l.slice(5).trim())
52
+ .at(-1) ?? "";
53
+ let body;
54
+ try {
55
+ body = JSON.parse(text);
56
+ }
57
+ catch {
58
+ throw new Error(`core /mcp ${method} failed with ${response.status}`);
59
+ }
60
+ if (body.error || body.result === undefined)
61
+ throw new Error(body.error?.message ??
62
+ `core /mcp ${method} failed (${response.status})`);
63
+ return body.result;
64
+ }
65
+ async listTools() {
66
+ const tools = [];
67
+ let cursor;
68
+ do {
69
+ const page = await this.rpc("tools/list", cursor ? { cursor } : {});
70
+ for (const t of page.tools)
71
+ tools.push({
72
+ name: t.name,
73
+ description: t.description ?? "",
74
+ inputSchema: t.inputSchema ?? { type: "object" },
75
+ readOnly: t.annotations?.readOnlyHint === true,
76
+ });
77
+ cursor = page.nextCursor;
78
+ } while (cursor);
79
+ return tools;
80
+ }
81
+ /** core.domain.list: the domains this token can read, with titles and descriptions. */
82
+ async domains() {
83
+ const data = await this.op("core.domain.list", {});
84
+ return data.domains.map((d) => ({
85
+ domain: d.domain,
86
+ title: d.ui?.title ?? domainTitles[d.domain] ?? d.title ?? d.domain,
87
+ description: d.description ?? "",
88
+ order: d.ui?.order ?? 100,
89
+ }));
90
+ }
91
+ callTool(name, args, signal) {
92
+ return this.rpc("tools/call", { name, arguments: args }, signal);
93
+ }
94
+ async op(name, input, signal) {
95
+ const response = await this.fetcher(`${this.server}/v1/ops/${name}`, {
96
+ method: "POST",
97
+ headers: {
98
+ authorization: `Bearer ${this.token}`,
99
+ "content-type": "application/json",
100
+ },
101
+ body: JSON.stringify(input),
102
+ signal,
103
+ });
104
+ const body = (await response.json().catch(() => ({})));
105
+ if (!response.ok || body.data === undefined)
106
+ throw new Error(body.error?.message ?? `${name} failed with ${response.status}`);
107
+ return body.data;
108
+ }
109
+ }
110
+ /**
111
+ * Why an agent may not call a core tool, or null. Scopes stay the real boundary; this
112
+ * keeps out what is plumbing or the owner's business even when a token could reach it.
113
+ */
114
+ export function deniedReason(name) {
115
+ if (/^core_(sync|change|command|principal|token|oauth|scope|device)_/.test(name))
116
+ return "同步/凭据类内部接口";
117
+ if (name === "core_link_scan")
118
+ return "同步/凭据类内部接口";
119
+ if (/_settings_update$/.test(name))
120
+ return "存储设置只能由用户在 App 里改";
121
+ if (/^agent_/.test(name) &&
122
+ !/^agent_(search_query|agent_list|conversation_list|message_list)$/.test(name))
123
+ return "助手域的写入和 host 操作";
124
+ if (/^inbox_app_icons?$/.test(name))
125
+ return "二进制图标";
126
+ return null;
127
+ }
128
+ const firstSentence = (text, max = 120) => {
129
+ const one = text.replace(/\s+/g, " ").trim();
130
+ const cut = one.search(/(?<=[.。!?!?])\s|(?<=[。!?])/);
131
+ const sentence = cut > 0 ? one.slice(0, cut).trim() : one;
132
+ const chars = Array.from(sentence);
133
+ return chars.length > max ? `${chars.slice(0, max).join("")}…` : sentence;
134
+ };
135
+ const verbWords = [
136
+ [/_(create|add)$/, "新建 添加 加 创建 记 add create new"],
137
+ [/_(update|set|move)$/, "修改 改 更新 调整 edit update change"],
138
+ [/_(delete|remove)$/, "删除 删掉 去掉 remove delete"],
139
+ [/_complete$/, "完成 做完 勾掉 done complete"],
140
+ [/_uncomplete$/, "重新打开 取消完成"],
141
+ [
142
+ /_(list|get|getMany|summary|metric|series|days|balance)$/,
143
+ "查询 查看 看 列出 有哪些 list get",
144
+ ],
145
+ [/_(search|query)$/, "搜索 搜 找 查找 search find"],
146
+ ];
147
+ const cjk = /[\u3400-\u9fff]/;
148
+ function score(haystack, terms) {
149
+ let total = 0;
150
+ for (const term of terms) {
151
+ if (haystack.includes(term)) {
152
+ total += 2;
153
+ continue;
154
+ }
155
+ if (cjk.test(term) && Array.from(term).length > 1) {
156
+ // 「加提醒」: every character somewhere beats most of them.
157
+ const chars = Array.from(term).filter((c) => cjk.test(c));
158
+ const hits = chars.filter((c) => haystack.includes(c)).length;
159
+ if (hits === chars.length)
160
+ total += 1.5;
161
+ else if (hits * 2 >= chars.length)
162
+ total += 1;
163
+ }
164
+ }
165
+ return total;
166
+ }
167
+ const pad = (n) => String(n).padStart(2, "0");
168
+ /** Minutes east of UTC of `timeZone` at `instant`. */
169
+ function offsetMinutes(timeZone, instant) {
170
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
171
+ timeZone,
172
+ hourCycle: "h23",
173
+ year: "numeric",
174
+ month: "2-digit",
175
+ day: "2-digit",
176
+ hour: "2-digit",
177
+ minute: "2-digit",
178
+ second: "2-digit",
179
+ })
180
+ .formatToParts(new Date(instant))
181
+ .map((p) => [p.type, p.value]));
182
+ const local = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second));
183
+ return Math.round((local - Math.floor(instant / 1000) * 1000) / 60_000);
184
+ }
185
+ /** The local calendar date `YYYY-MM-DD` of `instant` in `timeZone`. */
186
+ export function localDate(timeZone, instant = Date.now()) {
187
+ return new Intl.DateTimeFormat("en-CA", {
188
+ timeZone,
189
+ year: "numeric",
190
+ month: "2-digit",
191
+ day: "2-digit",
192
+ }).format(new Date(instant));
193
+ }
194
+ /** [start, end) of a local day as UTC instants. */
195
+ export function dayRange(date, timeZone) {
196
+ const [y, m, d] = date.split("-").map(Number);
197
+ const midnight = (day) => {
198
+ const guess = Date.UTC(y, m - 1, day);
199
+ let t = guess - offsetMinutes(timeZone, guess) * 60_000;
200
+ const again = offsetMinutes(timeZone, t);
201
+ t = guess - again * 60_000;
202
+ return new Date(t).toISOString();
203
+ };
204
+ return { from: midnight(d), to: midnight(d + 1) };
205
+ }
206
+ /** `2026-11-03 周二 18:40` in `timeZone`. */
207
+ export function localNow(timeZone, instant = Date.now()) {
208
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
209
+ timeZone,
210
+ hourCycle: "h23",
211
+ weekday: "short",
212
+ hour: "2-digit",
213
+ minute: "2-digit",
214
+ })
215
+ .formatToParts(new Date(instant))
216
+ .map((p) => [p.type, p.value]));
217
+ const week = {
218
+ Sun: "周日",
219
+ Mon: "周一",
220
+ Tue: "周二",
221
+ Wed: "周三",
222
+ Thu: "周四",
223
+ Fri: "周五",
224
+ Sat: "周六",
225
+ };
226
+ return `${localDate(timeZone, instant)} ${week[parts.weekday] ?? ""} ${pad(Number(parts.hour))}:${parts.minute}`;
227
+ }
228
+ const textOf = (result) => result.content
229
+ .filter((c) => c.type === "text" && typeof c.text === "string")
230
+ .map((c) => c.text)
231
+ .join("\n");
232
+ const keep = [
233
+ "id",
234
+ "title",
235
+ "name",
236
+ "start",
237
+ "end",
238
+ "allDay",
239
+ "due",
240
+ "dueHasTime",
241
+ "completedAt",
242
+ "location",
243
+ "placeName",
244
+ "arrivedAt",
245
+ "departedAt",
246
+ "arrival",
247
+ "departure",
248
+ "minutes",
249
+ "category",
250
+ "notes",
251
+ ];
252
+ /** Trim a record to what a day overview needs. */
253
+ function brief(item) {
254
+ if (!item || typeof item !== "object")
255
+ return item;
256
+ const out = {};
257
+ for (const key of keep) {
258
+ const value = item[key];
259
+ if (value === undefined || value === null || value === "")
260
+ continue;
261
+ out[key] =
262
+ typeof value === "string" && value.length > 200
263
+ ? `${value.slice(0, 200)}…`
264
+ : value;
265
+ }
266
+ return Object.keys(out).length ? out : item;
267
+ }
268
+ /** What each domain holds, for the catalog (fallback: the contract's description). */
269
+ const domainBlurbs = {
270
+ reminder: "待办和提醒(到期、地点、完成)",
271
+ calendar: "日程",
272
+ memo: "备忘和笔记",
273
+ people: "联系人",
274
+ location: "保存的地点、去过哪里(到访记录)",
275
+ ledger: "账户、交易、分类、报表",
276
+ inbox: "收到的通知(手机推送、支付提醒、服务器告警)",
277
+ telemetry: "睡眠、步数等健康数据",
278
+ agent: "和助手的对话历史",
279
+ core: "跨域:时间线、按 URI 读记录、记录之间的关联",
280
+ };
281
+ /** Which domains keep their tool list when the catalog must shrink (first kept longest). */
282
+ const catalogPriority = [
283
+ "reminder",
284
+ "calendar",
285
+ "memo",
286
+ "people",
287
+ "location",
288
+ "agent",
289
+ "core",
290
+ "telemetry",
291
+ "inbox",
292
+ "ledger",
293
+ ];
294
+ /** Shown for a domain whose catalog line is shortened. */
295
+ const commonTools = {
296
+ ledger: [
297
+ "transaction_list",
298
+ "transaction_summary",
299
+ "report_summary",
300
+ "account_balance",
301
+ ],
302
+ inbox: ["notification_list", "notification_get", "today_metric"],
303
+ telemetry: ["sleep_summary", "steps_metric", "steps_series", "sample_list"],
304
+ };
305
+ /** A context's list tool per domain, when it isn't the first `_list`. */
306
+ const commonList = {
307
+ reminder: "reminder_item_list",
308
+ calendar: "calendar_event_list",
309
+ location: "location_visit_list",
310
+ memo: "memo_note_list",
311
+ people: "people_person_list",
312
+ ledger: "ledger_transaction_list",
313
+ inbox: "inbox_notification_list",
314
+ };
315
+ /** A context's single write per domain: the everyday create. */
316
+ const commonWrite = {
317
+ reminder: "reminder_item_create",
318
+ calendar: "calendar_event_create",
319
+ memo: "memo_note_create",
320
+ };
321
+ /** Rough token count: ~4 ASCII characters or ~1 CJK character per token. */
322
+ export function estimateTokens(text) {
323
+ let ascii = 0;
324
+ let other = 0;
325
+ for (const ch of text) {
326
+ if (ch.charCodeAt(0) < 128)
327
+ ascii++;
328
+ else
329
+ other++;
330
+ }
331
+ return Math.ceil(ascii / 4) + other;
332
+ }
333
+ /** The domains a chat context is about: the 今天 page → reminder, calendar, location. */
334
+ export function contextDomains(context) {
335
+ const kind = context?.kind;
336
+ if (!kind)
337
+ return [];
338
+ if (kind.type === "entity" && typeof kind.uri === "string") {
339
+ const domain = kind.uri.split(":")[0] ?? "";
340
+ return domain ? [domain] : [];
341
+ }
342
+ if (kind.type === "page" && typeof kind.page === "string") {
343
+ const page = kind.page.toLowerCase();
344
+ if (["today", "timeline", "home", "今天"].includes(page))
345
+ return ["reminder", "calendar", "location"];
346
+ return [page.split(/[./:]/)[0] ?? page];
347
+ }
348
+ return [];
349
+ }
350
+ /** A JSON Schema as short parameter lines (no regex patterns, no $schema). */
351
+ export function schemaLines(schema, indent = "") {
352
+ const props = (schema.properties ?? {});
353
+ const required = new Set(schema.required ?? []);
354
+ return Object.entries(props).map(([key, prop]) => {
355
+ const desc = typeof prop.description === "string"
356
+ ? ` — ${prop.description.replace(/\s+/g, " ")}`
357
+ : "";
358
+ return `${indent}${key}${required.has(key) ? "" : "?"}: ${typeLabel(prop)}${desc}`;
359
+ });
360
+ }
361
+ function typeLabel(prop) {
362
+ if (Array.isArray(prop.enum))
363
+ return prop.enum.map((v) => JSON.stringify(v)).join("|");
364
+ if ("const" in prop)
365
+ return JSON.stringify(prop.const);
366
+ const union = (prop.anyOf ?? prop.oneOf);
367
+ if (Array.isArray(union))
368
+ return union.map(typeLabel).join(" | ");
369
+ let label = String(prop.type ?? "any");
370
+ if (prop.type === "string") {
371
+ if (prop.format)
372
+ label = `string(${prop.format})`;
373
+ if (prop.maxLength)
374
+ label += `≤${prop.maxLength}`;
375
+ }
376
+ else if (prop.type === "integer" || prop.type === "number") {
377
+ if (prop.minimum !== undefined || prop.maximum !== undefined)
378
+ label += ` ${prop.minimum ?? ""}–${prop.maximum ?? ""}`;
379
+ }
380
+ else if (prop.type === "array" && prop.items)
381
+ label = `${typeLabel(prop.items)}[]`;
382
+ else if (prop.type === "object" && prop.properties)
383
+ label = `{ ${Object.entries(prop.properties)
384
+ .map(([k, v]) => `${k}${(prop.required ?? []).includes(k) ? "" : "?"}: ${typeLabel(v)}`)
385
+ .join(", ")} }`;
386
+ if (prop.default !== undefined)
387
+ label += ` = ${JSON.stringify(prop.default)}`;
388
+ return label;
389
+ }
390
+ /** Strip regex patterns (uuid/date-time) and `$schema` from a schema, deeply. */
391
+ export function compactSchema(value) {
392
+ if (Array.isArray(value))
393
+ return value.map(compactSchema);
394
+ if (!value || typeof value !== "object")
395
+ return value;
396
+ const out = {};
397
+ for (const [k, v] of Object.entries(value)) {
398
+ if (k === "$schema")
399
+ continue;
400
+ if (k === "pattern" && "format" in value)
401
+ continue;
402
+ out[k] = compactSchema(v);
403
+ }
404
+ return out;
405
+ }
406
+ /** Order inside an expanded domain: the everyday verbs first. */
407
+ const verbRank = (name) => {
408
+ const order = [
409
+ /_list$/,
410
+ /_create$/,
411
+ /_update$/,
412
+ /_complete$/,
413
+ /_delete$/,
414
+ /_search|_query$/,
415
+ /_get$/,
416
+ ];
417
+ const i = order.findIndex((p) => p.test(name));
418
+ return i < 0 ? order.length : i;
419
+ };
420
+ /** Core's MCP result text is the op's output, or `{data, commandId}` for a command. */
421
+ const unwrap = (value) => value &&
422
+ typeof value === "object" &&
423
+ "data" in value &&
424
+ Object.keys(value).every((k) => k === "data" || k === "commandId" || k === "dryRun")
425
+ ? value.data
426
+ : value;
427
+ /** The host-served tool set for one agent. */
428
+ export class CenterTools {
429
+ api;
430
+ timeZone;
431
+ catalog = [];
432
+ domainInfo = [];
433
+ loadedAt = 0;
434
+ loading = null;
435
+ refreshMs;
436
+ now;
437
+ constructor(options) {
438
+ this.api = options.api;
439
+ this.timeZone = options.timeZone;
440
+ this.refreshMs = options.refreshMs ?? 10 * 60_000;
441
+ this.now = options.now ?? Date.now;
442
+ }
443
+ /** Core tools this agent may use (visible to its token, minus the denylist). */
444
+ async tools() {
445
+ if (!this.loadedAt || this.now() - this.loadedAt > this.refreshMs) {
446
+ this.loading ??= this.load();
447
+ await this.loading;
448
+ }
449
+ return this.catalog;
450
+ }
451
+ load() {
452
+ return Promise.all([
453
+ this.api.listTools(),
454
+ this.api.domains().catch(() => this.domainInfo),
455
+ ])
456
+ .then(([all, domains]) => {
457
+ this.catalog = all.filter((t) => !deniedReason(t.name));
458
+ this.domainInfo = domains;
459
+ this.loadedAt = this.now();
460
+ })
461
+ .finally(() => {
462
+ this.loading = null;
463
+ });
464
+ }
465
+ async find(name) {
466
+ return (await this.tools()).find((t) => t.name === name) ?? null;
467
+ }
468
+ async isWrite(name) {
469
+ const tool = await this.find(name);
470
+ return tool ? !tool.readOnly : false;
471
+ }
472
+ /**
473
+ * One line per domain the token can see: `reminder 提醒:待办和提醒 — item_list,
474
+ * item_create, …`. Within `budget` (estimated tokens) the less used domains drop their
475
+ * tool list first and keep a count.
476
+ */
477
+ async domainCatalog(budget = 500) {
478
+ const tools = await this.tools();
479
+ const byDomain = new Map();
480
+ for (const t of tools) {
481
+ const d = t.name.split("_")[0] ?? "";
482
+ byDomain.set(d, [...(byDomain.get(d) ?? []), t.name.slice(d.length + 1)]);
483
+ }
484
+ const byDomainSorted = new Map([...byDomain].map(([d, names]) => [
485
+ d,
486
+ [...names].sort((a, b) => verbRank(`_${a}`) - verbRank(`_${b}`) || a.localeCompare(b)),
487
+ ]));
488
+ const info = new Map(this.domainInfo.map((d) => [d.domain, d]));
489
+ const domains = [...byDomain.keys()].sort((a, b) => (a === "core" ? 1 : 0) - (b === "core" ? 1 : 0) ||
490
+ (info.get(a)?.order ?? 100) - (info.get(b)?.order ?? 100) ||
491
+ a.localeCompare(b));
492
+ const brief = new Set();
493
+ const render = () => domains
494
+ .map((d) => {
495
+ const i = info.get(d);
496
+ const title = i?.title ?? domainTitles[d] ?? d;
497
+ const blurb = domainBlurbs[d] ?? firstSentence(i?.description ?? "", 60);
498
+ const names = byDomain.get(d) ?? [];
499
+ const list = brief.has(d)
500
+ ? `${names.length} 个工具,常用 ${(commonTools[d]?.filter((n) => names.includes(n)) ?? byDomainSorted.get(d) ?? []).slice(0, 4).join(", ")}…`
501
+ : names.join(", ");
502
+ return `${d} ${title}:${blurb} — ${list}`;
503
+ })
504
+ .join("\n");
505
+ let text = render();
506
+ const shrink = [...domains].sort((a, b) => (catalogPriority.indexOf(b) + 1 || 99) -
507
+ (catalogPriority.indexOf(a) + 1 || 99));
508
+ for (const d of shrink) {
509
+ if (estimateTokens(text) <= budget)
510
+ break;
511
+ brief.add(d);
512
+ text = render();
513
+ }
514
+ return text;
515
+ }
516
+ /**
517
+ * Full tools of the domains a turn is about, inlined in its prompt: name, title,
518
+ * description and parameters, so the model can call them via tool_call right away.
519
+ */
520
+ /** A domain's tools, everyday verbs first. */
521
+ async domainTools(domain) {
522
+ return (await this.tools())
523
+ .filter((t) => t.name.startsWith(`${domain}_`) && !/_settings_/.test(t.name))
524
+ .sort((a, b) => verbRank(a.name) - verbRank(b.name) || a.name.localeCompare(b.name));
525
+ }
526
+ /**
527
+ * What a context expands for a domain: its list and get, plus its most common write
528
+ * (create). The rest stays a tool_describe away.
529
+ */
530
+ async contextTools(domain) {
531
+ const own = await this.domainTools(domain);
532
+ const list = own.find((t) => t.name === commonList[domain]) ??
533
+ own.find((t) => /_list$/.test(t.name));
534
+ const get = own.find((t) => /_get$/.test(t.name));
535
+ const write = own.find((t) => t.name === commonWrite[domain]) ??
536
+ own.find((t) => /_create$/.test(t.name));
537
+ const shown = [list, get, write].filter((t) => !!t);
538
+ return { shown, rest: own.filter((t) => !shown.includes(t)) };
539
+ }
540
+ async expandDomains(domains) {
541
+ const blocks = [];
542
+ for (const d of domains) {
543
+ const { shown, rest } = await this.contextTools(d);
544
+ if (!shown.length)
545
+ continue;
546
+ blocks.push([
547
+ `## ${domainTitles[d] ?? d}(${d})的工具,用 tool_call({name, input}) 调用`,
548
+ ...shown.map((t) => [
549
+ `### ${t.name} ${coreToolTitle(t.name, t.readOnly)}${t.readOnly ? "" : "(写入)"}`,
550
+ t.description.replace(/\s+/g, " "),
551
+ ...schemaLines(t.inputSchema, "- "),
552
+ ].join("\n")),
553
+ ...(rest.length
554
+ ? [
555
+ `(还有 ${rest.length} 个:${rest
556
+ .map((t) => t.name)
557
+ .join(", ")};用 tool_describe 看参数)`,
558
+ ]
559
+ : []),
560
+ ].join("\n"));
561
+ }
562
+ return blocks.join("\n\n");
563
+ }
564
+ /** `name 标题:one sentence`, grouped by domain. */
565
+ async search(input) {
566
+ const tools = await this.tools();
567
+ const domain = input.domain?.trim().toLowerCase();
568
+ const inDomain = domain
569
+ ? tools.filter((t) => t.name.startsWith(`${domain}_`) ||
570
+ domainTitles[t.name.split("_")[0] ?? ""] === input.domain?.trim())
571
+ : tools;
572
+ const query = input.query?.trim().toLowerCase();
573
+ let hits;
574
+ if (query) {
575
+ const terms = query.split(/[\s,,、]+/).filter(Boolean);
576
+ hits = inDomain
577
+ .map((t) => {
578
+ const d = t.name.split("_")[0] ?? "";
579
+ const verbs = verbWords
580
+ .filter(([p]) => p.test(t.name))
581
+ .map(([, w]) => w)
582
+ .join(" ");
583
+ const hay = `${t.name} ${t.name.replaceAll("_", " ")} ${coreToolTitle(t.name, t.readOnly)} ${domainTitles[d] ?? ""} ${verbs} ${t.description}`.toLowerCase();
584
+ return { t, s: score(hay, terms) };
585
+ })
586
+ .filter((x) => x.s > 0)
587
+ .sort((a, b) => b.s - a.s)
588
+ .slice(0, 25)
589
+ .map((x) => x.t);
590
+ }
591
+ else if (domain)
592
+ hits = inDomain;
593
+ else {
594
+ // Overview: every domain with its tool names.
595
+ const groups = new Map();
596
+ for (const t of tools) {
597
+ const d = t.name.split("_")[0] ?? "";
598
+ groups.set(d, [...(groups.get(d) ?? []), t.name]);
599
+ }
600
+ return [
601
+ "可用的工具(按域)。用 tools_search({query}) 或 tools_search({domain}) 看说明,tool_describe 看参数,tool_call 调用。",
602
+ ...[...groups].map(([d, names]) => `\n${domainTitles[d] ?? d}(${d},${names.length} 个):${names.join(", ")}`),
603
+ ].join("\n");
604
+ }
605
+ if (!hits.length)
606
+ return `没有找到匹配「${input.query ?? input.domain ?? ""}」的工具。可用的域:${[
607
+ ...new Set(tools.map((t) => t.name.split("_")[0])),
608
+ ]
609
+ .map((d) => `${domainTitles[d ?? ""] ?? d}(${d})`)
610
+ .join("、")}。换个关键词,或用 tools_search({domain}) 列出一个域的全部工具。`;
611
+ const lines = [];
612
+ let current = "";
613
+ for (const t of [...hits].sort((a, b) => query ? 0 : a.name.localeCompare(b.name))) {
614
+ const d = t.name.split("_")[0] ?? "";
615
+ if (!query && d !== current) {
616
+ current = d;
617
+ lines.push(`${domainTitles[d] ?? d}(${d})`);
618
+ }
619
+ lines.push(`- ${t.name} ${coreToolTitle(t.name, t.readOnly)}${t.readOnly ? "" : "(写入)"}:${firstSentence(t.description)}`);
620
+ }
621
+ lines.push("用 tool_describe({name}) 看参数,再用 tool_call({name, input}) 调用。");
622
+ return lines.join("\n");
623
+ }
624
+ async describe(name) {
625
+ const tool = await this.find(name);
626
+ if (!tool)
627
+ return null;
628
+ const schema = compactSchema(tool.inputSchema);
629
+ return JSON.stringify({
630
+ name: tool.name,
631
+ title: coreToolTitle(tool.name, tool.readOnly),
632
+ writes: !tool.readOnly,
633
+ description: tool.description,
634
+ inputSchema: schema,
635
+ }, null, 1);
636
+ }
637
+ async call(name, input, signal) {
638
+ const denied = deniedReason(name);
639
+ if (denied)
640
+ return {
641
+ content: [{ type: "text", text: `不能调用 ${name}:${denied}。` }],
642
+ isError: true,
643
+ };
644
+ if (!(await this.find(name)))
645
+ return {
646
+ content: [
647
+ {
648
+ type: "text",
649
+ text: `没有工具 ${name}(或这个助手没有权限)。先用 tools_search 找工具名。`,
650
+ },
651
+ ],
652
+ isError: true,
653
+ };
654
+ return this.api.callTool(name, input, signal);
655
+ }
656
+ /** A day's events, reminders (due that day, plus overdue ones for today) and visits. */
657
+ async todayOverview(input, signal) {
658
+ const today = localDate(this.timeZone, this.now());
659
+ const date = input.date && /^\d{4}-\d{2}-\d{2}$/.test(input.date) ? input.date : today;
660
+ const { from, to } = dayRange(date, this.timeZone);
661
+ const tz = this.timeZone;
662
+ const available = new Set((await this.tools()).map((t) => t.name));
663
+ const section = async (name, args) => {
664
+ if (!available.has(name))
665
+ return { unavailable: true };
666
+ try {
667
+ const result = await this.api.callTool(name, args, signal);
668
+ const text = textOf(result);
669
+ if (result.isError)
670
+ return { error: text.slice(0, 300) };
671
+ const data = unwrap(JSON.parse(text));
672
+ const items = data.items;
673
+ return Array.isArray(items) ? items.map(brief) : data;
674
+ }
675
+ catch (error) {
676
+ return { error: error.message };
677
+ }
678
+ };
679
+ const [events, reminders, overdue, visits] = await Promise.all([
680
+ section("calendar_event_list", { from, to, limit: 50 }),
681
+ section("reminder_item_list", { status: "open", from, to, limit: 50 }),
682
+ date === today
683
+ ? section("reminder_item_list", { status: "open", to: from, limit: 20 })
684
+ : Promise.resolve(undefined),
685
+ section("location_visit_list", { from, to, tz, limit: 30 }),
686
+ ]);
687
+ return JSON.stringify({
688
+ date,
689
+ timeZone: tz,
690
+ range: { from, to },
691
+ events,
692
+ reminders,
693
+ ...(overdue !== undefined ? { overdueReminders: overdue } : {}),
694
+ visits,
695
+ });
696
+ }
697
+ }
698
+ const symbols = {
699
+ reminder: "checklist",
700
+ calendar: "calendar",
701
+ memo: "note.text",
702
+ people: "person.crop.circle",
703
+ location: "mappin.and.ellipse",
704
+ ledger: "creditcard",
705
+ agent: "text.bubble",
706
+ };
707
+ function preview(uri, value) {
708
+ const str = (key) => typeof value?.[key] === "string" && value[key]
709
+ ? value[key]
710
+ : null;
711
+ const domain = uri.split(":")[0] ?? "";
712
+ const body = str("body") ?? str("text");
713
+ const title = str("title") ??
714
+ str("name") ??
715
+ (body
716
+ ? Array.from(body.split("\n")[0] ?? "")
717
+ .slice(0, 80)
718
+ .join("")
719
+ : null) ??
720
+ domainTitles[domain] ??
721
+ uri;
722
+ const date = str("due") ?? str("start") ?? str("at");
723
+ return {
724
+ title,
725
+ summary: str("location") ?? str("notes")?.slice(0, 200) ?? null,
726
+ symbol: symbols[domain] ?? null,
727
+ date,
728
+ dateHasTime: typeof value?.dueHasTime === "boolean"
729
+ ? value.dueHasTime
730
+ : typeof value?.allDay === "boolean"
731
+ ? !value.allDay
732
+ : true,
733
+ done: !!value?.completedAt,
734
+ };
735
+ }
736
+ /**
737
+ * Record cards for a write: the result of a core command carries its `commandId`
738
+ * (`{data, commandId}`); `core.command.get` lists the entities it changed and
739
+ * `core.entity.get` gives their current values. Both run with the agent's token, so only
740
+ * records it can read become cards. Nothing to show (a read, a dry run, an error): [].
741
+ */
742
+ export async function recordCards(api, resultText, options = {}) {
743
+ let body;
744
+ try {
745
+ body = JSON.parse(resultText);
746
+ }
747
+ catch {
748
+ return [];
749
+ }
750
+ if (typeof body?.commandId !== "string" || body.dryRun)
751
+ return [];
752
+ const command = await api.op("core.command.get", { id: body.commandId }, options.signal);
753
+ const byUri = new Map();
754
+ for (const change of command.changes ?? []) {
755
+ if (change.op === "purge")
756
+ continue;
757
+ const seen = byUri.get(change.uri);
758
+ // insert wins over a later update of the same row; delete wins over both.
759
+ if (!seen ||
760
+ change.op === "delete" ||
761
+ (seen === "update" && change.op === "insert"))
762
+ byUri.set(change.uri, change.op);
763
+ }
764
+ const uris = [...byUri.keys()].slice(0, options.max ?? 3);
765
+ if (!uris.length)
766
+ return [];
767
+ const values = await api
768
+ .op("core.entity.get", { uris, includeDeleted: true }, options.signal)
769
+ .then((r) => new Map(r.items.map((i) => [i.uri, i.value])))
770
+ .catch(() => new Map());
771
+ return uris
772
+ .filter((uri) => values.has(uri))
773
+ .map((uri) => {
774
+ const op = byUri.get(uri);
775
+ return {
776
+ type: "record",
777
+ record: {
778
+ uri,
779
+ change: op === "insert"
780
+ ? "created"
781
+ : op === "update"
782
+ ? "modified"
783
+ : null,
784
+ deleted: op === "delete",
785
+ preview: preview(uri, values.get(uri) ?? null),
786
+ },
787
+ };
788
+ });
789
+ }
790
+ /** Names of the host-served tools whose `input.name` is the real tool. */
791
+ export const proxyTools = new Set(["tool_call", "tool_describe"]);
792
+ export const centerInstructions = "Center 是用户的个人数据中心(提醒、日程、备忘、联系人、地点、账本、通知、健康…)。需要查或改数据时先用 tools_search 找工具,tool_describe 看参数,tool_call 调用;今天的安排直接用 today_overview;以前聊过的事用 agent_search_query。";
793
+ /** The five tools the model sees. */
794
+ export async function centerServedTools(tools) {
795
+ const search = await tools.find("agent_search_query").catch(() => null);
796
+ const result = (text, isError = false) => ({
797
+ content: [{ type: "text", text }],
798
+ ...(isError ? { isError } : {}),
799
+ });
800
+ const served = [
801
+ {
802
+ name: "tools_search",
803
+ description: "查找 Center 的数据工具。domain 列出一个域的全部工具(如 reminder、ledger);query 按关键词找(中文或英文,如「新建提醒」「睡眠」「search」),两者可以一起用。返回工具名、标题和一句话说明,不含参数;参数用 tool_describe 看。",
804
+ inputSchema: {
805
+ type: "object",
806
+ properties: {
807
+ query: { type: "string", description: "关键词" },
808
+ domain: { type: "string", description: "域名,如 reminder" },
809
+ },
810
+ },
811
+ handler: async (input) => tools.search({
812
+ query: typeof input.query === "string" ? input.query : undefined,
813
+ domain: typeof input.domain === "string" ? input.domain : undefined,
814
+ }),
815
+ },
816
+ {
817
+ name: "tool_describe",
818
+ description: "一个工具的完整说明和输入参数(JSON Schema)。调用 tool_call 之前先看。",
819
+ inputSchema: {
820
+ type: "object",
821
+ properties: {
822
+ name: {
823
+ type: "string",
824
+ description: "工具名,如 reminder_item_create",
825
+ },
826
+ },
827
+ required: ["name"],
828
+ },
829
+ handler: async (input) => {
830
+ const name = String(input.name ?? "");
831
+ return ((await tools.describe(name)) ??
832
+ result(`没有工具 ${name}。先用 tools_search 找工具名。`, true));
833
+ },
834
+ },
835
+ {
836
+ name: "tool_call",
837
+ description: "调用一个 Center 工具:name 是 tools_search 找到的工具名,input 是按 tool_describe 的参数写的对象。写入类工具会真的修改用户数据;新建时 id 可省略。",
838
+ inputSchema: {
839
+ type: "object",
840
+ properties: {
841
+ name: { type: "string" },
842
+ input: { type: "object", description: "工具参数" },
843
+ },
844
+ required: ["name"],
845
+ },
846
+ handler: async (input, { signal }) => {
847
+ const name = String(input.name ?? "");
848
+ let args = input.input ?? {};
849
+ if (typeof args === "string")
850
+ try {
851
+ args = JSON.parse(args);
852
+ }
853
+ catch { }
854
+ if (!args || typeof args !== "object" || Array.isArray(args))
855
+ return result("input 要是一个对象。", true);
856
+ return tools.call(name, args, signal);
857
+ },
858
+ },
859
+ {
860
+ name: "today_overview",
861
+ description: "某一天(默认今天)的概况:日程、当天到期的提醒(今天还包括过期未完成的)、去过的地点。回答「今天/明天有什么安排」这类问题先用它。",
862
+ inputSchema: {
863
+ type: "object",
864
+ properties: {
865
+ date: {
866
+ type: "string",
867
+ description: "YYYY-MM-DD,本地日期;省略为今天",
868
+ },
869
+ },
870
+ },
871
+ handler: async (input, { signal }) => tools.todayOverview({ date: typeof input.date === "string" ? input.date : undefined }, signal),
872
+ },
873
+ ];
874
+ if (search)
875
+ served.push({
876
+ name: "agent_search_query",
877
+ description: `搜索以前的对话和工具调用(想不起来的事先搜这里)。${search.description}`,
878
+ inputSchema: compactSchema(search.inputSchema),
879
+ handler: (input, { signal }) => tools.api.callTool("agent_search_query", input, signal),
880
+ });
881
+ return served;
882
+ }