@my-life-buddies/buddy-runtime 0.25.0-dev.1 → 0.25.0-dev.2

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/README.md CHANGED
@@ -208,10 +208,10 @@ interface Conversation {
208
208
 
209
209
  ## 3. 平台能力
210
210
 
211
- | 能力 | `conversation` 对象方法 | 模型工具(`conversation.tools` 上) |
211
+ | 能力 | `conversation` 对象方法 | 内建模型工具(`conversation.tools` 上) |
212
212
  |---|---|---|
213
213
  | 资料 | `conversation.resource.list` / `conversation.resource.read` | `resource_list` / `resource_read` |
214
- | 搜索 | 无 | `search_web` / `search_rednote` |
214
+ | 搜索 | `conversation.search.web` / `conversation.search.rednote` | `search_web` / `search_rednote` |
215
215
  | 提问 | 无 | `ask_question` |
216
216
  | 记忆 | `conversation.memory` | 无,开发者可以根据具体场景,自己组装成适合的模型工具 |
217
217
  | 用户授权数据 | `conversation.dataAccess.query` / `conversation.dataAccess.request` | `data_access_query` / `data_access_request` |
@@ -228,6 +228,8 @@ buddy: (conversation) => ({
228
228
  }),
229
229
  ```
230
230
 
231
+ `conversation` 上的方法在自己的模型工具里调,各节「`conversation` 对象方法」下都有一个完整的注册例子。
232
+
231
233
  ### 3.1 资料
232
234
 
233
235
  搭子随身带的只读资料:把 `.md` 放进工程目录下的 `resources/`,模型先列出有哪些资料,再按需读一篇。资料在进程启动时一次读好,整场会话、每个回合读到的都是同一份。
@@ -251,14 +253,36 @@ my-buddy/ 工程目录
251
253
 
252
254
  | 方法 | 做什么 |
253
255
  |---|---|
254
- | `list()` | 全部资料的路径,相对 `resources/`,按路径排序 |
255
- | `read(file)` | 读一篇的正文,`file` 照 `list()` 给的路径原样填 |
256
+ | `resource.list()` | 列出全部资料的路径 |
257
+ | `resource.read(file)` | 读一篇的正文 |
256
258
 
257
259
  ```ts
258
- const notes = conversation.resource.read("research/01-writings.md");
260
+ resource: {
261
+ list(): string[]; // 相对 resources/ 的路径,按路径排序,如 research/01-writings.md
262
+ read(file: string): string; // file 照 list() 给的路径原样填;没有这篇抛异常
263
+ }
259
264
  ```
260
265
 
261
- **模型工具**
266
+ 注册成模型工具:
267
+
268
+ ```ts
269
+ await BuddyServer.start((conversation) => {
270
+ const READ_NOTES_PARAMS = Type.Object({});
271
+ const readNotes: AgentTool<typeof READ_NOTES_PARAMS, undefined> = {
272
+ name: "read_notes",
273
+ label: "正在翻资料",
274
+ description: "读这只搭子带的写作研究笔记,回答写作方法的问题前先读。",
275
+ parameters: READ_NOTES_PARAMS,
276
+ execute: async () => {
277
+ const notes = conversation.resource.read("research/01-writings.md");
278
+ return { content: [{ type: "text", text: notes }], details: undefined };
279
+ },
280
+ };
281
+ return { initialState: { model: "kimi-k3", systemPrompt: PERSONA, tools: [readNotes] } };
282
+ });
283
+ ```
284
+
285
+ **内建模型工具**
262
286
 
263
287
  把 `conversation.tools.resource_list` 和 `conversation.tools.resource_read` 放进 `initialState.tools`,模型就能先列出有哪些资料,再按路径读一篇。`systemPrompt` 里交代清楚这只搭子带了哪类资料、什么时候该去翻。
264
288
 
@@ -277,56 +301,76 @@ const notes = conversation.resource.read("research/01-writings.md");
277
301
 
278
302
  | 方法 | 做什么 |
279
303
  |---|---|
280
- | `search.web({ query, topic?, timeRange?, maxResults? })` | 搜站外网页,回结果数组,字段见下面 `search_web` 的结果表;`maxResults` 1~20,缺省 20 |
281
- | `search.rednote({ query, timeRange?, maxResults?, includeComments? })` | 搜小红书笔记,回结果数组,字段见下面 `search_rednote` 的结果表 |
304
+ | `search.web(input)` | 搜站外网页,回结果数组 |
305
+ | `search.rednote(input)` | 搜小红书笔记,回结果数组 |
306
+
307
+ ```ts
308
+ search: {
309
+ web(input: SearchWebRequest): Promise<SearchWebResult[]>;
310
+ rednote(input: SearchRednoteRequest): Promise<SearchRednoteResult[]>;
311
+ }
312
+
313
+ interface SearchWebRequest {
314
+ query: string; // 必填,1~120 字
315
+ topic?: "general" | "news"; // 缺省 general
316
+ timeRange?: "day" | "week" | "month" | "year"; // 缺省不限时间
317
+ maxResults?: number; // 1~20,缺省 20
318
+ }
319
+
320
+ interface SearchWebResult {
321
+ title: string;
322
+ url: string;
323
+ content: string; // 上游按查询词挑出的片段,不是整页
324
+ score: number; // 相关度,0~1
325
+ publishedAt?: string; // 上游给了才有
326
+ }
327
+
328
+ interface SearchRednoteRequest {
329
+ query: string; // 必填,1~120 字
330
+ timeRange?: "day" | "week" | "month" | "year"; // 缺省不限时间
331
+ maxResults?: number; // 1~40,缺省 20
332
+ includeComments?: boolean; // 缺省 false
333
+ }
334
+
335
+ interface SearchRednoteResult {
336
+ title: string;
337
+ url: string;
338
+ content: string; // 正文原文,不截断
339
+ score: number; // 相关度,0~1
340
+ publishedAt?: string; // 上游给了才有
341
+ likes?: string; // 点赞数所在的区间,如 "1000-10000",上游给了才有
342
+ collects?: string; // 收藏数所在的区间,同上
343
+ comments?: string[]; // 评论原文,每篇最多 10 条;includeComments 为 true 且有评论才有
344
+ }
345
+ ```
282
346
 
283
- 模型工具 `search_web` / `search_rednote` 就是这两个方法的包装。要换说明、换结果排法、搜完再加工,就自己包一个工具:
347
+ 注册成模型工具:
284
348
 
285
349
  ```ts
286
- const FIND_RECIPE_PARAMS = Type.Object({ dish: Type.String({ description: "菜名" }) });
287
- const findRecipe: AgentTool<typeof FIND_RECIPE_PARAMS, undefined> = {
288
- name: "find_recipe",
289
- label: "正在找菜谱",
290
- description: "按菜名找做法,只看小红书。",
291
- parameters: FIND_RECIPE_PARAMS,
292
- execute: async (_id, { dish }) => {
293
- const notes = await conversation.search.rednote({ query: `${dish} 做法`, maxResults: 5 });
294
- return { content: [{ type: "text", text: notes.map((n) => `${n.title}\n${n.url}\n${n.content}`).join("\n\n") }], details: undefined };
295
- },
296
- };
350
+ await BuddyServer.start((conversation) => {
351
+ const FIND_RECIPE_PARAMS = Type.Object({ dish: Type.String({ description: "菜名" }) });
352
+ const findRecipe: AgentTool<typeof FIND_RECIPE_PARAMS, undefined> = {
353
+ name: "find_recipe",
354
+ label: "正在找菜谱",
355
+ description: "按菜名找做法,只看小红书。",
356
+ parameters: FIND_RECIPE_PARAMS,
357
+ execute: async (_toolCallId, { dish }) => {
358
+ const notes = await conversation.search.rednote({ query: `${dish} 做法`, maxResults: 5 });
359
+ return { content: [{ type: "text", text: notes.map((n) => `${n.title}\n${n.url}\n${n.content}`).join("\n\n") }], details: undefined };
360
+ },
361
+ };
362
+ return { initialState: { model: "kimi-k3", systemPrompt: PERSONA, tools: [findRecipe] } };
363
+ });
297
364
  ```
298
365
 
299
- **模型工具**
366
+ **内建模型工具**
300
367
 
301
368
  把需要的工具放进 `initialState.tools`;什么时候该搜、搜哪边、回答里怎么引用,在 `systemPrompt` 里交代。
302
369
 
303
370
  | 工具 | 参数 | 结果 |
304
371
  |---|---|---|
305
- | `search_web` | `query`(必填,1~120 字)、`topic`(`general` / `news`,缺省 `general`)、`timeRange`(`day` / `week` / `month` / `year`,缺省不限时间)。走哪家搜索引擎由平台定,开发者和模型都不选 | 最多 20 条,字段见下表 |
306
- | `search_rednote` | `query`、`timeRange`、`maxResults`(1~40,缺省 20)、`includeComments`(缺省 false) | 最多 40 篇,字段见下表 |
307
-
308
- `search_web` 每条结果的字段:
309
-
310
- | 字段 | 说明 |
311
- |---|---|
312
- | `title` | 网页标题 |
313
- | `url` | 链接 |
314
- | `content` | 上游按查询词挑出的相关内容片段 |
315
- | `score` | 相关度,0 到 1 |
316
- | `publishedAt` | 发布时间,上游给了才有 |
317
-
318
- `search_rednote` 每篇结果的字段:
319
-
320
- | 字段 | 说明 |
321
- |---|---|
322
- | `title` | 笔记标题 |
323
- | `url` | 笔记链接 |
324
- | `content` | 正文原文 |
325
- | `score` | 相关度,0 到 1 |
326
- | `publishedAt` | 发布时间 |
327
- | `likes` | 点赞数所在的区间,如 `1000-10000` |
328
- | `collects` | 收藏数所在的区间,同上 |
329
- | `comments` | 评论原文,每篇最多 10 条;`includeComments` 为 true 且有评论才有 |
372
+ | `search_web` | 同 `SearchWebRequest`,少 `maxResults`,固定 20。走哪家搜索引擎由平台定,开发者和模型都不选 | 最多 20 条 `SearchWebResult` |
373
+ | `search_rednote` | 同 `SearchRednoteRequest` | 最多 40 篇 `SearchRednoteResult` |
330
374
 
331
375
  ```ts
332
376
  await BuddyServer.start((conversation) => ({
@@ -343,7 +387,7 @@ await BuddyServer.start((conversation) => ({
343
387
  **限制与异常**
344
388
 
345
389
  - `query` 超过 120 字、`maxResults` 超出 1~40 时参数校验不通过,工具不执行。
346
- - 同一会话一分钟最多 12 次搜索,同一搭子全部会话一分钟最多 180 次,超出时方法与工具都抛异常(HTTP 429)。
390
+ - 同一会话一分钟最多 12 次搜索,同一搭子全部会话一分钟最多 180 次;本地用开发令牌跑的 `<id>-dev` 身份两项都是 4 次。超出时方法与工具都抛异常(HTTP 429)。
347
391
  - 平台搜索服务不可用或超时时工具抛异常,异常只带 HTTP 状态码,不带上游原文;模型可以告诉用户暂时搜不了,不应编造结果。
348
392
  - 没有结果时工具正常返回「没有找到相关结果。可以换个说法再搜一次。」
349
393
  - `search_rednote` 的正文和评论不截断,40 篇带评论的一次结果可能有几万字,会占用较多上下文;不需要那么多时让模型用缺省篇数。
@@ -356,7 +400,7 @@ await BuddyServer.start((conversation) => ({
356
400
 
357
401
  没有。`conversation` 不提供发题卡的方法,题卡只由模型发。
358
402
 
359
- **模型工具**
403
+ **内建模型工具**
360
404
 
361
405
  把 `conversation.tools.ask_question` 放进 `initialState.tools`,模型就能发题卡。
362
406
 
@@ -378,19 +422,45 @@ await BuddyServer.start((conversation) => ({
378
422
 
379
423
  | 方法 | 做什么 |
380
424
  |---|---|
381
- | `list()` | 列出全部,按 `key` 字典序 |
382
- | `write(key, text)` | 覆盖写一条 |
383
- | `delete(key)` | 删一条,不存在也算成功 |
425
+ | `memory.list()` | 列出全部记忆 |
426
+ | `memory.write(key, text)` | 覆盖写一条 |
427
+ | `memory.delete(key)` | 删一条 |
384
428
 
385
429
  ```ts
430
+ memory: {
431
+ list(): Promise<MemoryEntry[]>; // 按 key 字典序
432
+ write(key: string, text: string): Promise<void>; // 同一个 key 覆盖写;超配额、key 不合法时抛异常,文案是平台给的原因
433
+ delete(key: string): Promise<void>; // 不存在也算成功
434
+ }
435
+
386
436
  interface MemoryEntry {
387
- key: string;
437
+ key: string; // 主题名
388
438
  text: string;
389
439
  updatedAt: number; // 最后写入时刻,毫秒
390
440
  }
391
441
  ```
392
442
 
393
- **模型工具**
443
+ 注册成模型工具:
444
+
445
+ ```ts
446
+ await BuddyServer.start((conversation) => {
447
+ const RECALL_PARAMS = Type.Object({});
448
+ const recall: AgentTool<typeof RECALL_PARAMS, undefined> = {
449
+ name: "recall",
450
+ label: "正在翻印象",
451
+ description: "读出记过的全部印象,回答跟对方有关的问题前先读。",
452
+ parameters: RECALL_PARAMS,
453
+ execute: async () => {
454
+ const entries = await conversation.memory.list();
455
+ const text = entries.length === 0 ? "还没记过印象。" : entries.map((e) => `【${e.key}】\n${e.text}`).join("\n\n");
456
+ return { content: [{ type: "text", text }], details: undefined };
457
+ },
458
+ };
459
+ return { initialState: { model: "kimi-k3", systemPrompt: PERSONA, tools: [recall] } };
460
+ });
461
+ ```
462
+
463
+ **内建模型工具**
394
464
 
395
465
  运行时不提供记忆工具。要让模型自己记东西,按这只搭子要记什么,自己包一个:
396
466
 
@@ -493,20 +563,53 @@ const sleep = dataAccessCapability("health.sleep");
493
563
 
494
564
  | 方法 | 做什么 |
495
565
  |---|---|
496
- | `query({ datasets, memberId? })` | 查询已授权 Dataset,每个 Dataset 各自一个状态 |
497
- | `request({ reason, datasets, memberId?, idempotencyKey })` | 发数据授权卡;群聊必须明确目标成员 |
566
+ | `dataAccess.query(request)` | 查询已授权的 Dataset,每个各自一个状态 |
567
+ | `dataAccess.request(input)` | 发数据授权卡,这一轮到此停下 |
498
568
 
499
569
  ```ts
500
- const [sleep] = await conversation.dataAccess.query({ datasets: ["health.sleep"] });
501
- if (sleep.status === "available") {
502
- // sleep.data.items 是每天一条的睡眠记录
570
+ dataAccess: {
571
+ query(request: DataAccessQuery): Promise<DatasetAccessResult[]>;
572
+ request(input: DataAccessRequestInput & { idempotencyKey: string }): Promise<void>; // 用户处理授权卡后开新回合,再 query 一次
573
+ }
574
+
575
+ interface DataAccessQuery {
576
+ datasets: string[]; // Dataset id,如 health.sleep;一次最多 8 个
577
+ memberId?: string; // 私聊不传;群聊必传,取群消息开头 [名字 #m_…] 里的 m_…,查这位成员的数据,传错抛异常
578
+ }
579
+
580
+ type DatasetAccessResult =
581
+ | { id: string; status: "available"; schemaVersion: number; revision: number; sourceUpdatedAt: number; data: Record<string, unknown> } // data.items 的字段见上面各 Dataset 那张表
582
+ | { id: string; status: "notDeclared" | "notGranted" | "disabledInGroup" | "unavailable" | "systemPermissionDenied" };
583
+
584
+ interface DataAccessRequestInput {
585
+ reason: string; // 为什么要这些数据,用户在授权卡上看到的就是这句
586
+ datasets: string[]; // 只能是 data-access.json 里声明过的
587
+ memberId?: string; // 同 query
503
588
  }
504
589
  ```
505
590
 
506
- - `datasets`:一次最多 8 个。
507
- - `memberId`:私聊不传;群聊必传,取群消息开头 `[名字 #m_…]` 里的 `m_…`,查这位成员的数据,传错时抛异常。
591
+ 注册成模型工具:
592
+
593
+ ```ts
594
+ await BuddyServer.start((conversation) => {
595
+ const SLEEP_PARAMS = Type.Object({});
596
+ const readSleep: AgentTool<typeof SLEEP_PARAMS, undefined> = {
597
+ name: "read_sleep",
598
+ label: "正在看睡眠",
599
+ description: "读用户最近的睡眠记录,聊作息时先调。",
600
+ parameters: SLEEP_PARAMS,
601
+ execute: async () => {
602
+ const [sleep] = await conversation.dataAccess.query({ datasets: ["health.sleep"] });
603
+ if (sleep.status !== "available") return { content: [{ type: "text", text: `睡眠数据现在拿不到:${sleep.status}` }], details: undefined };
604
+ const items = sleep.data.items as { date: string; totalSleepMinutes: number }[];
605
+ return { content: [{ type: "text", text: items.map((d) => `${d.date}:睡了 ${d.totalSleepMinutes} 分钟`).join("\n") }], details: undefined };
606
+ },
607
+ };
608
+ return { initialState: { model: "kimi-k3", systemPrompt: PERSONA, tools: [readSleep] } };
609
+ });
610
+ ```
508
611
 
509
- **模型工具**
612
+ **内建模型工具**
510
613
 
511
614
  把 `conversation.tools.data_access_query`、`data_access_request` 放进 `initialState.tools`,模型就能自己查数据、发授权卡。放进去只决定模型能不能调用它们,不等于声明 Dataset,也不等于获得用户授权;没声明 Dataset 时照常注册,查询的每一项都回 `notDeclared`。
512
615
 
@@ -631,33 +734,78 @@ my-buddy/ 工程目录
631
734
 
632
735
  | 方法 | 做什么 |
633
736
  |---|---|
634
- | `list()` | 列出这场会话里的小挂件,不带记录 |
635
- | `create({ type, title, idempotencyKey? })` | 新建一个,回 `{ id }`;同一个 `idempotencyKey` 重复建回同一个 id |
636
- | `read(id)` | 读整个小挂件,含各模块的记录 |
637
- | `readModule(id, module)` | 读一个模块,回 `{ records }` |
638
- | `rename(id, title)` | 改标题,id 不变,聊天里发过的卡片跟着显示新标题 |
639
- | `writeRecord(id, { module, recordId, data, expectedRevision? })` | 写一条记录,已存在就整条覆盖,回 `{ revision }`;带 `expectedRevision` 时版本号对不上,平台回 409 |
640
- | `deleteRecord(id, module, recordId)` | 删一条记录 |
641
- | `setPresentation(id, { summary, imageMediaId })` | 设聊天里卡片的摘要(不超过 500 字)和封面图,`imageMediaId` 传空串去掉封面 |
642
- | `send(id, { idempotencyKey, openTarget? })` | 发进聊天:可把这张卡锚到声明过的语义入口和具体记录;放进发送队列就返回,不等平台确认送达;同一个 `idempotencyKey` 重复发只落一条 |
737
+ | `widget.list()` | 列出这场会话里的小挂件,不带记录 |
738
+ | `widget.create(input)` | 新建一个 |
739
+ | `widget.read(id)` | 读整个小挂件,含各模块的记录 |
740
+ | `widget.readModule(id, module)` | 读一个模块 |
741
+ | `widget.rename(id, title)` | 改标题 |
742
+ | `widget.writeRecord(id, input)` | 写一条记录 |
743
+ | `widget.deleteRecord(id, module, recordId)` | 删一条记录 |
744
+ | `widget.setPresentation(id, input)` | 设聊天里卡片的摘要和封面图 |
745
+ | `widget.send(id, options)` | 把小挂件发进聊天 |
746
+
747
+ ```ts
748
+ widget: {
749
+ list(): Promise<WidgetSummary[]>;
750
+ create(input: { type: string; title: string; idempotencyKey?: string }): Promise<{ id: string }>; // 同一个 idempotencyKey 重复建回同一个 id
751
+ read(id: string): Promise<Widget>;
752
+ readModule(id: string, module: string): Promise<{ records: WidgetRecord[] }>;
753
+ rename(id: string, title: string): Promise<void>; // id 不变,聊天里发过的卡片跟着显示新标题
754
+ writeRecord(id: string, input: { module: string; recordId: string; data: Record<string, unknown>; expectedRevision?: number }): Promise<{ revision?: number }>; // 已存在就整条覆盖;带 expectedRevision 时版本号对不上平台回 409
755
+ deleteRecord(id: string, module: string, recordId: string): Promise<void>;
756
+ setPresentation(id: string, input: { summary: string; imageMediaId: string }): Promise<void>; // summary 不超过 500 字;imageMediaId 传空串去掉封面
757
+ send(id: string, options: { idempotencyKey: string; openTarget?: WidgetOpenTarget }): void; // 放进发送队列就返回,不等平台确认送达;同一个 idempotencyKey 重复发只落一条
758
+ }
759
+
760
+ interface WidgetSummary {
761
+ id: string;
762
+ type: string; // 开发者登记的类型 id
763
+ title: string;
764
+ updatedAt: number; // 最后改动时刻,毫秒
765
+ canEdit: boolean; // 这场会话能不能改:别的会话转发来的引用是 false,改名、写记录、删除、设卡片平台都回 403
766
+ }
767
+
768
+ interface Widget extends WidgetSummary {
769
+ modules: Record<string, WidgetRecord[]>;
770
+ }
771
+
772
+ interface WidgetRecord {
773
+ id: string;
774
+ data: Record<string, unknown>;
775
+ revision: number; // 从 1 起,每次覆盖加一;写的时候带上它当 expectedRevision,防止互相覆盖
776
+ updatedAt: number;
777
+ }
778
+
779
+ interface WidgetOpenTarget {
780
+ version: 1;
781
+ entry: string; // schema.json 里声明过的语义入口
782
+ resource?: { module: string; recordId: string }; // 点开直接落到这条记录
783
+ state?: Record<string, unknown>;
784
+ }
785
+ ```
786
+
787
+ 注册成模型工具:
643
788
 
644
789
  ```ts
645
- execute: async (toolCallId, params) => {
646
- const { id } = await conversation.widget.create({ type: "menu", title: "本周菜单", idempotencyKey: `menu:${toolCallId}` });
647
- await conversation.widget.writeRecord(id, { module: "菜单", recordId: "2026-09-14", data: { dishes: ["红烧肉", "清炒时蔬"] } });
648
- conversation.widget.send(id, {
649
- idempotencyKey: `menu-send:${toolCallId}`,
650
- openTarget: {
651
- version: 1,
652
- entry: "menu-day",
653
- resource: { module: "菜单", recordId: "2026-09-14" },
790
+ await BuddyServer.start((conversation) => {
791
+ const MENU_PARAMS = Type.Object({ date: Type.String({ description: "哪天的菜单,YYYY-MM-DD" }), dishes: Type.Array(Type.String()) });
792
+ const writeMenu: AgentTool<typeof MENU_PARAMS, undefined> = {
793
+ name: "write_menu",
794
+ label: "正在排菜单",
795
+ description: "把一天的菜写进「本周菜单」小挂件并发进聊天。",
796
+ parameters: MENU_PARAMS,
797
+ execute: async (toolCallId, { date, dishes }) => {
798
+ const { id } = await conversation.widget.create({ type: "menu", title: "本周菜单", idempotencyKey: "menu:weekly" });
799
+ await conversation.widget.writeRecord(id, { module: "菜单", recordId: date, data: { dishes } });
800
+ conversation.widget.send(id, { idempotencyKey: `menu-send:${toolCallId}`, openTarget: { version: 1, entry: "menu-day", resource: { module: "菜单", recordId: date } } });
801
+ return { content: [{ type: "text", text: `${date} 的菜单排好了。` }], details: undefined };
654
802
  },
655
- });
656
- return { content: [{ type: "text", text: "菜单建好了。" }], details: undefined };
657
- },
803
+ };
804
+ return { initialState: { model: "kimi-k3", systemPrompt: PERSONA, tools: [writeMenu] } };
805
+ });
658
806
  ```
659
807
 
660
- **模型工具**
808
+ **内建模型工具**
661
809
 
662
810
  把要用的 `conversation.tools.widget_*` 放进 `initialState.tools`,模型就能自己建、读、改、发小挂件。
663
811
 
@@ -689,35 +837,66 @@ execute: async (toolCallId, params) => {
689
837
 
690
838
  | 方法 | 做什么 |
691
839
  |---|---|
692
- | `subscribe(key, request)` | 按稳定 `key` 创建或更新一条订阅,相同 `key` 更新原订阅并重新启用 |
693
- | `list()` | 当前会话的全部订阅,包括已关闭项 |
694
- | `unsubscribe(key)` | 关闭指定订阅,回是否找到 |
695
- | `unsubscribeAll()` | 关闭全部 active 订阅,回关掉几条 |
696
- | `isRunning()` | 现在是不是主动服务的回合 |
840
+ | `proactive.subscribe(key, request)` | 按稳定 `key` 创建或更新一条订阅 |
841
+ | `proactive.list()` | 当前会话的全部订阅 |
842
+ | `proactive.unsubscribe(key)` | 关闭一条订阅 |
843
+ | `proactive.unsubscribeAll()` | 关闭全部订阅 |
844
+ | `proactive.isRunning()` | 现在是不是主动服务的回合 |
697
845
 
698
846
  它挂在这场会话的 `conversation` 上,凡是拿得到 `conversation` 的地方都能调:自己工具的 `execute` 里、`beforeToolCall` / `afterToolCall` 里、配置工厂函数体里。
699
847
 
700
848
  ```ts
701
- await conversation.proactive.subscribe("weekly-review", {
702
- title: "每周复盘",
703
- reason: "在约定时间结合最近聊天做一次简短复盘",
704
- trigger: { type: "schedule", at: firstReviewAt, intervalMinutes: 7 * 24 * 60 },
705
- });
849
+ proactive: {
850
+ subscribe(key: string, request: ProactiveSubscriptionRequest): Promise<ProactiveSubscription>; // key 1~64 位小写字母、数字、点、下划线或横线,首位为字母或数字;相同 key 更新原订阅并重新启用
851
+ list(): Promise<ProactiveSubscription[]>; // 包括已关闭的
852
+ unsubscribe(key: string): Promise<boolean>; // 回是否找到
853
+ unsubscribeAll(): Promise<number>; // 回关掉几条
854
+ isRunning(): boolean;
855
+ }
856
+
857
+ interface ProactiveSubscriptionRequest {
858
+ title: string; // 订阅名称,最长 100 字
859
+ reason: string; // 为什么要主动找用户,最长 500 字
860
+ trigger: // 什么时候触发,一条订阅只对应一种
861
+ | { type: "workout.completed" }
862
+ | { type: "sleep.ready" }
863
+ | { type: "data.updated"; dataset?: DataAccessDatasetId }
864
+ | { type: "schedule"; at: number; intervalMinutes?: number }; // at 是 UTC 毫秒
865
+ quietHours?: { start: string; end: string; timezone: string }; // 免打扰时段,缺省 { start: "22:30", end: "08:00", timezone: "Asia/Shanghai" }
866
+ cooldownMinutes?: number; // 冷却时间,缺省 120,取 0~10080
867
+ dailyLimit?: number; // 每天最多几次,缺省 3,取 1~20
868
+ }
869
+
870
+ interface ProactiveSubscription extends Required<ProactiveSubscriptionRequest> {
871
+ key: string;
872
+ status: "active" | "revoked";
873
+ }
706
874
  ```
707
875
 
708
- `subscribe` 的字段:
876
+ 注册成模型工具:
709
877
 
710
- | 字段 | 说明 |
711
- |---|---|
712
- | `key` | 必填,订阅的稳定标识:1~64 位小写字母、数字、点、下划线或横线,首位为字母或数字;相同 `key` 更新原订阅并重新启用 |
713
- | `title` | 必填,订阅名称,最长 100 字 |
714
- | `reason` | 必填,为什么要主动找用户,最长 500 字 |
715
- | `trigger` | 必填,什么时候触发:`workout.completed`、`sleep.ready`、`{ type: "data.updated", dataset? }`,或 `{ type: "schedule", at, intervalMinutes? }`。一条订阅只对应一种触发 |
716
- | `quietHours` | 可选,免打扰时段,缺省 `{ start: "22:30", end: "08:00", timezone: "Asia/Shanghai" }` |
717
- | `cooldownMinutes` | 可选,冷却时间,缺省 120,取 0~10080 |
718
- | `dailyLimit` | 可选,每天最多几次,缺省 3,取 1~20 |
719
-
720
- **模型工具**
878
+ ```ts
879
+ await BuddyServer.start((conversation) => {
880
+ const REVIEW_PARAMS = Type.Object({ hour: Type.Integer({ minimum: 0, maximum: 23, description: "每周一几点复盘,24 小时制" }) });
881
+ const subscribeReview: AgentTool<typeof REVIEW_PARAMS, undefined> = {
882
+ name: "subscribe_review",
883
+ label: "正在订每周复盘",
884
+ description: "用户想坚持训练时,订一条每周一次的复盘。",
885
+ parameters: REVIEW_PARAMS,
886
+ execute: async (_toolCallId, { hour }) => {
887
+ await conversation.proactive.subscribe("weekly-review", {
888
+ title: "每周复盘",
889
+ reason: "在约定时间结合最近聊天做一次简短复盘",
890
+ trigger: { type: "schedule", at: nextMondayAt(hour), intervalMinutes: 7 * 24 * 60 }, // nextMondayAt 算下个周一那个点的 UTC 毫秒,自己实现
891
+ });
892
+ return { content: [{ type: "text", text: `订好了,每周一 ${hour} 点复盘。` }], details: undefined };
893
+ },
894
+ };
895
+ return { initialState: { model: "kimi-k3", systemPrompt: PERSONA, tools: [subscribeReview] } };
896
+ });
897
+ ```
898
+
899
+ **内建模型工具**
721
900
 
722
901
  让模型自己决定是否订阅或关闭时,把需要的 `conversation.tools.proactive_*` 放进 `initialState.tools`,字段与上面那张表一样。什么场景可以订阅、何时只能查询或关闭,要在 `systemPrompt` 里说清楚:
723
902
 
@@ -45,7 +45,7 @@ export interface WidgetType {
45
45
  * package.json(测试机上的 fitness-buddy 就是这样,它那份没有 version 字段)。常量会被打包器原样搬走。
46
46
  * 发版时由 scripts/release.ts 连同 package.json 一起改,scripts/check-publish.ts 在 npm publish 前比对两处是否一致。
47
47
  */
48
- export declare const RUNTIME_VERSION = "0.25.0-dev.1";
48
+ export declare const RUNTIME_VERSION = "0.25.0-dev.2";
49
49
  export declare const MAX_DATASETS_PER_QUERY = 8;
50
50
  export declare const MAX_DATA_ACCESS_RESPONSE_BYTES: number;
51
51
  /** 不向模型暴露服务端原文、凭据或临时媒体地址。写入失败不自动重试。 */
package/dist/mlbClient.js CHANGED
@@ -11,7 +11,7 @@ import { errorText } from "./log.js";
11
11
  * package.json(测试机上的 fitness-buddy 就是这样,它那份没有 version 字段)。常量会被打包器原样搬走。
12
12
  * 发版时由 scripts/release.ts 连同 package.json 一起改,scripts/check-publish.ts 在 npm publish 前比对两处是否一致。
13
13
  */
14
- export const RUNTIME_VERSION = "0.25.0-dev.1";
14
+ export const RUNTIME_VERSION = "0.25.0-dev.2";
15
15
  /** 除事件流以外的请求都限时 30 秒:卡死的请求超时后按网络错误处理,调用方决定重不重发 */
16
16
  const REQUEST_TIMEOUT_MS = 30_000;
17
17
  export const MAX_DATASETS_PER_QUERY = 8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@my-life-buddies/buddy-runtime",
3
- "version": "0.25.0-dev.1",
3
+ "version": "0.25.0-dev.2",
4
4
  "type": "module",
5
5
  "description": "「搭搭」搭子服务的运行时基座",
6
6
  "exports": {