@istarry/dsh-assistant-profile 0.5.0 → 0.6.1

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/lib/index.js CHANGED
@@ -1,8 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
- import { defineTool } from "@deepseek-ai/dsh-tools";
4
- import "@deepseek-ai/dsh-llm";
5
- import "@deepseek-ai/dsh-session";
2
+ import { Service } from "@deepseek-ai/cordis";
6
3
  //#region ../tooling/node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml/dist/js-yaml.mjs
7
4
  /*! js-yaml 4.2.0 https://github.com/nodeca/js-yaml @license MIT */
8
5
  var __create = Object.create;
@@ -2563,7 +2560,8 @@ const Config = z.object({
2563
2560
  customsJson: z.string().default("[]").volatile(),
2564
2561
  memoryEnabled: z.boolean().default(true).description("Inject the assistant memory into the system prompt").volatile(),
2565
2562
  autoMemory: z.boolean().default(false).description("Let the model write memories on its own judgment (requires memoryEnabled)").volatile(),
2566
- memoryText: z.string().default("").description("The assistant memory document").volatile()
2563
+ memoryText: z.string().default("").description("The assistant memory document").volatile(),
2564
+ rememberSignal: z.string().default("").description("Browser-to-host mailbox for the remember button").volatile()
2567
2565
  });
2568
2566
  /** 跨插件重载共享的当前配置引用:旧闭包也能读到新值。 */
2569
2567
  const LIVE = Symbol.for("dsh.assistant-profile.config");
@@ -2671,26 +2669,77 @@ async function installPersona(ctx, agent) {
2671
2669
  });
2672
2670
  }
2673
2671
  const MEMORY_ADD_DESCRIPTION = "Add one fact to the assistant's long-term memory when the conversation reveals something worth remembering across future sessions: stable preferences, work habits, durable decisions, or facts the human stated as lasting. Remember prose conclusions, not the raw exchange; merge related facts into one entry. Do not store credentials, one-off task details, or anything the human said is transient.";
2674
- /** `memory_add` 的输出结构。 */
2672
+ /**
2673
+ * `memory_add` 的输出结构。
2674
+ *
2675
+ * 这是**输出值**的 JSON Schema,不是工具参数的 `defineTool` 方言:`required` 必须是
2676
+ * 对象层上的字符串数组,写成属性上的 `required: true` 会被宿主的 JSON Schema 校验器
2677
+ * 拒绝(`unsupported JSON schema: ... required is not supported on type "boolean"`),
2678
+ * 而注册失败只会让工具消失——不报给用户。
2679
+ */
2675
2680
  const MEMORY_ADD_VALUE_SCHEMA = {
2676
2681
  type: "object",
2677
2682
  additionalProperties: false,
2678
2683
  properties: {
2679
- saved: {
2680
- type: "boolean",
2681
- required: true
2682
- },
2684
+ saved: { type: "boolean" },
2683
2685
  outcome: { type: "string" },
2684
2686
  content: { type: "string" }
2685
- }
2687
+ },
2688
+ required: ["saved"]
2686
2689
  };
2690
+ /** 把选中的对话文本提炼成记忆条目的指令(与 agent-suite 一致的规则)。 */
2691
+ const REMEMBER_INSTRUCTION = "你是一个长期记忆提炼引擎。用户从对话中圈选了下面这段内容,明确希望把它记入助手的长期记忆。请把这些内容提炼成简短、稳定、可跨会话复用的记忆条目。\n规则:\n- 每行输出一条记忆内容本身(不带时间戳、不带 \"- \" 前缀、不带编号)。\n- 用户既然圈选了它,请默认提取出其中明确、稳定的信息:称呼、偏好、习惯、约定、身份事实、项目背景。\n- 把相关事实合并成一条;不要逐字复述,只提炼结论,但保留关键名称、数字、关系。\n- 只有当整段内容完全是临时任务细节或含凭据、密钥时,才不输出条目(此时只输出一个真正的空行)。\n- 用与对话片段一致的语言书写(对话是中文就用中文,是英文就用英文)。\n- 严禁输出\"空行\"\"(空行)\"\"无\"\"没有\"\"none\"之类的占位文字。";
2692
+ /** 设置命名空间 = 本插件在 profile 里的条目 id。 */
2693
+ const SETTINGS_NS = "assistant-profile";
2694
+ /** 信箱里一次请求最多带多少字符(别把超长选区写进 profile 的 patch)。 */
2695
+ const REMEMBER_TEXT_LIMIT = 2e4;
2696
+ /** 信箱轮询间隔:0.7s 读一次字符串字段,人感觉不到延迟。 */
2697
+ const MAILBOX_POLL_MS = 700;
2698
+ /**
2699
+ * 一条用户消息。按线上形状手搭而不是引 `@deepseek-ai/dsh-llm`:宿主那边的包在插件
2700
+ * 目录里解析不到(只有 cordis 与 schemastery 两端共享),而消息只是 role/content/id
2701
+ * 三个字段的纯数据。
2702
+ * @param text - 消息正文。
2703
+ * @returns 可直接交给 llm.stream 的用户消息。
2704
+ */
2705
+ function userMessage(text) {
2706
+ return {
2707
+ id: crypto.randomUUID(),
2708
+ role: "user",
2709
+ content: [{
2710
+ type: "text",
2711
+ text
2712
+ }],
2713
+ source: { kind: "user" }
2714
+ };
2715
+ }
2716
+ /**
2717
+ * 解析信箱字段;坏数据当空(宁可什么都不做,也不误跑一次)。
2718
+ * @param raw - 字段原文。
2719
+ * @returns 解析出的信号。
2720
+ */
2721
+ function parseSignal(raw) {
2722
+ if (raw.trim() === "") return void 0;
2723
+ try {
2724
+ const parsed = JSON.parse(raw);
2725
+ if (typeof parsed !== "object" || parsed === null) return void 0;
2726
+ return parsed;
2727
+ } catch {
2728
+ return;
2729
+ }
2730
+ }
2687
2731
  /**
2688
2732
  * 助理档案服务:人格注入 + 单份长期记忆。
2689
2733
  *
2690
- * 记忆的存储、注入、两个开关、`memory_add` 工具和按钮提炼都在这里;
2691
- * 客户端只负责设置页与弹窗。
2734
+ * 记忆的存储、注入、两个开关、`memory_add` 工具与按钮提炼都在这里;客户端只负责
2735
+ * 设置页、聊天里的「记住」按钮和选择弹窗。
2736
+ *
2737
+ * 按钮提炼没有自定义 RPC:浏览器把「要提炼哪段文本」写进 `rememberSignal`
2738
+ * (`phase: request`),宿主在 `loader/volatile-update` 里接手,用该会话自己的模型
2739
+ * 跑一次提炼,把结果写回同一个字段(`phase: done`)。写入统一走设置服务,所以记忆
2740
+ * 与它一样落进 profile 的 patch。
2692
2741
  */
2693
- var AssistantProfile = class extends TypertRemoteService {
2742
+ var AssistantProfile = class extends Service {
2694
2743
  config;
2695
2744
  static inject = [
2696
2745
  "systemPrompt",
@@ -2700,6 +2749,10 @@ var AssistantProfile = class extends TypertRemoteService {
2700
2749
  static Config = Config;
2701
2750
  /** `memory_add` 工具的注册 disposer,只在开关允许时存在。 */
2702
2751
  toolDisposer;
2752
+ /** 正在处理一次按钮提炼:同一时间只跑一个。 */
2753
+ remembering = false;
2754
+ /** 上一次处理过的请求 id,防止同一请求被重复执行。 */
2755
+ lastRequestId;
2703
2756
  constructor(ctx, config) {
2704
2757
  super(ctx, "assistantProfile");
2705
2758
  this.config = config;
@@ -2728,7 +2781,18 @@ var AssistantProfile = class extends TypertRemoteService {
2728
2781
  this.syncTool();
2729
2782
  ctx.on("loader/volatile-update", () => {
2730
2783
  this.syncTool();
2784
+ this.schedulePump();
2731
2785
  });
2786
+ ctx.effect(() => {
2787
+ const poller = setInterval(() => {
2788
+ this.syncTool();
2789
+ this.pumpRemember();
2790
+ }, MAILBOX_POLL_MS);
2791
+ return () => {
2792
+ clearInterval(poller);
2793
+ };
2794
+ }, "assistant-profile: mailbox poll");
2795
+ this.schedulePump();
2732
2796
  }
2733
2797
  /** 按当前开关注册或撤下 `memory_add` 工具。 */
2734
2798
  syncTool() {
@@ -2742,43 +2806,53 @@ var AssistantProfile = class extends TypertRemoteService {
2742
2806
  /** 注册模型侧的记忆工具,返回撤下函数。 */
2743
2807
  registerTool() {
2744
2808
  const ctx = this.ctx;
2745
- const disposer = ctx.tools.register(defineTool({
2746
- name: "memory_add",
2747
- description: MEMORY_ADD_DESCRIPTION,
2748
- parameters: { text: {
2749
- type: "string",
2750
- required: true,
2751
- description: "The stable fact to remember, in its final prose form."
2752
- } },
2753
- output: {
2754
- schema: MEMORY_ADD_VALUE_SCHEMA,
2755
- render: (_args, value) => {
2756
- const v = value;
2757
- if (v.saved) return [{
2758
- type: "text",
2759
- text: `记住:${v.content ?? ""}`
2760
- }];
2761
- return [{
2762
- type: "text",
2763
- text: `未记住:${v.outcome ?? "未启用"}`
2764
- }];
2809
+ let disposer;
2810
+ try {
2811
+ disposer = ctx.tools.register({
2812
+ name: "memory_add",
2813
+ description: MEMORY_ADD_DESCRIPTION,
2814
+ parameters: {
2815
+ type: "object",
2816
+ additionalProperties: false,
2817
+ properties: { text: {
2818
+ type: "string",
2819
+ description: "The stable fact to remember, in its final prose form."
2820
+ } },
2821
+ required: ["text"]
2822
+ },
2823
+ output: {
2824
+ schema: MEMORY_ADD_VALUE_SCHEMA,
2825
+ render: (_args, value) => {
2826
+ const v = value;
2827
+ if (v.saved) return [{
2828
+ type: "text",
2829
+ text: `记住:${v.content ?? ""}`
2830
+ }];
2831
+ return [{
2832
+ type: "text",
2833
+ text: `未记住:${v.outcome ?? "未启用"}`
2834
+ }];
2835
+ }
2836
+ },
2837
+ execute: async (args) => {
2838
+ const trimmed = (typeof args.text === "string" ? args.text : "").trim();
2839
+ if (trimmed === "") return {
2840
+ saved: false,
2841
+ outcome: "empty fact"
2842
+ };
2843
+ const result = appendToText(this.config.memoryText.get(), trimmed);
2844
+ await this.writeMemory(result.text);
2845
+ return {
2846
+ saved: true,
2847
+ content: result.content,
2848
+ outcome: result.appended ? "appended" : result.refreshed ? "refreshed" : "unchanged"
2849
+ };
2765
2850
  }
2766
- },
2767
- execute: async (args, _exec) => {
2768
- const trimmed = args.text.trim();
2769
- if (trimmed === "") return {
2770
- saved: false,
2771
- outcome: "empty fact"
2772
- };
2773
- const result = appendToText(this.config.memoryText.get(), trimmed);
2774
- this.config.memoryText.set(result.text);
2775
- return {
2776
- saved: true,
2777
- content: result.content,
2778
- outcome: result.appended ? "appended" : result.refreshed ? "refreshed" : "unchanged"
2779
- };
2780
- }
2781
- }));
2851
+ });
2852
+ } catch (error) {
2853
+ ctx.logger.warn(`assistant-profile: memory_add registration failed: ${String(error)}`);
2854
+ return () => {};
2855
+ }
2782
2856
  const teardown = () => {
2783
2857
  disposer();
2784
2858
  this.toolDisposer = void 0;
@@ -2788,7 +2862,169 @@ var AssistantProfile = class extends TypertRemoteService {
2788
2862
  disposer();
2789
2863
  };
2790
2864
  }
2865
+ /**
2866
+ * 把一段记忆文本落进配置。只能经设置服务写:`Volatile` 只暴露 `get()`,
2867
+ * 宿主没有权限直接改自己的配置引用;写入经它之后,加载器会把新值就地提交进
2868
+ * 运行时引用(下一次组装提示词就读到),并落进 profile 的 patch。
2869
+ * @param next - 新的整段文本。
2870
+ */
2871
+ async writeMemory(next) {
2872
+ await this.persist({ memoryText: next });
2873
+ }
2874
+ /**
2875
+ * 经设置服务写回本插件的 volatile 字段。
2876
+ * @param patch - 要写入的字段。
2877
+ * @returns 是否写成功(失败只记日志,由调用方决定要不要重试)。
2878
+ */
2879
+ async persist(patch) {
2880
+ const settings = this.ctx.get("settings");
2881
+ if (settings === void 0) return false;
2882
+ try {
2883
+ await settings.update(SETTINGS_NS, patch);
2884
+ return true;
2885
+ } catch (error) {
2886
+ this.ctx.logger.warn(`assistant-profile: persist failed: ${String(error)}`);
2887
+ return false;
2888
+ }
2889
+ }
2890
+ /**
2891
+ * 把一次信箱处理排到当前事务之外。
2892
+ *
2893
+ * 设置写入本身跑在 HMR 事务里(`hmr.runExclusive`),而写回结果又要经设置服务再开
2894
+ * 一个事务——直接调用会撞上「HMR transactions cannot be nested」。所以触发点统一
2895
+ * 用 `setTimeout(0)` 推到事务之外,轮询那条路天然在事务外。
2896
+ */
2897
+ schedulePump() {
2898
+ setTimeout(() => {
2899
+ this.pumpRemember();
2900
+ }, 0);
2901
+ }
2902
+ /**
2903
+ * 检查信箱里有没有新请求,有就跑一次提炼并写回结果。
2904
+ *
2905
+ * 这里绝不向外抛:调用方是 `void`,一次未捕获的拒绝会被加载器当成插件加载失败。
2906
+ */
2907
+ async pumpRemember() {
2908
+ if (this.remembering) return;
2909
+ const signal = parseSignal(this.config.rememberSignal.get());
2910
+ if (signal?.phase !== "request" || signal.id === void 0) return;
2911
+ if (signal.id === this.lastRequestId) return;
2912
+ this.remembering = true;
2913
+ this.lastRequestId = signal.id;
2914
+ let result;
2915
+ try {
2916
+ result = await this.remember(signal.sessionId ?? "", signal.text ?? "");
2917
+ } catch (error) {
2918
+ result = {
2919
+ saved: false,
2920
+ outcome: `failed: ${errorChain(error)}`
2921
+ };
2922
+ }
2923
+ try {
2924
+ if (!await this.publishSignal({
2925
+ id: signal.id,
2926
+ phase: "done",
2927
+ saved: result.saved,
2928
+ entries: result.entries,
2929
+ outcome: result.outcome
2930
+ })) this.lastRequestId = void 0;
2931
+ } finally {
2932
+ this.remembering = false;
2933
+ }
2934
+ }
2935
+ /**
2936
+ * 把结果写回信箱(同一个字段),浏览器据此结束等待。
2937
+ * @param signal - 带结果的信箱内容。
2938
+ */
2939
+ async publishSignal(signal) {
2940
+ return await this.persist({ rememberSignal: JSON.stringify(signal) });
2941
+ }
2942
+ /**
2943
+ * 用该会话自己的模型把选中的对话文本提炼成记忆条目并追加。
2944
+ *
2945
+ * 会话必须还在线(agent 提供模型配置);提炼不写入对话历史,只发一次独立请求。
2946
+ * @param sessionId - 执行提炼的会话。
2947
+ * @param text - 选中的对话文本。
2948
+ * @returns 是否记住了、记住了什么、或失败原因。
2949
+ */
2950
+ async remember(sessionId, text) {
2951
+ const trimmed = text.trim().slice(0, REMEMBER_TEXT_LIMIT);
2952
+ if (trimmed === "") return {
2953
+ saved: false,
2954
+ outcome: "empty selection"
2955
+ };
2956
+ const agent = this.ctx.get("agents")?.get(sessionId);
2957
+ if (agent === void 0) return {
2958
+ saved: false,
2959
+ outcome: "session not open"
2960
+ };
2961
+ const callConfig = agent.session.requestHeader?.()?.config;
2962
+ if (callConfig === void 0) return {
2963
+ saved: false,
2964
+ outcome: "no provider/model available"
2965
+ };
2966
+ const llm = this.ctx.llm;
2967
+ let output = "";
2968
+ let failure;
2969
+ try {
2970
+ for await (const chunk of llm.stream({
2971
+ ...callConfig,
2972
+ messages: [userMessage(trimmed), userMessage(REMEMBER_INSTRUCTION)],
2973
+ sessionId: agent.session.id
2974
+ })) if (chunk.type === "text-delta" && typeof chunk.text === "string") output += chunk.text;
2975
+ else if (chunk.type === "finish") {
2976
+ const kind = chunk.reason?.kind;
2977
+ if (kind === "error" || kind === "aborted") failure = `analysis ${kind}: ${errorChain(chunk.reason?.failure)}`;
2978
+ else if (kind === "max-tokens") failure = "analysis truncated at the token cap";
2979
+ }
2980
+ } catch (error) {
2981
+ return {
2982
+ saved: false,
2983
+ outcome: `analysis failed: ${errorChain(error)}`
2984
+ };
2985
+ }
2986
+ if (failure !== void 0) return {
2987
+ saved: false,
2988
+ outcome: failure
2989
+ };
2990
+ const entries = splitLines(output);
2991
+ if (entries.length === 0) return {
2992
+ saved: false,
2993
+ outcome: "nothing worth remembering"
2994
+ };
2995
+ let next = this.config.memoryText.get();
2996
+ for (const entry of entries) next = appendToText(next, entry).text;
2997
+ await this.writeMemory(next);
2998
+ return {
2999
+ saved: true,
3000
+ entries,
3001
+ outcome: `remembered ${String(entries.length)}`
3002
+ };
3003
+ }
2791
3004
  };
3005
+ /** 把错误及其 cause 链压成一行。 */
3006
+ function errorChain(error) {
3007
+ const parts = [];
3008
+ let current = error;
3009
+ while (typeof current === "object" && current !== null && parts.length < 4) {
3010
+ const message = current.message;
3011
+ if (typeof message !== "string" || message === "") break;
3012
+ parts.push(message);
3013
+ current = current.cause;
3014
+ }
3015
+ return parts.join(" | ");
3016
+ }
3017
+ /** 把模型输出拆成一行一条记忆(去列表前缀、丢空行、丢占位词)。 */
3018
+ function splitLines(text) {
3019
+ const entries = [];
3020
+ for (const raw of text.split(/\r?\n/)) {
3021
+ const line = stripInvisible(raw.trim().replace(/^[-*]\s+/, "").trim());
3022
+ if (line === "") continue;
3023
+ if (isVacuousEntry(line)) continue;
3024
+ entries.push(line);
3025
+ }
3026
+ return entries;
3027
+ }
2792
3028
  /** 去掉模型可能在正文周围吐出的不可见字符。 */
2793
3029
  function stripInvisible(value) {
2794
3030
  return value.replace(/[\u0000-\u001F\u007F-\u009F\u00A0\u00AD\u1680\u2000-\u200B\u2028-\u202F\u205F-\u206F\u3000\uFEFF\uFFFD]/g, "").trim();
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@istarry/dsh-assistant-profile",
3
- "description": "助理档案:给 DSH 一份全局人格(名称 / 风格 / 语言 / 人格提示词),每个新会话的系统提示最前面都带着它;侧栏「新会话」上方显示助理名",
4
- "version": "0.5.0",
3
+ "description": "助理档案:给 DSH 一份全局人格(名称 / 语言 / 人格提示词)与一份长期记忆,每个新会话的系统提示最前面都带着它;聊天里点一下就能把选中的对话提炼进记忆;侧栏「新会话」上方显示助理名",
4
+ "version": "0.6.1",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./cordis.patch.yml": "./cordis.patch.yml",
12
+ "./locale/*.json": "./locale/*.json",
13
+ "./package.json": "./package.json"
14
+ },
8
15
  "license": "MIT",
9
16
  "publishConfig": {
10
17
  "access": "public"
@@ -45,11 +52,6 @@
45
52
  "@deepseek-ai/dsh-agent": "^0.2.1-rc.2",
46
53
  "@deepseek-ai/dsh-agent-preset-registry": "^0.2.1-rc.2",
47
54
  "@deepseek-ai/dsh-settings": "^0.2.1-rc.2",
48
- "@deepseek-ai/dsh-system-prompt": "^0.2.1-rc.2",
49
- "@deepseek-ai/dsh-typert-protocol": "*",
50
- "@deepseek-ai/dsh-tools": "*",
51
- "@deepseek-ai/dsh-llm": "*",
52
- "@deepseek-ai/dsh-session": "*",
53
- "@deepseek-ai/dsh-home-paths": "*"
55
+ "@deepseek-ai/dsh-system-prompt": "^0.2.1-rc.2"
54
56
  }
55
57
  }