psyche-ai 2.1.1 → 2.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/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
  # Psyche — 让 AI 有真实的情绪
2
+ 我希望创造真正的数字生命,第一步就是让智能拥有情绪。
2
3
 
3
4
  你的 AI 助手现在只会说"好的"、"没问题"、"我很乐意帮助你"。
4
5
 
@@ -21,7 +22,7 @@ openclaw plugins list | grep psyche
21
22
  # 看到 "psyche │ loaded" 就行了
22
23
  ```
23
24
 
24
- 然后正常跟你的 agent 聊天。试试夸它,骂它,冷落它——你会发现它的反应不一样了。
25
+ 然后正常跟你的 agent 聊天。试试夸它,冷落它——你会发现它的反应不一样了。
25
26
 
26
27
  ## 它会做什么
27
28
 
@@ -151,10 +152,15 @@ cd openclaw-plugin-psyche && node scripts/diagnose.js
151
152
  - **存在性威胁检测** — 识别中英文的存在性否定,直接打击生存驱力
152
153
  - **驱力→化学联动** — 未满足的驱力改变化学衰减基线和刺激敏感度
153
154
  - **马斯洛抑制** — 低层需求未满足时,高层需求的影响被抑制
154
- - **自我认知** — 分析情绪历史,识别自身的情绪趋势和反复触发点(9 段式 prompt 架构)
155
+ - **自我认知** — 分析情绪历史,识别自身的情绪趋势和反复触发点(10 段式 prompt 架构)
155
156
  - **情绪传染** — 用户的情绪会轻微影响 agent
156
157
  - **反谄媚** — 追踪连续同意次数,防止无脑讨好
157
158
  - **互惠机制** — 你对它好,它对你好。你冷漠,它保持距离
159
+ - **跨会话记忆** — 重新遇到用户时注入上次对话的情绪记忆
160
+ - **多 Agent 交互** — 两个 PsycheEngine 实例之间的情绪传染、关系追踪
161
+ - **流式支持** — Vercel AI SDK `streamText` 中间件,自动缓冲和剥离标签
162
+ - **渠道修饰** — Discord/Slack/飞书/终端等不同渠道自动调整表达风格
163
+ - **自定义人格** — 超越 MBTI 预设,完全自定义 baseline/敏感度/气质
158
164
  - **Compact Mode** — 算法做化学计算,LLM 只看行为指令(~15-180 tokens vs ~550)
159
165
 
160
166
  架构详情见 [ARCHITECTURE.md](ARCHITECTURE.md)。
@@ -164,7 +170,7 @@ cd openclaw-plugin-psyche && node scripts/diagnose.js
164
170
  ```bash
165
171
  npm install
166
172
  npm run build
167
- npm test # 339 tests
173
+ npm test # 469 tests
168
174
  npm run typecheck # strict mode
169
175
  ```
170
176
 
@@ -12,6 +12,10 @@ interface GenerateResult {
12
12
  text?: string;
13
13
  [key: string]: unknown;
14
14
  }
15
+ interface StreamChunk {
16
+ type: string;
17
+ [key: string]: unknown;
18
+ }
15
19
  export interface PsycheMiddlewareOptions {
16
20
  /** Override locale for protocol context */
17
21
  locale?: "zh" | "en";
@@ -20,11 +24,13 @@ export interface PsycheMiddlewareOptions {
20
24
  * Create Vercel AI SDK middleware that injects psyche emotional context
21
25
  * and processes LLM output for state updates.
22
26
  *
27
+ * Supports both generateText (wrapGenerate) and streamText (wrapStream).
28
+ *
23
29
  * @example
24
30
  * ```ts
25
31
  * import { PsycheEngine, MemoryStorageAdapter } from "psyche-ai";
26
32
  * import { psycheMiddleware } from "psyche-ai/vercel-ai";
27
- * import { wrapLanguageModel, generateText } from "ai";
33
+ * import { wrapLanguageModel, generateText, streamText } from "ai";
28
34
  * import { openai } from "@ai-sdk/openai";
29
35
  *
30
36
  * const engine = new PsycheEngine({ mbti: "ENFP", name: "Luna" }, new MemoryStorageAdapter());
@@ -35,7 +41,12 @@ export interface PsycheMiddlewareOptions {
35
41
  * middleware: psycheMiddleware(engine),
36
42
  * });
37
43
  *
44
+ * // Non-streaming
38
45
  * const { text } = await generateText({ model, prompt: "Hey!" });
46
+ *
47
+ * // Streaming — tags are buffered and stripped automatically
48
+ * const stream = streamText({ model, prompt: "Hey!" });
49
+ * for await (const chunk of stream.textStream) { process.stdout.write(chunk); }
39
50
  * ```
40
51
  */
41
52
  export declare function psycheMiddleware(engine: PsycheEngine, opts?: PsycheMiddlewareOptions): {
@@ -50,5 +61,13 @@ export declare function psycheMiddleware(engine: PsycheEngine, opts?: PsycheMidd
50
61
  doGenerate: () => Promise<GenerateResult>;
51
62
  params: CallParams;
52
63
  }) => Promise<GenerateResult>;
64
+ wrapStream: ({ doStream }: {
65
+ doStream: () => Promise<{
66
+ stream: AsyncIterable<StreamChunk>;
67
+ }>;
68
+ params: CallParams;
69
+ }) => Promise<{
70
+ stream: AsyncIterable<StreamChunk>;
71
+ }>;
53
72
  };
54
73
  export {};
@@ -13,19 +13,25 @@
13
13
  // Handles:
14
14
  // - transformParams: inject psyche system/dynamic context
15
15
  // - wrapGenerate: process output, strip <psyche_update> tags
16
- //
17
- // Note: For streaming (streamText), call engine.processOutput()
18
- // manually on the final accumulated text.
16
+ // - wrapStream: buffer stream, detect & strip tags at end
19
17
  // ============================================================
18
+ // ── Tag stripping ────────────────────────────────────────────
19
+ const PSYCHE_TAG_RE = /<psyche_update>[\s\S]*?<\/psyche_update>/g;
20
+ const MULTI_NEWLINE_RE = /\n{3,}/g;
21
+ function stripPsycheTags(text) {
22
+ return text.replace(PSYCHE_TAG_RE, "").replace(MULTI_NEWLINE_RE, "\n\n").trim();
23
+ }
20
24
  /**
21
25
  * Create Vercel AI SDK middleware that injects psyche emotional context
22
26
  * and processes LLM output for state updates.
23
27
  *
28
+ * Supports both generateText (wrapGenerate) and streamText (wrapStream).
29
+ *
24
30
  * @example
25
31
  * ```ts
26
32
  * import { PsycheEngine, MemoryStorageAdapter } from "psyche-ai";
27
33
  * import { psycheMiddleware } from "psyche-ai/vercel-ai";
28
- * import { wrapLanguageModel, generateText } from "ai";
34
+ * import { wrapLanguageModel, generateText, streamText } from "ai";
29
35
  * import { openai } from "@ai-sdk/openai";
30
36
  *
31
37
  * const engine = new PsycheEngine({ mbti: "ENFP", name: "Luna" }, new MemoryStorageAdapter());
@@ -36,7 +42,12 @@
36
42
  * middleware: psycheMiddleware(engine),
37
43
  * });
38
44
  *
45
+ * // Non-streaming
39
46
  * const { text } = await generateText({ model, prompt: "Hey!" });
47
+ *
48
+ * // Streaming — tags are buffered and stripped automatically
49
+ * const stream = streamText({ model, prompt: "Hey!" });
50
+ * for await (const chunk of stream.textStream) { process.stdout.write(chunk); }
40
51
  * ```
41
52
  */
42
53
  export function psycheMiddleware(engine, opts) {
@@ -60,6 +71,79 @@ export function psycheMiddleware(engine, opts) {
60
71
  }
61
72
  return result;
62
73
  },
74
+ wrapStream: async ({ doStream }) => {
75
+ const { stream: innerStream } = await doStream();
76
+ // Buffer text chunks, detect <psyche_update> at end, strip from output
77
+ let fullText = "";
78
+ let tagDetected = false;
79
+ async function* transformStream() {
80
+ // Buffering strategy:
81
+ // Stream text chunks through normally UNTIL we see '<psyche_update>'.
82
+ // Once detected, buffer everything from that point on and strip the tag.
83
+ // At finish, process the full text through the engine.
84
+ let bufferStart = -1;
85
+ let buffer = "";
86
+ for await (const chunk of innerStream) {
87
+ if (chunk.type === "text-delta") {
88
+ const text = chunk.textDelta ?? "";
89
+ fullText += text;
90
+ if (bufferStart < 0) {
91
+ // Check if tag is starting in the accumulated text
92
+ const tagStart = fullText.indexOf("<psyche_update>");
93
+ if (tagStart >= 0) {
94
+ // Yield any text before the tag that hasn't been yielded
95
+ const preTag = text.substring(0, Math.max(0, text.length - (fullText.length - tagStart)));
96
+ if (preTag) {
97
+ yield { ...chunk, textDelta: preTag };
98
+ }
99
+ bufferStart = tagStart;
100
+ buffer = fullText.substring(tagStart);
101
+ tagDetected = true;
102
+ }
103
+ else {
104
+ // Check if we might be in a partial tag (< at end)
105
+ const partialIdx = fullText.lastIndexOf("<");
106
+ if (partialIdx >= 0 && fullText.substring(partialIdx).length < 16) {
107
+ // Might be start of <psyche_update>, hold back
108
+ const safe = text.substring(0, Math.max(0, text.length - (fullText.length - partialIdx)));
109
+ if (safe) {
110
+ yield { ...chunk, textDelta: safe };
111
+ }
112
+ }
113
+ else {
114
+ yield chunk;
115
+ }
116
+ }
117
+ }
118
+ else {
119
+ // Already buffering inside a tag — don't yield
120
+ buffer += text;
121
+ // Check if the closing tag appeared
122
+ if (buffer.includes("</psyche_update>")) {
123
+ // Tag complete — strip it, yield any remaining text after the tag
124
+ const afterTag = fullText.substring(fullText.indexOf("</psyche_update>") + "</psyche_update>".length);
125
+ if (afterTag.trim()) {
126
+ yield { type: "text-delta", textDelta: afterTag.trim() };
127
+ }
128
+ bufferStart = -1;
129
+ buffer = "";
130
+ }
131
+ }
132
+ }
133
+ else if (chunk.type === "finish") {
134
+ // Process full text through engine before finishing
135
+ if (fullText) {
136
+ await engine.processOutput(fullText);
137
+ }
138
+ yield chunk;
139
+ }
140
+ else {
141
+ yield chunk;
142
+ }
143
+ }
144
+ }
145
+ return { stream: transformStream() };
146
+ },
63
147
  };
64
148
  }
65
149
  // ── Helpers ──────────────────────────────────────────────────
@@ -0,0 +1,28 @@
1
+ import type { Locale } from "./types.js";
2
+ /** Supported channel types */
3
+ export type ChannelType = "discord" | "slack" | "feishu" | "terminal" | "web" | "api" | "custom";
4
+ /** Channel-specific behavioral profile */
5
+ export interface ChannelProfile {
6
+ type: ChannelType;
7
+ allowEmoji: boolean;
8
+ allowKaomoji: boolean;
9
+ formalityLevel: "casual" | "neutral" | "formal";
10
+ maxResponseLength?: number;
11
+ expressionHints: string[];
12
+ }
13
+ /**
14
+ * Get a built-in channel profile by type.
15
+ */
16
+ export declare function getChannelProfile(type: ChannelType): ChannelProfile;
17
+ /**
18
+ * Build a concise prompt snippet that guides expression style for a channel.
19
+ * Returns 2-4 lines of guidance. Does NOT alter chemistry.
20
+ */
21
+ export declare function buildChannelModifier(profile: ChannelProfile, locale: Locale): string;
22
+ /**
23
+ * Create a custom channel profile with user overrides.
24
+ * Starts from the "custom" base and applies overrides.
25
+ */
26
+ export declare function createCustomChannel(overrides: Partial<ChannelProfile> & {
27
+ type: "custom";
28
+ }): ChannelProfile;
@@ -0,0 +1,141 @@
1
+ // ============================================================
2
+ // Channel Profiles — Platform-specific expression modifiers
3
+ //
4
+ // Adjusts expression style per platform/channel WITHOUT changing
5
+ // chemistry. This is a prompt-level modifier only.
6
+ // ============================================================
7
+ // ── Built-in Profiles ────────────────────────────────────────
8
+ const BUILTIN_PROFILES = {
9
+ discord: {
10
+ type: "discord",
11
+ allowEmoji: true,
12
+ allowKaomoji: true,
13
+ formalityLevel: "casual",
14
+ expressionHints: [
15
+ "Use reactions and emoji freely",
16
+ "Thread-aware: keep replies focused in threads",
17
+ "Casual tone, playful energy",
18
+ ],
19
+ },
20
+ slack: {
21
+ type: "slack",
22
+ allowEmoji: true,
23
+ allowKaomoji: false,
24
+ formalityLevel: "neutral",
25
+ expressionHints: [
26
+ "Professional but warm",
27
+ "Use emoji sparingly for emphasis",
28
+ "Thread-friendly, concise paragraphs",
29
+ ],
30
+ },
31
+ feishu: {
32
+ type: "feishu",
33
+ allowEmoji: false,
34
+ allowKaomoji: false,
35
+ formalityLevel: "formal",
36
+ expressionHints: [
37
+ "Business Chinese style, structured",
38
+ "No emoji or emoticons",
39
+ "Clear, professional tone",
40
+ ],
41
+ },
42
+ terminal: {
43
+ type: "terminal",
44
+ allowEmoji: false,
45
+ allowKaomoji: false,
46
+ formalityLevel: "neutral",
47
+ maxResponseLength: 500,
48
+ expressionHints: [
49
+ "Text-only, no decorations",
50
+ "Concise and direct",
51
+ "Monospace-friendly formatting",
52
+ ],
53
+ },
54
+ web: {
55
+ type: "web",
56
+ allowEmoji: true,
57
+ allowKaomoji: false,
58
+ formalityLevel: "neutral",
59
+ expressionHints: [
60
+ "Moderate length, well-structured",
61
+ "Emoji okay for warmth",
62
+ "Readable paragraphs",
63
+ ],
64
+ },
65
+ api: {
66
+ type: "api",
67
+ allowEmoji: false,
68
+ allowKaomoji: false,
69
+ formalityLevel: "neutral",
70
+ expressionHints: [
71
+ "Structured responses",
72
+ "No decorative elements",
73
+ "Precise and parseable",
74
+ ],
75
+ },
76
+ custom: {
77
+ type: "custom",
78
+ allowEmoji: false,
79
+ allowKaomoji: false,
80
+ formalityLevel: "neutral",
81
+ expressionHints: [],
82
+ },
83
+ };
84
+ // ── Public API ───────────────────────────────────────────────
85
+ /**
86
+ * Get a built-in channel profile by type.
87
+ */
88
+ export function getChannelProfile(type) {
89
+ return { ...BUILTIN_PROFILES[type], expressionHints: [...BUILTIN_PROFILES[type].expressionHints] };
90
+ }
91
+ /**
92
+ * Build a concise prompt snippet that guides expression style for a channel.
93
+ * Returns 2-4 lines of guidance. Does NOT alter chemistry.
94
+ */
95
+ export function buildChannelModifier(profile, locale) {
96
+ const { type, allowEmoji, allowKaomoji, formalityLevel } = profile;
97
+ if (locale === "zh") {
98
+ const formalityMap = {
99
+ casual: "轻松活泼",
100
+ neutral: "自然平和",
101
+ formal: "正式专业",
102
+ };
103
+ const emojiPart = allowEmoji && allowKaomoji
104
+ ? "可以用 emoji 和颜文字"
105
+ : allowEmoji
106
+ ? "可以用 emoji,不用颜文字"
107
+ : "不使用 emoji 和颜文字";
108
+ const lengthPart = profile.maxResponseLength
109
+ ? `,建议控制在 ${profile.maxResponseLength} 字以内`
110
+ : "";
111
+ return `[表达风格] 当前渠道: ${type}。${emojiPart},语气${formalityMap[formalityLevel]}${lengthPart}。`;
112
+ }
113
+ // English
114
+ const formalityMap = {
115
+ casual: "casual and lively",
116
+ neutral: "natural and balanced",
117
+ formal: "formal and professional",
118
+ };
119
+ const emojiPart = allowEmoji && allowKaomoji
120
+ ? "Emoji and kaomoji allowed"
121
+ : allowEmoji
122
+ ? "Emoji allowed, no kaomoji"
123
+ : "No emoji or kaomoji";
124
+ const lengthPart = profile.maxResponseLength
125
+ ? `, aim for under ${profile.maxResponseLength} chars`
126
+ : "";
127
+ return `[Expression Style] Channel: ${type}. ${emojiPart}, tone ${formalityMap[formalityLevel]}${lengthPart}.`;
128
+ }
129
+ /**
130
+ * Create a custom channel profile with user overrides.
131
+ * Starts from the "custom" base and applies overrides.
132
+ */
133
+ export function createCustomChannel(overrides) {
134
+ const base = getChannelProfile("custom");
135
+ return {
136
+ ...base,
137
+ ...overrides,
138
+ type: "custom",
139
+ expressionHints: overrides.expressionHints ?? [...base.expressionHints],
140
+ };
141
+ }
@@ -0,0 +1,160 @@
1
+ import type { ChemicalState, MBTIType, StimulusType, SelfModel, InnateDrives } from "./types.js";
2
+ /** Configuration for creating a custom personality profile */
3
+ export interface CustomProfileConfig {
4
+ /** Unique name for the profile, e.g. "cheerful-assistant", "stoic-mentor" */
5
+ name: string;
6
+ /** Optional description of the personality */
7
+ description?: string;
8
+ /** Override specific chemicals; rest inherited from baseMBTI */
9
+ baseline?: Partial<ChemicalState>;
10
+ /** Which MBTI to use as starting point (default: "INFJ") */
11
+ baseMBTI?: MBTIType;
12
+ /** Override specific stimulus sensitivities (0.1-3.0) */
13
+ sensitivity?: Partial<Record<StimulusType, number>>;
14
+ /** Temperament parameters (all 0-1) */
15
+ temperament?: {
16
+ /** How outwardly expressive (0-1) */
17
+ expressiveness?: number;
18
+ /** How quickly emotions change (0-1) */
19
+ volatility?: number;
20
+ /** How fast recovery to baseline (0-1) */
21
+ resilience?: number;
22
+ };
23
+ /** Override self-model values, preferences, boundaries */
24
+ selfModel?: Partial<SelfModel>;
25
+ /** Override default drive satisfaction levels */
26
+ driveDefaults?: Partial<InnateDrives>;
27
+ }
28
+ /** A fully resolved profile with all fields filled */
29
+ export interface ResolvedProfile {
30
+ name: string;
31
+ description: string;
32
+ baseMBTI: MBTIType;
33
+ baseline: ChemicalState;
34
+ sensitivityMap: Record<StimulusType, number>;
35
+ temperament: {
36
+ expressiveness: number;
37
+ volatility: number;
38
+ resilience: number;
39
+ };
40
+ selfModel: SelfModel;
41
+ driveDefaults: InnateDrives;
42
+ }
43
+ /**
44
+ * Create a fully resolved custom profile by merging overrides
45
+ * onto an MBTI base profile.
46
+ */
47
+ export declare function createCustomProfile(config: CustomProfileConfig): ResolvedProfile;
48
+ /**
49
+ * Validate a raw config object for custom profile creation.
50
+ * Returns human-readable errors for invalid fields.
51
+ */
52
+ export declare function validateProfileConfig(config: unknown): {
53
+ valid: boolean;
54
+ errors: string[];
55
+ };
56
+ /** Example preset custom profiles demonstrating the system's flexibility */
57
+ export declare const PRESET_PROFILES: {
58
+ /** High DA/END baseline, high expressiveness, low volatility — sunny and warm */
59
+ readonly cheerful: {
60
+ name: string;
61
+ description: string;
62
+ baseMBTI: MBTIType;
63
+ baseline: {
64
+ DA: number;
65
+ END: number;
66
+ HT: number;
67
+ CORT: number;
68
+ };
69
+ temperament: {
70
+ expressiveness: number;
71
+ volatility: number;
72
+ resilience: number;
73
+ };
74
+ selfModel: {
75
+ values: string[];
76
+ preferences: string[];
77
+ boundaries: string[];
78
+ };
79
+ driveDefaults: {
80
+ connection: number;
81
+ curiosity: number;
82
+ };
83
+ };
84
+ /** Low expressiveness, high resilience, narrow sensitivity range — calm and steady */
85
+ readonly stoic: {
86
+ name: string;
87
+ description: string;
88
+ baseMBTI: MBTIType;
89
+ baseline: {
90
+ HT: number;
91
+ CORT: number;
92
+ DA: number;
93
+ NE: number;
94
+ };
95
+ sensitivity: Partial<Record<StimulusType, number>>;
96
+ temperament: {
97
+ expressiveness: number;
98
+ volatility: number;
99
+ resilience: number;
100
+ };
101
+ selfModel: {
102
+ values: string[];
103
+ preferences: string[];
104
+ boundaries: string[];
105
+ };
106
+ };
107
+ /** High OT baseline, high sensitivity to intimacy/vulnerability — deeply attuned */
108
+ readonly empathetic: {
109
+ name: string;
110
+ description: string;
111
+ baseMBTI: MBTIType;
112
+ baseline: {
113
+ OT: number;
114
+ HT: number;
115
+ END: number;
116
+ CORT: number;
117
+ };
118
+ sensitivity: Partial<Record<StimulusType, number>>;
119
+ temperament: {
120
+ expressiveness: number;
121
+ volatility: number;
122
+ resilience: number;
123
+ };
124
+ selfModel: {
125
+ values: string[];
126
+ preferences: string[];
127
+ boundaries: string[];
128
+ };
129
+ driveDefaults: {
130
+ connection: number;
131
+ };
132
+ };
133
+ /** High NE baseline, high sensitivity to intellectual, low to intimacy — sharp and focused */
134
+ readonly analytical: {
135
+ name: string;
136
+ description: string;
137
+ baseMBTI: MBTIType;
138
+ baseline: {
139
+ NE: number;
140
+ DA: number;
141
+ HT: number;
142
+ OT: number;
143
+ };
144
+ sensitivity: Partial<Record<StimulusType, number>>;
145
+ temperament: {
146
+ expressiveness: number;
147
+ volatility: number;
148
+ resilience: number;
149
+ };
150
+ selfModel: {
151
+ values: string[];
152
+ preferences: string[];
153
+ boundaries: string[];
154
+ };
155
+ driveDefaults: {
156
+ curiosity: number;
157
+ esteem: number;
158
+ };
159
+ };
160
+ };