psyche-ai 2.2.0 → 3.0.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
@@ -159,6 +159,10 @@ cd openclaw-plugin-psyche && node scripts/diagnose.js
159
159
  - **跨会话记忆** — 重新遇到用户时注入上次对话的情绪记忆
160
160
  - **多 Agent 交互** — 两个 PsycheEngine 实例之间的情绪传染、关系追踪
161
161
  - **流式支持** — Vercel AI SDK `streamText` 中间件,自动缓冲和剥离标签
162
+ - **渠道修饰** — Discord/Slack/飞书/终端等不同渠道自动调整表达风格
163
+ - **自定义人格** — 超越 MBTI 预设,完全自定义 baseline/敏感度/气质
164
+ - **情绪学习** — 从交互结果中学习,调整情绪反应参数(躯体标记假说)
165
+ - **上下文分类** — 关系/驱力/历史感知的刺激分类,超越简单正则
162
166
  - **Compact Mode** — 算法做化学计算,LLM 只看行为指令(~15-180 tokens vs ~550)
163
167
 
164
168
  架构详情见 [ARCHITECTURE.md](ARCHITECTURE.md)。
@@ -168,7 +172,7 @@ cd openclaw-plugin-psyche && node scripts/diagnose.js
168
172
  ```bash
169
173
  npm install
170
174
  npm run build
171
- npm test # 395 tests
175
+ npm test # 525 tests
172
176
  npm run typecheck # strict mode
173
177
  ```
174
178
 
@@ -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,39 @@
1
+ import type { StimulusType, DriveType, RelationshipState, PsycheState } from "./types.js";
2
+ export interface ContextFeatures {
3
+ relationshipPhase: RelationshipState["phase"];
4
+ recentStimuli: StimulusType[];
5
+ driveSatisfaction: Record<DriveType, "high" | "mid" | "low">;
6
+ timeSinceLastMessage: number;
7
+ totalInteractions: number;
8
+ agreementStreak: number;
9
+ }
10
+ export interface ContextualClassification {
11
+ type: StimulusType;
12
+ baseConfidence: number;
13
+ contextConfidence: number;
14
+ contextModifiers: string[];
15
+ }
16
+ /**
17
+ * Extract contextual features from the current psyche state.
18
+ * Used to feed into classifyStimulusWithContext.
19
+ */
20
+ export declare function extractContextFeatures(state: PsycheState, userId?: string): ContextFeatures;
21
+ /**
22
+ * Classify a stimulus with context modifiers applied.
23
+ *
24
+ * Wraps classifyStimulus(text) and adjusts confidence based on:
25
+ * - Relationship depth
26
+ * - Recent stimulus patterns
27
+ * - Drive hunger
28
+ * - Agreement streak
29
+ * - Time gap
30
+ *
31
+ * Returns results sorted by contextConfidence descending.
32
+ */
33
+ export declare function classifyStimulusWithContext(text: string, context: ContextFeatures): ContextualClassification[];
34
+ /**
35
+ * Map a stimulus type to a warmth score for outcome evaluation.
36
+ * Positive stimuli return positive values; negative stimuli return negative.
37
+ * null returns 0.
38
+ */
39
+ export declare function stimulusWarmth(stimulus: StimulusType | null): number;
@@ -0,0 +1,204 @@
1
+ // ============================================================
2
+ // Context-Aware Stimulus Classification
3
+ //
4
+ // Wraps classify.ts with contextual signals (relationship depth,
5
+ // recent stimulus patterns, drive hunger, agreement streaks,
6
+ // time gaps) to improve classification accuracy.
7
+ // ============================================================
8
+ import { DRIVE_KEYS } from "./types.js";
9
+ import { classifyStimulus } from "./classify.js";
10
+ // ── Drive Satisfaction Thresholds ────────────────────────────
11
+ function driveSatisfactionLevel(value) {
12
+ if (value >= 70)
13
+ return "high";
14
+ if (value >= 40)
15
+ return "mid";
16
+ return "low";
17
+ }
18
+ // ── Extract Context Features ─────────────────────────────────
19
+ /**
20
+ * Extract contextual features from the current psyche state.
21
+ * Used to feed into classifyStimulusWithContext.
22
+ */
23
+ export function extractContextFeatures(state, userId) {
24
+ // Relationship phase
25
+ const relKey = userId ?? "_default";
26
+ const relationship = state.relationships[relKey] ?? state.relationships["_default"];
27
+ const relationshipPhase = relationship?.phase ?? "stranger";
28
+ // Recent stimuli from emotional history (last 3)
29
+ const recentStimuli = state.emotionalHistory
30
+ .slice(-3)
31
+ .map((snap) => snap.stimulus)
32
+ .filter((s) => s !== null);
33
+ // Drive satisfaction levels
34
+ const driveSatisfaction = {};
35
+ for (const key of DRIVE_KEYS) {
36
+ driveSatisfaction[key] = driveSatisfactionLevel(state.drives[key]);
37
+ }
38
+ // Time since last message (minutes)
39
+ const now = Date.now();
40
+ const updatedAt = new Date(state.updatedAt).getTime();
41
+ const timeSinceLastMessage = Math.max(0, (now - updatedAt) / 60_000);
42
+ // Total interactions
43
+ const totalInteractions = state.meta.totalInteractions;
44
+ // Agreement streak
45
+ const agreementStreak = state.agreementStreak;
46
+ return {
47
+ relationshipPhase,
48
+ recentStimuli,
49
+ driveSatisfaction,
50
+ timeSinceLastMessage,
51
+ totalInteractions,
52
+ agreementStreak,
53
+ };
54
+ }
55
+ // ── Context-Adjusted Classification ──────────────────────────
56
+ /**
57
+ * Classify a stimulus with context modifiers applied.
58
+ *
59
+ * Wraps classifyStimulus(text) and adjusts confidence based on:
60
+ * - Relationship depth
61
+ * - Recent stimulus patterns
62
+ * - Drive hunger
63
+ * - Agreement streak
64
+ * - Time gap
65
+ *
66
+ * Returns results sorted by contextConfidence descending.
67
+ */
68
+ export function classifyStimulusWithContext(text, context) {
69
+ const baseResults = classifyStimulus(text);
70
+ const results = baseResults.map((r) => ({
71
+ type: r.type,
72
+ baseConfidence: r.confidence,
73
+ contextConfidence: r.confidence,
74
+ contextModifiers: [],
75
+ }));
76
+ // Ensure all stimulus types that need boosting have an entry
77
+ const ensureType = (type) => {
78
+ let entry = results.find((r) => r.type === type);
79
+ if (!entry) {
80
+ entry = {
81
+ type,
82
+ baseConfidence: 0,
83
+ contextConfidence: 0,
84
+ contextModifiers: [],
85
+ };
86
+ results.push(entry);
87
+ }
88
+ return entry;
89
+ };
90
+ for (const r of results) {
91
+ // ── Relationship depth modifiers ──
92
+ if (context.relationshipPhase === "stranger" && r.type === "intimacy") {
93
+ r.contextConfidence *= 0.7;
94
+ r.contextModifiers.push("stranger penalty on intimacy");
95
+ }
96
+ if ((context.relationshipPhase === "close" || context.relationshipPhase === "deep") &&
97
+ r.type === "casual") {
98
+ r.contextConfidence += 0.1;
99
+ r.contextModifiers.push("close relationship boost on casual");
100
+ }
101
+ if (context.relationshipPhase === "stranger" && r.type === "vulnerability") {
102
+ r.contextConfidence *= 0.6;
103
+ r.contextModifiers.push("stranger penalty on vulnerability");
104
+ }
105
+ // ── Recent stimulus pattern modifiers ──
106
+ // Same stimulus 3x in a row → confidence * 0.8 (repetition fatigue)
107
+ if (context.recentStimuli.length >= 3 &&
108
+ context.recentStimuli.every((s) => s === r.type)) {
109
+ r.contextConfidence *= 0.8;
110
+ r.contextModifiers.push("repetition fatigue penalty");
111
+ }
112
+ // ── Agreement streak modifiers ──
113
+ if (context.agreementStreak >= 5 && r.type === "validation") {
114
+ r.contextConfidence *= 0.8;
115
+ r.contextModifiers.push("sycophantic loop dampening on validation");
116
+ }
117
+ // ── Time gap modifiers ──
118
+ if (context.timeSinceLastMessage > 1440) {
119
+ if (r.type === "casual") {
120
+ r.contextConfidence += 0.1;
121
+ r.contextModifiers.push("long absence boost on casual");
122
+ }
123
+ if (r.type === "intimacy") {
124
+ r.contextConfidence *= 0.9;
125
+ r.contextModifiers.push("long absence penalty on intimacy");
126
+ }
127
+ }
128
+ }
129
+ // ── De-escalation pattern (conflict → casual) ──
130
+ if (context.recentStimuli.length > 0 &&
131
+ context.recentStimuli[context.recentStimuli.length - 1] === "conflict") {
132
+ const casual = ensureType("casual");
133
+ casual.contextConfidence += 0.15;
134
+ casual.contextModifiers.push("de-escalation boost after conflict");
135
+ }
136
+ // ── Fake praise follow-up (praise → sarcasm) ──
137
+ if (context.recentStimuli.length > 0 &&
138
+ context.recentStimuli[context.recentStimuli.length - 1] === "praise") {
139
+ const sarcasm = results.find((r) => r.type === "sarcasm");
140
+ if (sarcasm) {
141
+ sarcasm.contextConfidence += 0.1;
142
+ sarcasm.contextModifiers.push("possible fake praise follow-up boost on sarcasm");
143
+ }
144
+ }
145
+ // ── Drive-hunger modifiers ──
146
+ if (context.driveSatisfaction.connection === "low") {
147
+ // Positive stimuli get a warmth boost
148
+ const positiveTypes = [
149
+ "praise", "validation", "intimacy", "humor", "casual", "vulnerability",
150
+ ];
151
+ for (const r of results) {
152
+ if (positiveTypes.includes(r.type)) {
153
+ r.contextConfidence += 0.05;
154
+ r.contextModifiers.push("connection hunger boost");
155
+ }
156
+ }
157
+ }
158
+ if (context.driveSatisfaction.esteem === "low") {
159
+ for (const r of results) {
160
+ if (r.type === "validation" || r.type === "praise") {
161
+ r.contextConfidence += 0.05;
162
+ r.contextModifiers.push("esteem hunger boost");
163
+ }
164
+ }
165
+ }
166
+ if (context.driveSatisfaction.survival === "low") {
167
+ for (const r of results) {
168
+ if (r.type === "authority" || r.type === "conflict") {
169
+ r.contextConfidence += 0.1;
170
+ r.contextModifiers.push("survival threat sensitivity boost");
171
+ }
172
+ }
173
+ }
174
+ // ── Sort by contextConfidence descending ──
175
+ results.sort((a, b) => b.contextConfidence - a.contextConfidence);
176
+ return results;
177
+ }
178
+ // ── Warmth Scoring ───────────────────────────────────────────
179
+ const WARMTH_MAP = {
180
+ praise: 0.8,
181
+ validation: 0.7,
182
+ intimacy: 0.9,
183
+ humor: 0.5,
184
+ surprise: 0.3,
185
+ casual: 0.1,
186
+ intellectual: 0.2,
187
+ vulnerability: 0.3,
188
+ sarcasm: -0.5,
189
+ criticism: -0.7,
190
+ conflict: -0.9,
191
+ authority: -0.4,
192
+ neglect: -0.8,
193
+ boredom: -0.3,
194
+ };
195
+ /**
196
+ * Map a stimulus type to a warmth score for outcome evaluation.
197
+ * Positive stimuli return positive values; negative stimuli return negative.
198
+ * null returns 0.
199
+ */
200
+ export function stimulusWarmth(stimulus) {
201
+ if (stimulus === null)
202
+ return 0;
203
+ return WARMTH_MAP[stimulus] ?? 0;
204
+ }
package/dist/core.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PsycheState, StimulusType, Locale, MBTIType } from "./types.js";
1
+ import type { PsycheState, StimulusType, Locale, MBTIType, OutcomeScore } from "./types.js";
2
2
  import type { StorageAdapter } from "./storage.js";
3
3
  export interface PsycheEngineConfig {
4
4
  mbti?: MBTIType;
@@ -24,11 +24,19 @@ export interface ProcessOutputResult {
24
24
  /** Whether chemistry was meaningfully updated (contagion or psyche_update) */
25
25
  stateChanged: boolean;
26
26
  }
27
+ export interface ProcessOutcomeResult {
28
+ /** Outcome evaluation score (-1 to 1) */
29
+ outcomeScore: OutcomeScore;
30
+ /** Whether learning state was updated */
31
+ learningUpdated: boolean;
32
+ }
27
33
  export declare class PsycheEngine {
28
34
  private state;
29
35
  private readonly storage;
30
36
  private readonly cfg;
31
37
  private readonly protocolCache;
38
+ /** Pending prediction from last processInput for auto-learning */
39
+ private pendingPrediction;
32
40
  constructor(config: PsycheEngineConfig | undefined, storage: StorageAdapter);
33
41
  /**
34
42
  * Load or create initial state. Must be called before processInput/processOutput.
@@ -48,6 +56,19 @@ export declare class PsycheEngine {
48
56
  processOutput(text: string, opts?: {
49
57
  userId?: string;
50
58
  }): Promise<ProcessOutputResult>;
59
+ /**
60
+ * Phase 3 (optional): Explicitly evaluate the outcome of the last interaction.
61
+ *
62
+ * This is automatically called at the start of processInput, so most users
63
+ * don't need to call it manually. Use this for explicit outcome evaluation
64
+ * (e.g., when a session ends without a follow-up message).
65
+ *
66
+ * @param nextUserStimulus - The stimulus detected in the user's next message,
67
+ * or null if the session ended.
68
+ */
69
+ processOutcome(nextUserStimulus: StimulusType | null, opts?: {
70
+ userId?: string;
71
+ }): Promise<ProcessOutcomeResult | null>;
51
72
  /**
52
73
  * Get the current psyche state (read-only snapshot).
53
74
  */
package/dist/core.js CHANGED
@@ -1,13 +1,17 @@
1
1
  // ============================================================
2
2
  // PsycheEngine — Framework-agnostic emotional intelligence core
3
3
  //
4
- // Two-phase API:
5
- // processInput(text) → systemContext + dynamicContext + stimulus
6
- // processOutput(text) → cleanedText + stateChanged
4
+ // Three-phase API:
5
+ // processInput(text) → systemContext + dynamicContext + stimulus
6
+ // processOutput(text) → cleanedText + stateChanged
7
+ // processOutcome(text) → outcomeScore (optional: evaluate last interaction)
7
8
  //
8
- // Orchestrates: chemistry, classify, prompt, profiles, guards
9
+ // Auto-learning: processInput auto-evaluates the previous turn's
10
+ // outcome using the new user message as the outcome signal.
11
+ //
12
+ // Orchestrates: chemistry, classify, prompt, profiles, guards, learning
9
13
  // ============================================================
10
- import { DEFAULT_RELATIONSHIP, DEFAULT_DRIVES } from "./types.js";
14
+ import { DEFAULT_RELATIONSHIP, DEFAULT_DRIVES, DEFAULT_LEARNING_STATE } from "./types.js";
11
15
  import { applyDecay, applyStimulus, applyContagion, clamp } from "./chemistry.js";
12
16
  import { classifyStimulus } from "./classify.js";
13
17
  import { buildDynamicContext, buildProtocolContext, buildCompactContext } from "./prompt.js";
@@ -16,6 +20,7 @@ import { isStimulusType } from "./guards.js";
16
20
  import { parsePsycheUpdate, mergeUpdates, updateAgreementStreak, pushSnapshot, } from "./psyche-file.js";
17
21
  import { decayDrives, feedDrives, detectExistentialThreat, computeEffectiveBaseline, computeEffectiveSensitivity, } from "./drives.js";
18
22
  import { checkForUpdate } from "./update.js";
23
+ import { evaluateOutcome, computeContextHash, updateLearnedVector, predictChemistry, recordPrediction, } from "./learning.js";
19
24
  const NOOP_LOGGER = { info: () => { }, warn: () => { }, debug: () => { } };
20
25
  // ── PsycheEngine ─────────────────────────────────────────────
21
26
  export class PsycheEngine {
@@ -23,6 +28,8 @@ export class PsycheEngine {
23
28
  storage;
24
29
  cfg;
25
30
  protocolCache = new Map();
31
+ /** Pending prediction from last processInput for auto-learning */
32
+ pendingPrediction = null;
26
33
  constructor(config = {}, storage) {
27
34
  this.storage = storage;
28
35
  this.cfg = {
@@ -41,6 +48,11 @@ export class PsycheEngine {
41
48
  async initialize() {
42
49
  const loaded = await this.storage.load();
43
50
  if (loaded) {
51
+ // Migrate v3 → v4: add learning state if missing
52
+ if (!loaded.learning) {
53
+ loaded.learning = { ...DEFAULT_LEARNING_STATE };
54
+ loaded.version = 4;
55
+ }
44
56
  this.state = loaded;
45
57
  }
46
58
  else {
@@ -56,6 +68,29 @@ export class PsycheEngine {
56
68
  */
57
69
  async processInput(text, opts) {
58
70
  let state = this.ensureInitialized();
71
+ // ── Auto-learning: evaluate previous turn's outcome ──────
72
+ if (this.pendingPrediction && text.length > 0) {
73
+ const nextClassifications = classifyStimulus(text);
74
+ const nextStimulus = (nextClassifications[0]?.confidence ?? 0) >= 0.5
75
+ ? nextClassifications[0].type
76
+ : null;
77
+ const outcome = evaluateOutcome(this.pendingPrediction.preInteractionState, state, nextStimulus, this.pendingPrediction.appliedStimulus);
78
+ // Record prediction accuracy
79
+ state = {
80
+ ...state,
81
+ learning: recordPrediction(state.learning, this.pendingPrediction.predictedChemistry, state.current, this.pendingPrediction.appliedStimulus),
82
+ };
83
+ // Update learned vectors based on outcome
84
+ if (this.pendingPrediction.appliedStimulus) {
85
+ state = {
86
+ ...state,
87
+ learning: updateLearnedVector(state.learning, this.pendingPrediction.appliedStimulus, this.pendingPrediction.contextHash, outcome.adaptiveScore, state.current, state.baseline),
88
+ };
89
+ }
90
+ this.pendingPrediction = null;
91
+ }
92
+ // ── Snapshot pre-interaction state for next turn's outcome evaluation
93
+ const preInteractionState = { ...state };
59
94
  // Time decay toward baseline (chemistry + drives)
60
95
  const now = new Date();
61
96
  const minutesElapsed = (now.getTime() - new Date(state.updatedAt).getTime()) / 60000;
@@ -124,6 +159,21 @@ export class PsycheEngine {
124
159
  ...state,
125
160
  meta: { ...state.meta, totalInteractions: state.meta.totalInteractions + 1 },
126
161
  };
162
+ // ── Generate prediction for next turn's auto-learning ────
163
+ if (appliedStimulus) {
164
+ const ctxHash = computeContextHash(state, opts?.userId);
165
+ const effectiveSensitivity = computeEffectiveSensitivity(getSensitivity(state.mbti), state.drives, appliedStimulus);
166
+ const predicted = predictChemistry(preInteractionState.current, appliedStimulus, state.learning, ctxHash, effectiveSensitivity, this.cfg.maxChemicalDelta);
167
+ this.pendingPrediction = {
168
+ predictedChemistry: predicted,
169
+ preInteractionState,
170
+ appliedStimulus,
171
+ contextHash: ctxHash,
172
+ };
173
+ }
174
+ else {
175
+ this.pendingPrediction = null;
176
+ }
127
177
  // Persist
128
178
  this.state = state;
129
179
  await this.storage.save(state);
@@ -185,6 +235,41 @@ export class PsycheEngine {
185
235
  }
186
236
  return { cleanedText, stateChanged };
187
237
  }
238
+ /**
239
+ * Phase 3 (optional): Explicitly evaluate the outcome of the last interaction.
240
+ *
241
+ * This is automatically called at the start of processInput, so most users
242
+ * don't need to call it manually. Use this for explicit outcome evaluation
243
+ * (e.g., when a session ends without a follow-up message).
244
+ *
245
+ * @param nextUserStimulus - The stimulus detected in the user's next message,
246
+ * or null if the session ended.
247
+ */
248
+ async processOutcome(nextUserStimulus, opts) {
249
+ if (!this.pendingPrediction)
250
+ return null;
251
+ let state = this.ensureInitialized();
252
+ const pending = this.pendingPrediction;
253
+ this.pendingPrediction = null;
254
+ const outcome = evaluateOutcome(pending.preInteractionState, state, nextUserStimulus, pending.appliedStimulus);
255
+ // Record prediction
256
+ state = {
257
+ ...state,
258
+ learning: recordPrediction(state.learning, pending.predictedChemistry, state.current, pending.appliedStimulus),
259
+ };
260
+ // Update learned vectors
261
+ let learningUpdated = false;
262
+ if (pending.appliedStimulus) {
263
+ state = {
264
+ ...state,
265
+ learning: updateLearnedVector(state.learning, pending.appliedStimulus, pending.contextHash, outcome.adaptiveScore, state.current, state.baseline),
266
+ };
267
+ learningUpdated = true;
268
+ }
269
+ this.state = state;
270
+ await this.storage.save(state);
271
+ return { outcomeScore: outcome, learningUpdated };
272
+ }
188
273
  /**
189
274
  * Get the current psyche state (read-only snapshot).
190
275
  */
@@ -216,7 +301,7 @@ export class PsycheEngine {
216
301
  const selfModel = getDefaultSelfModel(mbti);
217
302
  const now = new Date().toISOString();
218
303
  return {
219
- version: 3,
304
+ version: 4,
220
305
  mbti,
221
306
  baseline,
222
307
  current: { ...baseline },
@@ -228,6 +313,7 @@ export class PsycheEngine {
228
313
  emotionalHistory: [],
229
314
  agreementStreak: 0,
230
315
  lastDisagreement: null,
316
+ learning: { ...DEFAULT_LEARNING_STATE },
231
317
  meta: {
232
318
  agentName: name,
233
319
  createdAt: now,