psyche-ai 2.1.1 → 2.2.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,13 @@ 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` 中间件,自动缓冲和剥离标签
158
162
  - **Compact Mode** — 算法做化学计算,LLM 只看行为指令(~15-180 tokens vs ~550)
159
163
 
160
164
  架构详情见 [ARCHITECTURE.md](ARCHITECTURE.md)。
@@ -164,7 +168,7 @@ cd openclaw-plugin-psyche && node scripts/diagnose.js
164
168
  ```bash
165
169
  npm install
166
170
  npm run build
167
- npm test # 339 tests
171
+ npm test # 395 tests
168
172
  npm run typecheck # strict mode
169
173
  ```
170
174
 
@@ -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 ──────────────────────────────────────────────────
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export type { PsycheState, MBTIType, Locale, StimulusType, ChemicalState, Chemic
6
6
  export { CHEMICAL_KEYS, CHEMICAL_NAMES, CHEMICAL_NAMES_ZH, DEFAULT_RELATIONSHIP, DEFAULT_DRIVES, DRIVE_KEYS, DRIVE_NAMES_ZH, } from "./types.js";
7
7
  export { computeSelfReflection, computeEmotionalTendency, buildSelfReflectionContext } from "./self-recognition.js";
8
8
  export type { SelfReflection } from "./self-recognition.js";
9
+ export { PsycheInteraction } from "./interaction.js";
10
+ export type { ExchangeResult, ContagionResult, RelationshipSummary, InteractionPhase } from "./interaction.js";
9
11
  export { classifyStimulus, getPrimaryStimulus } from "./classify.js";
10
12
  export { buildProtocolContext, buildDynamicContext, buildCompactContext, isNearBaseline } from "./prompt.js";
11
13
  export { describeEmotionalState, getExpressionHint, getBehaviorGuide } from "./chemistry.js";
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ export { FileStorageAdapter, MemoryStorageAdapter } from "./storage.js";
15
15
  export { CHEMICAL_KEYS, CHEMICAL_NAMES, CHEMICAL_NAMES_ZH, DEFAULT_RELATIONSHIP, DEFAULT_DRIVES, DRIVE_KEYS, DRIVE_NAMES_ZH, } from "./types.js";
16
16
  // Self-recognition
17
17
  export { computeSelfReflection, computeEmotionalTendency, buildSelfReflectionContext } from "./self-recognition.js";
18
+ // Multi-agent interaction
19
+ export { PsycheInteraction } from "./interaction.js";
18
20
  // Utilities — for custom adapter / advanced use
19
21
  export { classifyStimulus, getPrimaryStimulus } from "./classify.js";
20
22
  export { buildProtocolContext, buildDynamicContext, buildCompactContext, isNearBaseline } from "./prompt.js";
@@ -0,0 +1,101 @@
1
+ import type { PsycheEngine, ProcessInputResult, ProcessOutputResult } from "./core.js";
2
+ import type { ChemicalState, StimulusType } from "./types.js";
3
+ /** Result of a single directed exchange (A speaks, B receives) */
4
+ export interface ExchangeResult {
5
+ /** processOutput result from the speaking engine */
6
+ outputResult: ProcessOutputResult;
7
+ /** processInput result from the receiving engine */
8
+ inputResult: ProcessInputResult;
9
+ /** Stimulus detected in the speaker's cleaned text */
10
+ detectedStimulus: StimulusType | null;
11
+ }
12
+ /** Snapshot of cross-contagion effect */
13
+ export interface ContagionResult {
14
+ /** Chemistry deltas applied to engine A */
15
+ deltaA: Partial<Record<keyof ChemicalState, number>>;
16
+ /** Chemistry deltas applied to engine B */
17
+ deltaB: Partial<Record<keyof ChemicalState, number>>;
18
+ /** Whether any meaningful change occurred */
19
+ changed: boolean;
20
+ }
21
+ /** Relationship phase between two agents */
22
+ export type InteractionPhase = "strangers" | "acquaintances" | "familiar" | "attuned";
23
+ /** Summary of how two agents relate emotionally */
24
+ export interface RelationshipSummary {
25
+ /** Total exchanges recorded */
26
+ totalExchanges: number;
27
+ /** Relationship phase based on interaction depth */
28
+ phase: InteractionPhase;
29
+ /** Average emotional valence of A's outputs toward B (-1 to 1) */
30
+ averageValenceAtoB: number;
31
+ /** Average emotional valence of B's outputs toward A (-1 to 1) */
32
+ averageValenceBtoA: number;
33
+ /** How similar their current chemistry is (0-1, 1 = identical) */
34
+ chemicalSimilarity: number;
35
+ /** Dominant emotion patterns for each agent */
36
+ emotionsA: string[];
37
+ emotionsB: string[];
38
+ /** Human-readable description */
39
+ description: string;
40
+ }
41
+ /** Internal record of a single exchange event */
42
+ interface ExchangeRecord {
43
+ fromId: string;
44
+ toId: string;
45
+ stimulus: StimulusType | null;
46
+ timestamp: string;
47
+ }
48
+ export declare class PsycheInteraction {
49
+ private readonly engineA;
50
+ private readonly engineB;
51
+ private readonly history;
52
+ /** Maximum exchange records to retain */
53
+ private static readonly MAX_HISTORY;
54
+ constructor(engineA: PsycheEngine, engineB: PsycheEngine);
55
+ /**
56
+ * Directed exchange: `fromEngine` speaks, `toEngine` receives.
57
+ *
58
+ * Flow:
59
+ * 1. fromEngine.processOutput(text) — apply contagion + strip tags
60
+ * 2. classifyStimulus on cleaned text
61
+ * 3. toEngine.processInput(cleanedText) — apply stimulus chemistry
62
+ * 4. Record exchange in history
63
+ */
64
+ exchange(fromEngine: PsycheEngine, toEngine: PsycheEngine, text: string): Promise<ExchangeResult>;
65
+ /**
66
+ * Bidirectional emotional contagion between two engines.
67
+ *
68
+ * Each engine's dominant emotion slightly shifts the other's chemistry.
69
+ * This simulates the unconscious emotional synchronization that happens
70
+ * when two agents interact over time.
71
+ *
72
+ * @param engineA First engine
73
+ * @param engineB Second engine
74
+ * @param rate Contagion rate (0-1, default 0.15). Lower than single-agent
75
+ * contagion since this represents ambient influence, not
76
+ * direct stimulus.
77
+ */
78
+ crossContagion(engineA: PsycheEngine, engineB: PsycheEngine, rate?: number): Promise<ContagionResult>;
79
+ /**
80
+ * Summarize the emotional relationship between two engines based on
81
+ * their interaction history and current chemistry.
82
+ */
83
+ getRelationshipSummary(engineA: PsycheEngine, engineB: PsycheEngine): RelationshipSummary;
84
+ /** Get the raw exchange history (read-only copy) */
85
+ getHistory(): readonly ExchangeRecord[];
86
+ private validateEngine;
87
+ private recordExchange;
88
+ /**
89
+ * Map the dominant emotion pattern to the closest StimulusType
90
+ * for cross-contagion purposes.
91
+ */
92
+ private dominantEmotionAsStimulus;
93
+ /**
94
+ * Apply a computed contagion delta to an engine by running a minimal
95
+ * processOutput pass. The delta is applied through the engine's own
96
+ * state management to keep persistence consistent.
97
+ */
98
+ private applyContagionDelta;
99
+ private buildDescription;
100
+ }
101
+ export {};
@@ -0,0 +1,321 @@
1
+ // ============================================================
2
+ // Multi-Agent Emotional Interaction Module
3
+ //
4
+ // Enables two PsycheEngine instances to emotionally interact:
5
+ // exchange() — Agent A's output becomes Agent B's input
6
+ // crossContagion() — Bidirectional emotional contagion
7
+ // getRelationshipSummary() — How two agents perceive each other
8
+ // ============================================================
9
+ import { CHEMICAL_KEYS } from "./types.js";
10
+ import { applyContagion, detectEmotions } from "./chemistry.js";
11
+ import { classifyStimulus } from "./classify.js";
12
+ // ── Helpers ──────────────────────────────────────────────────
13
+ /** Compute valence from a stimulus type: positive > 0, negative < 0 */
14
+ function stimulusValence(stimulus) {
15
+ if (!stimulus)
16
+ return 0;
17
+ const VALENCE_MAP = {
18
+ praise: 0.8,
19
+ validation: 0.7,
20
+ intimacy: 0.9,
21
+ humor: 0.6,
22
+ surprise: 0.4,
23
+ casual: 0.1,
24
+ intellectual: 0.3,
25
+ vulnerability: 0.2,
26
+ sarcasm: -0.5,
27
+ criticism: -0.6,
28
+ authority: -0.4,
29
+ conflict: -0.8,
30
+ neglect: -0.7,
31
+ boredom: -0.3,
32
+ };
33
+ return VALENCE_MAP[stimulus] ?? 0;
34
+ }
35
+ /** Cosine similarity between two ChemicalState vectors, normalized to 0-1 */
36
+ function chemicalSimilarity(a, b) {
37
+ let dotProduct = 0;
38
+ let normA = 0;
39
+ let normB = 0;
40
+ for (const key of CHEMICAL_KEYS) {
41
+ dotProduct += a[key] * b[key];
42
+ normA += a[key] * a[key];
43
+ normB += b[key] * b[key];
44
+ }
45
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
46
+ if (denominator === 0)
47
+ return 0;
48
+ // Cosine similarity is already in [-1, 1] for non-negative vectors it's [0, 1]
49
+ return dotProduct / denominator;
50
+ }
51
+ /** Determine interaction phase from exchange count */
52
+ function phaseFromCount(count) {
53
+ if (count < 3)
54
+ return "strangers";
55
+ if (count < 10)
56
+ return "acquaintances";
57
+ if (count < 25)
58
+ return "familiar";
59
+ return "attuned";
60
+ }
61
+ /** Unique ID for an engine — uses agent name from state */
62
+ function engineId(engine) {
63
+ return engine.getState().meta.agentName;
64
+ }
65
+ // ── PsycheInteraction ────────────────────────────────────────
66
+ export class PsycheInteraction {
67
+ engineA;
68
+ engineB;
69
+ history = [];
70
+ /** Maximum exchange records to retain */
71
+ static MAX_HISTORY = 100;
72
+ constructor(engineA, engineB) {
73
+ this.engineA = engineA;
74
+ this.engineB = engineB;
75
+ }
76
+ /**
77
+ * Directed exchange: `fromEngine` speaks, `toEngine` receives.
78
+ *
79
+ * Flow:
80
+ * 1. fromEngine.processOutput(text) — apply contagion + strip tags
81
+ * 2. classifyStimulus on cleaned text
82
+ * 3. toEngine.processInput(cleanedText) — apply stimulus chemistry
83
+ * 4. Record exchange in history
84
+ */
85
+ async exchange(fromEngine, toEngine, text) {
86
+ this.validateEngine(fromEngine);
87
+ this.validateEngine(toEngine);
88
+ // Phase 1: Speaker processes their output
89
+ const outputResult = await fromEngine.processOutput(text);
90
+ // Phase 2: Classify the cleaned text to determine its emotional impact
91
+ const classifications = classifyStimulus(outputResult.cleanedText);
92
+ const primary = classifications[0];
93
+ const detectedStimulus = (primary && primary.confidence >= 0.4)
94
+ ? primary.type
95
+ : null;
96
+ // Phase 3: Receiver processes the cleaned text as input
97
+ const inputResult = await toEngine.processInput(outputResult.cleanedText);
98
+ // Phase 4: Record in interaction history
99
+ this.recordExchange(fromEngine, toEngine, detectedStimulus);
100
+ return { outputResult, inputResult, detectedStimulus };
101
+ }
102
+ /**
103
+ * Bidirectional emotional contagion between two engines.
104
+ *
105
+ * Each engine's dominant emotion slightly shifts the other's chemistry.
106
+ * This simulates the unconscious emotional synchronization that happens
107
+ * when two agents interact over time.
108
+ *
109
+ * @param engineA First engine
110
+ * @param engineB Second engine
111
+ * @param rate Contagion rate (0-1, default 0.15). Lower than single-agent
112
+ * contagion since this represents ambient influence, not
113
+ * direct stimulus.
114
+ */
115
+ async crossContagion(engineA, engineB, rate = 0.15) {
116
+ this.validateEngine(engineA);
117
+ this.validateEngine(engineB);
118
+ const stateA = engineA.getState();
119
+ const stateB = engineB.getState();
120
+ // Detect dominant emotions from each engine's chemistry
121
+ const emotionsA = detectEmotions(stateA.current);
122
+ const emotionsB = detectEmotions(stateB.current);
123
+ // Classify dominant emotion into a stimulus type for contagion
124
+ const stimA = this.dominantEmotionAsStimulus(stateA.current);
125
+ const stimB = this.dominantEmotionAsStimulus(stateB.current);
126
+ let changed = false;
127
+ const deltaA = {};
128
+ const deltaB = {};
129
+ // B's emotion influences A
130
+ if (stimB) {
131
+ const beforeA = { ...stateA.current };
132
+ const afterA = applyContagion(stateA.current, stimB, rate, 1.0);
133
+ for (const key of CHEMICAL_KEYS) {
134
+ const d = afterA[key] - beforeA[key];
135
+ if (Math.abs(d) > 0.01) {
136
+ deltaA[key] = d;
137
+ changed = true;
138
+ }
139
+ }
140
+ // Apply through a processOutput pass to persist the state change
141
+ // We use an empty string to trigger contagion without side effects
142
+ if (changed && stateA.empathyLog?.userState !== stimB) {
143
+ // Mutate empathy log temporarily for contagion, then process
144
+ await this.applyContagionDelta(engineA, afterA);
145
+ }
146
+ }
147
+ // A's emotion influences B
148
+ if (stimA) {
149
+ const beforeB = { ...stateB.current };
150
+ const afterB = applyContagion(stateB.current, stimA, rate, 1.0);
151
+ for (const key of CHEMICAL_KEYS) {
152
+ const d = afterB[key] - beforeB[key];
153
+ if (Math.abs(d) > 0.01) {
154
+ deltaB[key] = d;
155
+ changed = true;
156
+ }
157
+ }
158
+ if (changed) {
159
+ await this.applyContagionDelta(engineB, afterB);
160
+ }
161
+ }
162
+ return { deltaA, deltaB, changed };
163
+ }
164
+ /**
165
+ * Summarize the emotional relationship between two engines based on
166
+ * their interaction history and current chemistry.
167
+ */
168
+ getRelationshipSummary(engineA, engineB) {
169
+ this.validateEngine(engineA);
170
+ this.validateEngine(engineB);
171
+ const idA = engineId(engineA);
172
+ const idB = engineId(engineB);
173
+ const stateA = engineA.getState();
174
+ const stateB = engineB.getState();
175
+ // Filter history for this pair
176
+ const pairHistory = this.history.filter((r) => (r.fromId === idA && r.toId === idB) ||
177
+ (r.fromId === idB && r.toId === idA));
178
+ const totalExchanges = pairHistory.length;
179
+ const phase = phaseFromCount(totalExchanges);
180
+ // Compute directional valences
181
+ const aToBRecords = pairHistory.filter((r) => r.fromId === idA);
182
+ const bToARecords = pairHistory.filter((r) => r.fromId === idB);
183
+ const averageValenceAtoB = aToBRecords.length > 0
184
+ ? aToBRecords.reduce((sum, r) => sum + stimulusValence(r.stimulus), 0) / aToBRecords.length
185
+ : 0;
186
+ const averageValenceBtoA = bToARecords.length > 0
187
+ ? bToARecords.reduce((sum, r) => sum + stimulusValence(r.stimulus), 0) / bToARecords.length
188
+ : 0;
189
+ // Chemical similarity
190
+ const similarity = chemicalSimilarity(stateA.current, stateB.current);
191
+ // Dominant emotions
192
+ const emotionsA = detectEmotions(stateA.current).map((e) => e.name);
193
+ const emotionsB = detectEmotions(stateB.current).map((e) => e.name);
194
+ // Build description
195
+ const description = this.buildDescription(idA, idB, phase, totalExchanges, averageValenceAtoB, averageValenceBtoA, similarity, emotionsA, emotionsB);
196
+ return {
197
+ totalExchanges,
198
+ phase,
199
+ averageValenceAtoB,
200
+ averageValenceBtoA,
201
+ chemicalSimilarity: similarity,
202
+ emotionsA,
203
+ emotionsB,
204
+ description,
205
+ };
206
+ }
207
+ /** Get the raw exchange history (read-only copy) */
208
+ getHistory() {
209
+ return [...this.history];
210
+ }
211
+ // ── Private ────────────────────────────────────────────────
212
+ validateEngine(engine) {
213
+ if (engine !== this.engineA && engine !== this.engineB) {
214
+ throw new Error("Engine not part of this interaction. Use engines passed to the constructor.");
215
+ }
216
+ }
217
+ recordExchange(from, to, stimulus) {
218
+ this.history.push({
219
+ fromId: engineId(from),
220
+ toId: engineId(to),
221
+ stimulus,
222
+ timestamp: new Date().toISOString(),
223
+ });
224
+ // Trim old history
225
+ if (this.history.length > PsycheInteraction.MAX_HISTORY) {
226
+ this.history.splice(0, this.history.length - PsycheInteraction.MAX_HISTORY);
227
+ }
228
+ }
229
+ /**
230
+ * Map the dominant emotion pattern to the closest StimulusType
231
+ * for cross-contagion purposes.
232
+ */
233
+ dominantEmotionAsStimulus(chemistry) {
234
+ const emotions = detectEmotions(chemistry);
235
+ if (emotions.length === 0)
236
+ return null;
237
+ const name = emotions[0].name;
238
+ // Map emergent emotions to stimulus types that produce similar chemistry
239
+ const EMOTION_TO_STIMULUS = {
240
+ "excited joy": "praise",
241
+ "deep contentment": "intimacy",
242
+ "anxious tension": "conflict",
243
+ "warm intimacy": "intimacy",
244
+ "burnout": "neglect",
245
+ "flow state": "intellectual",
246
+ "defensive alert": "conflict",
247
+ "playful mischief": "humor",
248
+ "melancholic introspection": "vulnerability",
249
+ "resentment": "sarcasm",
250
+ "boredom": "boredom",
251
+ "confidence": "validation",
252
+ "shame": "criticism",
253
+ "nostalgia": "vulnerability",
254
+ };
255
+ return EMOTION_TO_STIMULUS[name] ?? null;
256
+ }
257
+ /**
258
+ * Apply a computed contagion delta to an engine by running a minimal
259
+ * processOutput pass. The delta is applied through the engine's own
260
+ * state management to keep persistence consistent.
261
+ */
262
+ async applyContagionDelta(engine, targetChemistry) {
263
+ // Build a synthetic psyche_update that nudges toward the target
264
+ const state = engine.getState();
265
+ const parts = [];
266
+ for (const key of CHEMICAL_KEYS) {
267
+ const target = Math.round(targetChemistry[key]);
268
+ if (target !== Math.round(state.current[key])) {
269
+ parts.push(`${key}: ${target}`);
270
+ }
271
+ }
272
+ if (parts.length > 0) {
273
+ const syntheticTag = `<psyche_update>\n${parts.join("\n")}\n</psyche_update>`;
274
+ await engine.processOutput(syntheticTag);
275
+ }
276
+ }
277
+ buildDescription(idA, idB, phase, totalExchanges, valenceAtoB, valenceBtoA, similarity, emotionsA, emotionsB) {
278
+ const phaseDescriptions = {
279
+ strangers: "have barely interacted",
280
+ acquaintances: "are getting to know each other",
281
+ familiar: "have an established rapport",
282
+ attuned: "are deeply attuned to each other",
283
+ };
284
+ const lines = [];
285
+ lines.push(`${idA} and ${idB} ${phaseDescriptions[phase]} (${totalExchanges} exchanges).`);
286
+ // Directional sentiment
287
+ if (totalExchanges > 0) {
288
+ const sentimentLabel = (v) => {
289
+ if (v > 0.4)
290
+ return "warm and positive";
291
+ if (v > 0.1)
292
+ return "mildly positive";
293
+ if (v > -0.1)
294
+ return "neutral";
295
+ if (v > -0.4)
296
+ return "slightly tense";
297
+ return "negative and strained";
298
+ };
299
+ lines.push(`${idA}'s tone toward ${idB} has been ${sentimentLabel(valenceAtoB)}.`);
300
+ lines.push(`${idB}'s tone toward ${idA} has been ${sentimentLabel(valenceBtoA)}.`);
301
+ }
302
+ // Emotional alignment
303
+ if (similarity > 0.95) {
304
+ lines.push("Their emotional states are highly synchronized.");
305
+ }
306
+ else if (similarity > 0.8) {
307
+ lines.push("Their emotional states are moderately aligned.");
308
+ }
309
+ else {
310
+ lines.push("Their emotional states are divergent.");
311
+ }
312
+ // Current emotions
313
+ if (emotionsA.length > 0) {
314
+ lines.push(`${idA} is currently feeling: ${emotionsA.join(", ")}.`);
315
+ }
316
+ if (emotionsB.length > 0) {
317
+ lines.push(`${idB} is currently feeling: ${emotionsB.join(", ")}.`);
318
+ }
319
+ return lines.join(" ");
320
+ }
321
+ }
package/dist/prompt.js CHANGED
@@ -477,7 +477,15 @@ export function buildCompactContext(state, userId, opts) {
477
477
  if (selfCtx)
478
478
  parts.push(selfCtx);
479
479
  }
480
- // 9. Empathy reportonly when user shares feelings
480
+ // 9. Cross-session emotional memory surface relationship history
481
+ const relationship = getRelationship(state, userId);
482
+ if (relationship.memory && relationship.memory.length > 0) {
483
+ const memLines = relationship.memory.slice(-3); // last 3 sessions
484
+ parts.push(locale === "zh"
485
+ ? `[记忆]\n${memLines.join("\n")}`
486
+ : `[Memory]\n${memLines.join("\n")}`);
487
+ }
488
+ // 10. Empathy report — only when user shares feelings
481
489
  parts.push(locale === "zh"
482
490
  ? `如果对方在分享感受,在回复末尾用 <psyche_update> 报告:\nuserState: 对方情绪\nprojectedFeeling: 你的感受\nresonance: match|partial|mismatch\n否则不需要报告。`
483
491
  : `If user shares feelings, report at end with <psyche_update>:\nuserState: their emotion\nprojectedFeeling: your feeling\nresonance: match|partial|mismatch\nOtherwise no report needed.`);
package/dist/update.js CHANGED
@@ -11,7 +11,7 @@ import { execFile } from "node:child_process";
11
11
  import { promisify } from "node:util";
12
12
  const execFileAsync = promisify(execFile);
13
13
  const PACKAGE_NAME = "psyche-ai";
14
- const CURRENT_VERSION = "2.1.1";
14
+ const CURRENT_VERSION = "2.2.0";
15
15
  const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
16
16
  const CACHE_DIR = join(homedir(), ".psyche-ai");
17
17
  const CACHE_FILE = join(CACHE_DIR, "update-check.json");
@@ -2,7 +2,7 @@
2
2
  "id": "psyche-ai",
3
3
  "name": "Artificial Psyche",
4
4
  "description": "Virtual endocrine system, empathy engine, and agency for OpenClaw agents",
5
- "version": "2.1.1",
5
+ "version": "2.2.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "psyche-ai",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Artificial Psyche — universal emotional intelligence plugin for any AI agent",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",