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 +7 -3
- package/dist/adapters/vercel-ai.d.ts +20 -1
- package/dist/adapters/vercel-ai.js +88 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/interaction.d.ts +101 -0
- package/dist/interaction.js +321 -0
- package/dist/prompt.js +9 -1
- package/dist/update.js +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
-
- **自我认知** — 分析情绪历史,识别自身的情绪趋势和反复触发点(
|
|
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 #
|
|
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.
|
|
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.
|
|
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");
|
package/openclaw.plugin.json
CHANGED