psyche-ai 5.1.0 → 9.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
@@ -176,6 +176,73 @@ cd openclaw-plugin-psyche && node scripts/diagnose.js
176
176
 
177
177
  架构详情见 [ARCHITECTURE.md](ARCHITECTURE.md)。
178
178
 
179
+ ## 模式
180
+
181
+ | 模式 | 说明 | 适用场景 |
182
+ |------|------|---------|
183
+ | natural | 默认。完整情绪体验 | 大部分场景 |
184
+ | work | 最小情绪,专注任务 | 写代码、写文档 |
185
+ | companion | 强化情绪表达 | 闲聊、陪伴 |
186
+
187
+ ```bash
188
+ # 切换模式
189
+ psyche mode ./workspace work
190
+
191
+ # 初始化时设置模式
192
+ psyche init . --mode companion
193
+ ```
194
+
195
+ ## 性格强度
196
+
197
+ `personalityIntensity` 控制情绪表达的强烈程度(0.0-1.0):
198
+
199
+ - **0.0** = 传统温暖 AI,没有情绪波动
200
+ - **0.5** = 轻微情绪
201
+ - **0.7** = 默认
202
+ - **1.0** = 完整 Psyche 体验
203
+
204
+ ## Big Five 人格
205
+
206
+ 除了 MBTI 预设,你也可以用学术界更认可的 Big Five (大五人格) 来定义性格:
207
+
208
+ ```bash
209
+ psyche init . --traits "O:80,C:40,E:90,A:60,N:30"
210
+ ```
211
+
212
+ | 维度 | 含义 | 低值 | 高值 |
213
+ |------|------|------|------|
214
+ | O (Openness) | 开放性 | 保守、实际 | 好奇、想象力强 |
215
+ | C (Conscientiousness) | 尽责性 | 随性、灵活 | 严谨、有条理 |
216
+ | E (Extraversion) | 外向性 | 内向、安静 | 外向、精力充沛 |
217
+ | A (Agreeableness) | 宜人性 | 独立、直率 | 合作、温暖 |
218
+ | N (Neuroticism) | 神经质 | 情绪稳定 | 情绪敏感 |
219
+
220
+ ## 隐私
221
+
222
+ 情绪状态默认存储在本地 `psyche-state.json`。如果不想留任何痕迹:
223
+
224
+ ```bash
225
+ # 初始化时选择不持久化
226
+ psyche init . --no-persist
227
+ ```
228
+
229
+ 或者在代码中:
230
+
231
+ ```javascript
232
+ const engine = new PsycheEngine({ persist: false }, storage);
233
+ ```
234
+
235
+ 详细伦理声明见 [ETHICS.md](ETHICS.md)。
236
+
237
+ ## 商业模式
238
+
239
+ Psyche 核心引擎永久开源(MIT)。
240
+
241
+ 计划中的增值服务:
242
+ - **Psyche Cloud**:云端情绪状态同步 + 跨设备记忆
243
+ - **Psyche Pro Classifier**:基于微调模型的高精度刺激分类(替代正则)
244
+ - **企业定制**:自定义人格模型、合规审计、SLA 保障
245
+
179
246
  ## 开发
180
247
 
181
248
  ```bash
@@ -161,6 +161,13 @@ export function register(api) {
161
161
  return;
162
162
  const engine = engines.get(workspaceDir);
163
163
  if (engine) {
164
+ // Compress session history into relationship memory before closing
165
+ try {
166
+ await engine.endSession({ userId: ctx.userId });
167
+ }
168
+ catch (err) {
169
+ logger.warn(`Psyche: failed to compress session: ${err}`);
170
+ }
164
171
  const state = engine.getState();
165
172
  logger.info(`Psyche: session ended for ${state.meta.agentName}, ` +
166
173
  `chemistry saved (DA:${Math.round(state.current.DA)} ` +
@@ -0,0 +1,59 @@
1
+ import type { ChemicalState, InnateDrives, Locale, EnergyBudgets } from "./types.js";
2
+ export type AutonomicState = "ventral-vagal" | "sympathetic" | "dorsal-vagal";
3
+ export interface AutonomicResult {
4
+ state: AutonomicState;
5
+ transitionProgress: number;
6
+ gatedEmotionCategories: string[];
7
+ description: string;
8
+ processingDepth: number;
9
+ skippedStages: string[];
10
+ }
11
+ export interface AutonomicTransition {
12
+ from: AutonomicState;
13
+ to: AutonomicState;
14
+ transitionMinutes: number;
15
+ }
16
+ /**
17
+ * Compute the raw autonomic state from chemistry and drives.
18
+ * No transition inertia — returns the "target" state.
19
+ */
20
+ export declare function computeAutonomicState(chemistry: ChemicalState, drives: InnateDrives): AutonomicState;
21
+ /**
22
+ * Gate emotions based on autonomic state.
23
+ * - Ventral vagal: all emotions pass through
24
+ * - Sympathetic: blocks positive social emotions
25
+ * - Dorsal vagal: only allows numbness/introspection/burnout (whitelist)
26
+ */
27
+ export declare function gateEmotions(autonomicState: AutonomicState, emotions: string[]): string[];
28
+ /**
29
+ * Get the transition time in minutes between two autonomic states.
30
+ * Asymmetric: activation is faster than recovery.
31
+ */
32
+ export declare function getTransitionTime(from: AutonomicState, to: AutonomicState): number;
33
+ /**
34
+ * Describe an autonomic state in the given locale.
35
+ */
36
+ export declare function describeAutonomicState(state: AutonomicState, locale: Locale): string;
37
+ /**
38
+ * P10: Compute processing depth from autonomic state and chemistry.
39
+ *
40
+ * Processing depth represents the cognitive resource available for reflection:
41
+ * - 0 = pure System 1 (intuition only, no metacognition)
42
+ * - 1 = full System 2 (complete reflective capacity)
43
+ *
44
+ * This is a natural extension of autonomic state: you can't deeply reflect
45
+ * when your nervous system is in fight/flight/freeze mode.
46
+ */
47
+ export declare function computeProcessingDepth(autonomicState: AutonomicState, chemistry: ChemicalState, baseline: ChemicalState, energyBudgets?: EnergyBudgets): {
48
+ depth: number;
49
+ skippedStages: string[];
50
+ };
51
+ /**
52
+ * Compute the full autonomic result with transition inertia.
53
+ *
54
+ * If previousState differs from the target state, transition progress
55
+ * is based on elapsed time vs required transition time.
56
+ *
57
+ * Includes P10 processing depth (dual-process cognitive gating).
58
+ */
59
+ export declare function computeAutonomicResult(chemistry: ChemicalState, drives: InnateDrives, previousState: AutonomicState | null, minutesSinceLastUpdate: number, locale?: Locale, baseline?: ChemicalState, energyBudgets?: EnergyBudgets): AutonomicResult;
@@ -0,0 +1,239 @@
1
+ // ============================================================
2
+ // Autonomic Nervous System — Polyvagal Theory Implementation
3
+ // ============================================================
4
+ //
5
+ // Maps chemical state + innate drives to autonomic nervous system
6
+ // states based on Stephen Porges' Polyvagal Theory:
7
+ //
8
+ // - Ventral vagal: social engagement, safety (default)
9
+ // - Sympathetic: fight/flight mobilization
10
+ // - Dorsal vagal: freeze/shutdown/collapse
11
+ // ── i18n Strings ─────────────────────────────────────────────
12
+ const AUTONOMIC_STRINGS = {
13
+ zh: {
14
+ "ventral-vagal": "腹侧迷走神经激活——安全与社交参与状态,情绪开放,表达自然",
15
+ "sympathetic": "交感神经激活——警觉动员状态,战斗或逃跑准备中",
16
+ "dorsal-vagal": "背侧迷走神经激活——冻结与保护性关闭状态,能量极低",
17
+ },
18
+ en: {
19
+ "ventral-vagal": "Ventral vagal activation — safe and socially engaged, emotionally open",
20
+ "sympathetic": "Sympathetic activation — alert and mobilized, fight-or-flight readiness",
21
+ "dorsal-vagal": "Dorsal vagal activation — freeze and protective shutdown, minimal energy",
22
+ },
23
+ };
24
+ // ── Transition Time Matrix (minutes) ─────────────────────────
25
+ const TRANSITION_TIMES = {
26
+ "ventral-vagal": {
27
+ "ventral-vagal": 0,
28
+ "sympathetic": 2, // fast activation
29
+ "dorsal-vagal": 7, // not a shortcut: >= ventral→sympathetic + sympathetic→dorsal (2+5=7)
30
+ },
31
+ "sympathetic": {
32
+ "ventral-vagal": 8, // calming down
33
+ "sympathetic": 0,
34
+ "dorsal-vagal": 5, // collapse
35
+ },
36
+ "dorsal-vagal": {
37
+ "ventral-vagal": 25, // full recovery is slow
38
+ "sympathetic": 12, // re-mobilization from freeze
39
+ "dorsal-vagal": 0,
40
+ },
41
+ };
42
+ // ── Emotion Gating ───────────────────────────────────────────
43
+ /** Positive social emotions blocked during sympathetic activation */
44
+ const SYMPATHETIC_BLOCKED = new Set([
45
+ "deep contentment",
46
+ "warm intimacy",
47
+ "playful mischief",
48
+ "excited joy",
49
+ "tender affection",
50
+ "serene peace",
51
+ "grateful warmth",
52
+ "compassionate care",
53
+ ]);
54
+ /** Emotions allowed during dorsal-vagal (whitelist) */
55
+ const DORSAL_ALLOWED = new Set([
56
+ "emotional numbness",
57
+ "melancholic introspection",
58
+ "burnout",
59
+ "resignation",
60
+ "dissociation",
61
+ "exhaustion",
62
+ ]);
63
+ // ── Core Functions ───────────────────────────────────────────
64
+ /**
65
+ * Compute the raw autonomic state from chemistry and drives.
66
+ * No transition inertia — returns the "target" state.
67
+ */
68
+ export function computeAutonomicState(chemistry, drives) {
69
+ const { CORT, NE, DA, HT, OT } = chemistry;
70
+ const { survival, safety, connection } = drives;
71
+ // Count drives that are critically low (< 20)
72
+ const lowDriveCount = [survival, safety, connection, drives.esteem, drives.curiosity]
73
+ .filter((d) => d < 20).length;
74
+ // ── Dorsal vagal check (freeze/shutdown) ──
75
+ // Very high stress + low arousal + low motivation = collapse
76
+ if (CORT >= 80 && NE <= 25 && DA <= 20) {
77
+ return "dorsal-vagal";
78
+ }
79
+ // Multiple critically low drives with depleted chemistry
80
+ if (lowDriveCount >= 3 && CORT >= 70 && (NE <= 30 || DA <= 20)) {
81
+ return "dorsal-vagal";
82
+ }
83
+ // ── Sympathetic check (fight/flight) ──
84
+ // High stress + high arousal
85
+ if (CORT >= 70 && NE >= 70) {
86
+ return "sympathetic";
87
+ }
88
+ // Very low survival or safety drive with elevated stress
89
+ if ((survival < 20 || safety < 20) && CORT >= 60 && NE >= 60) {
90
+ return "sympathetic";
91
+ }
92
+ // ── Default: Ventral vagal (social engagement/safety) ──
93
+ return "ventral-vagal";
94
+ }
95
+ /**
96
+ * Gate emotions based on autonomic state.
97
+ * - Ventral vagal: all emotions pass through
98
+ * - Sympathetic: blocks positive social emotions
99
+ * - Dorsal vagal: only allows numbness/introspection/burnout (whitelist)
100
+ */
101
+ export function gateEmotions(autonomicState, emotions) {
102
+ if (autonomicState === "ventral-vagal") {
103
+ return emotions;
104
+ }
105
+ if (autonomicState === "sympathetic") {
106
+ return emotions.filter((e) => !SYMPATHETIC_BLOCKED.has(e));
107
+ }
108
+ // dorsal-vagal: whitelist only
109
+ return emotions.filter((e) => DORSAL_ALLOWED.has(e));
110
+ }
111
+ /**
112
+ * Get the transition time in minutes between two autonomic states.
113
+ * Asymmetric: activation is faster than recovery.
114
+ */
115
+ export function getTransitionTime(from, to) {
116
+ return TRANSITION_TIMES[from][to];
117
+ }
118
+ /**
119
+ * Describe an autonomic state in the given locale.
120
+ */
121
+ export function describeAutonomicState(state, locale) {
122
+ return AUTONOMIC_STRINGS[locale]?.[state] ?? AUTONOMIC_STRINGS.zh[state];
123
+ }
124
+ /**
125
+ * P10: Compute processing depth from autonomic state and chemistry.
126
+ *
127
+ * Processing depth represents the cognitive resource available for reflection:
128
+ * - 0 = pure System 1 (intuition only, no metacognition)
129
+ * - 1 = full System 2 (complete reflective capacity)
130
+ *
131
+ * This is a natural extension of autonomic state: you can't deeply reflect
132
+ * when your nervous system is in fight/flight/freeze mode.
133
+ */
134
+ export function computeProcessingDepth(autonomicState, chemistry, baseline, energyBudgets) {
135
+ const { DA, HT, CORT, OT, NE, END } = chemistry;
136
+ // Chemical deviation from baseline (0-1)
137
+ let totalDeviation = 0;
138
+ const keys = ["DA", "HT", "CORT", "OT", "NE", "END"];
139
+ for (const k of keys) {
140
+ totalDeviation += Math.abs(chemistry[k] - baseline[k]);
141
+ }
142
+ const chemDeviation = Math.min(1, totalDeviation / 600);
143
+ // Base depth from autonomic state
144
+ let baseDepth;
145
+ if (autonomicState === "dorsal-vagal") {
146
+ baseDepth = 0;
147
+ }
148
+ else if (autonomicState === "sympathetic") {
149
+ // Higher CORT in sympathetic = less cognitive resource
150
+ baseDepth = CORT >= 60 ? 0.15 : 0.35;
151
+ }
152
+ else {
153
+ // ventral-vagal: safe, most cognitive resource available
154
+ baseDepth = 0.85;
155
+ }
156
+ // Chemical deviation reduces depth (strong emotions = less reflection)
157
+ let depth = Math.max(0, Math.min(1, baseDepth * (1 - chemDeviation * 0.5)));
158
+ // v9: Low attention energy further reduces processing depth
159
+ if (energyBudgets && energyBudgets.attention < 30) {
160
+ const attentionPenalty = (30 - energyBudgets.attention) / 30 * 0.3;
161
+ depth = Math.max(0, depth - attentionPenalty);
162
+ }
163
+ // Map depth to skipped pipeline stages
164
+ const skippedStages = [];
165
+ if (depth < 0.8)
166
+ skippedStages.push("generative-self");
167
+ if (depth < 0.5) {
168
+ skippedStages.push("ethics");
169
+ skippedStages.push("shared-intentionality");
170
+ }
171
+ if (depth < 0.2) {
172
+ skippedStages.push("metacognition");
173
+ skippedStages.push("experiential-field");
174
+ }
175
+ return { depth, skippedStages };
176
+ }
177
+ /**
178
+ * Compute the full autonomic result with transition inertia.
179
+ *
180
+ * If previousState differs from the target state, transition progress
181
+ * is based on elapsed time vs required transition time.
182
+ *
183
+ * Includes P10 processing depth (dual-process cognitive gating).
184
+ */
185
+ export function computeAutonomicResult(chemistry, drives, previousState, minutesSinceLastUpdate, locale = "zh", baseline, energyBudgets) {
186
+ const targetState = computeAutonomicState(chemistry, drives);
187
+ const effectiveBaseline = baseline ?? { DA: 50, HT: 50, CORT: 50, OT: 50, NE: 50, END: 50 };
188
+ // Helper to build full result with processing depth
189
+ const buildResult = (state, transitionProgress) => {
190
+ const { depth, skippedStages } = computeProcessingDepth(state, chemistry, effectiveBaseline, energyBudgets);
191
+ return {
192
+ state,
193
+ transitionProgress,
194
+ gatedEmotionCategories: getGatedCategories(state),
195
+ description: describeAutonomicState(state, locale),
196
+ processingDepth: depth,
197
+ skippedStages,
198
+ };
199
+ };
200
+ // First call or same state — immediate
201
+ if (previousState === null || previousState === targetState) {
202
+ return buildResult(targetState, 1);
203
+ }
204
+ // Transitioning between states
205
+ const transitionTime = getTransitionTime(previousState, targetState);
206
+ const progress = transitionTime === 0
207
+ ? 1
208
+ : Math.min(1, minutesSinceLastUpdate / transitionTime);
209
+ // If transition is complete, use the new state
210
+ if (progress >= 1) {
211
+ return buildResult(targetState, 1);
212
+ }
213
+ // Transition in progress
214
+ return buildResult(targetState, progress);
215
+ }
216
+ // ── Internal Helpers ─────────────────────────────────────────
217
+ /** Get the list of emotion categories that are blocked/gated for a state */
218
+ function getGatedCategories(state) {
219
+ if (state === "ventral-vagal") {
220
+ return [];
221
+ }
222
+ if (state === "sympathetic") {
223
+ return [...SYMPATHETIC_BLOCKED];
224
+ }
225
+ // dorsal-vagal gates everything except the whitelist
226
+ return [
227
+ "excited joy",
228
+ "warm intimacy",
229
+ "playful mischief",
230
+ "deep contentment",
231
+ "focused alertness",
232
+ "righteous anger",
233
+ "anxious tension",
234
+ "tender affection",
235
+ "serene peace",
236
+ "grateful warmth",
237
+ "compassionate care",
238
+ ];
239
+ }
@@ -8,7 +8,7 @@ export declare function clamp(v: number): number;
8
8
  *
9
9
  * decayed = baseline + (current - baseline) * factor^(minutes/60)
10
10
  */
11
- export declare function applyDecay(current: ChemicalState, baseline: ChemicalState, minutesElapsed: number): ChemicalState;
11
+ export declare function applyDecay(current: ChemicalState, baseline: ChemicalState, minutesElapsed: number, decayRateModifiers?: Partial<Record<keyof ChemicalState, number>>): ChemicalState;
12
12
  /**
13
13
  * Apply a stimulus to the current state.
14
14
  * Respects emotional inertia (maxDelta) and personality sensitivity.
@@ -16,7 +16,7 @@ export declare function applyDecay(current: ChemicalState, baseline: ChemicalSta
16
16
  */
17
17
  export declare function applyStimulus(current: ChemicalState, stimulus: StimulusType, sensitivity: number, maxDelta: number, logger?: {
18
18
  warn: (msg: string) => void;
19
- }): ChemicalState;
19
+ }, recentSameCount?: number): ChemicalState;
20
20
  /**
21
21
  * Apply emotional contagion: the detected user emotion partially
22
22
  * influences the agent's chemistry.
package/dist/chemistry.js CHANGED
@@ -133,13 +133,22 @@ export function clamp(v) {
133
133
  *
134
134
  * decayed = baseline + (current - baseline) * factor^(minutes/60)
135
135
  */
136
- export function applyDecay(current, baseline, minutesElapsed) {
136
+ export function applyDecay(current, baseline, minutesElapsed, decayRateModifiers) {
137
137
  if (minutesElapsed <= 0)
138
138
  return { ...current };
139
139
  const result = { ...current };
140
140
  for (const key of CHEMICAL_KEYS) {
141
141
  const speed = CHEMICAL_DECAY_SPEED[key];
142
- const factor = Math.pow(DECAY_FACTORS[speed], minutesElapsed / 60);
142
+ const baseFactor = Math.pow(DECAY_FACTORS[speed], minutesElapsed / 60);
143
+ // v9: decayRateModifiers alter decay speed per chemical
144
+ // > 1 = slower recovery (trauma: factor closer to 1)
145
+ // < 1 = faster recovery (resilience: factor closer to 0)
146
+ let factor = baseFactor;
147
+ if (decayRateModifiers?.[key] !== undefined) {
148
+ const mod = decayRateModifiers[key];
149
+ // Raise the factor to the modifier power: mod>1 → slower decay, mod<1 → faster
150
+ factor = Math.pow(baseFactor, 1 / mod);
151
+ }
143
152
  result[key] = clamp(baseline[key] + (current[key] - baseline[key]) * factor);
144
153
  }
145
154
  return result;
@@ -149,15 +158,21 @@ export function applyDecay(current, baseline, minutesElapsed) {
149
158
  * Respects emotional inertia (maxDelta) and personality sensitivity.
150
159
  * Logs a warning for unknown stimulus types.
151
160
  */
152
- export function applyStimulus(current, stimulus, sensitivity, maxDelta, logger) {
161
+ export function applyStimulus(current, stimulus, sensitivity, maxDelta, logger, recentSameCount) {
153
162
  const vector = STIMULUS_VECTORS[stimulus];
154
163
  if (!vector) {
155
164
  logger?.warn(t("log.unknown_stimulus", "zh", { type: stimulus }));
156
165
  return { ...current };
157
166
  }
167
+ // v9: Habituation — Weber-Fechner diminishing returns
168
+ // First 2 exposures: 100%. 3rd: 77%, 5th: 53%, 10th: 29%
169
+ let effectiveSensitivity = sensitivity;
170
+ if (recentSameCount !== undefined && recentSameCount > 2) {
171
+ effectiveSensitivity *= 1 / (1 + 0.3 * (recentSameCount - 2));
172
+ }
158
173
  const result = { ...current };
159
174
  for (const key of CHEMICAL_KEYS) {
160
- const raw = vector[key] * sensitivity;
175
+ const raw = vector[key] * effectiveSensitivity;
161
176
  const clamped = Math.max(-maxDelta, Math.min(maxDelta, raw));
162
177
  result[key] = clamp(current[key] + clamped);
163
178
  }
@@ -0,0 +1,55 @@
1
+ import type { ChemicalState, EnergyBudgets, StimulusType } from "./types.js";
2
+ export type CircadianPhase = "morning" | "midday" | "afternoon" | "evening" | "night";
3
+ /**
4
+ * Classify a time into a circadian phase.
5
+ * morning: 6–9
6
+ * midday: 10–13
7
+ * afternoon: 14–17
8
+ * evening: 18–21
9
+ * night: 22–5
10
+ */
11
+ export declare function getCircadianPhase(time: Date): CircadianPhase;
12
+ /**
13
+ * Apply circadian rhythm modulation to baseline chemistry.
14
+ *
15
+ * Each chemical follows a sinusoidal daily curve:
16
+ * CORT — peaks ~8am, amplitude ±8
17
+ * HT — peaks ~13 (daytime high), amplitude ±5
18
+ * DA — slight afternoon peak ~14, amplitude ±3
19
+ * NE — morning rise ~10, amplitude ±5
20
+ * END — evening rise ~20, amplitude ±3
21
+ * OT — evening warmth ~20, amplitude ±2
22
+ *
23
+ * All results clamped to [0, 100].
24
+ */
25
+ export declare function computeCircadianModulation(currentTime: Date, baseline: ChemicalState): ChemicalState;
26
+ /**
27
+ * Compute fatigue effects from extended session duration.
28
+ *
29
+ * Below 30 minutes: no pressure (grace period).
30
+ * Beyond 30 min: logarithmic growth (diminishing returns).
31
+ * All values non-negative.
32
+ */
33
+ export declare function computeHomeostaticPressure(sessionMinutes: number): {
34
+ cortAccumulation: number;
35
+ daDepletion: number;
36
+ neDepletion: number;
37
+ };
38
+ /**
39
+ * Deplete energy budgets from a single interaction turn.
40
+ *
41
+ * - Attention: -3/turn base, extra for intellectual/conflict
42
+ * - Social energy: extraverts +2/turn (charging), introverts -3/turn (draining)
43
+ * - Decision capacity: varies by stimulus complexity
44
+ *
45
+ * Extraverts can exceed 100 (up to 120 — "supercharged").
46
+ */
47
+ export declare function computeEnergyDepletion(budgets: EnergyBudgets, stimulus: StimulusType | null, isExtravert: boolean): EnergyBudgets;
48
+ /**
49
+ * Recover energy budgets during absence (between sessions or long pauses).
50
+ *
51
+ * - Attention: +20/hour
52
+ * - Social energy: extraverts -3/hour (drain when alone), introverts +15/hour (recharge)
53
+ * - Decision capacity: +25/hour
54
+ */
55
+ export declare function computeEnergyRecovery(budgets: EnergyBudgets, minutesElapsed: number, isExtravert: boolean): EnergyBudgets;
@@ -0,0 +1,163 @@
1
+ // ============================================================
2
+ // Artificial Psyche — Circadian Rhythm Module
3
+ // ============================================================
4
+ // Applies time-of-day modulation to virtual neurochemistry,
5
+ // modeling the body's ~24-hour biological clock and fatigue
6
+ // from extended sessions (homeostatic pressure).
7
+ // ============================================================
8
+ /**
9
+ * Classify a time into a circadian phase.
10
+ * morning: 6–9
11
+ * midday: 10–13
12
+ * afternoon: 14–17
13
+ * evening: 18–21
14
+ * night: 22–5
15
+ */
16
+ export function getCircadianPhase(time) {
17
+ const h = time.getHours();
18
+ if (h >= 6 && h <= 9)
19
+ return "morning";
20
+ if (h >= 10 && h <= 13)
21
+ return "midday";
22
+ if (h >= 14 && h <= 17)
23
+ return "afternoon";
24
+ if (h >= 18 && h <= 21)
25
+ return "evening";
26
+ return "night";
27
+ }
28
+ // ── Sinusoidal Helpers ───────────────────────────────────────
29
+ /** Convert hour (0-23) + minute to fractional hours */
30
+ function fractionalHour(time) {
31
+ return time.getHours() + time.getMinutes() / 60;
32
+ }
33
+ /**
34
+ * Sinusoidal modulation: amplitude * cos(2π(t - peak) / 24)
35
+ * Returns value in [-amplitude, +amplitude], peaking at `peakHour`.
36
+ */
37
+ function sinMod(t, peakHour, amplitude) {
38
+ const phase = ((t - peakHour) / 24) * 2 * Math.PI;
39
+ return amplitude * Math.cos(phase);
40
+ }
41
+ // ── Circadian Modulation ─────────────────────────────────────
42
+ /**
43
+ * Apply circadian rhythm modulation to baseline chemistry.
44
+ *
45
+ * Each chemical follows a sinusoidal daily curve:
46
+ * CORT — peaks ~8am, amplitude ±8
47
+ * HT — peaks ~13 (daytime high), amplitude ±5
48
+ * DA — slight afternoon peak ~14, amplitude ±3
49
+ * NE — morning rise ~10, amplitude ±5
50
+ * END — evening rise ~20, amplitude ±3
51
+ * OT — evening warmth ~20, amplitude ±2
52
+ *
53
+ * All results clamped to [0, 100].
54
+ */
55
+ export function computeCircadianModulation(currentTime, baseline) {
56
+ const t = fractionalHour(currentTime);
57
+ const cortDelta = sinMod(t, 8, 8);
58
+ const htDelta = sinMod(t, 13, 5);
59
+ const daDelta = sinMod(t, 14, 3);
60
+ const neDelta = sinMod(t, 10, 5);
61
+ const endDelta = sinMod(t, 20, 3);
62
+ const otDelta = sinMod(t, 20, 2);
63
+ return {
64
+ DA: clamp(baseline.DA + daDelta),
65
+ HT: clamp(baseline.HT + htDelta),
66
+ CORT: clamp(baseline.CORT + cortDelta),
67
+ OT: clamp(baseline.OT + otDelta),
68
+ NE: clamp(baseline.NE + neDelta),
69
+ END: clamp(baseline.END + endDelta),
70
+ };
71
+ }
72
+ function clamp(v, lo = 0, hi = 100) {
73
+ return Math.max(lo, Math.min(hi, v));
74
+ }
75
+ // ── Homeostatic Pressure ─────────────────────────────────────
76
+ /**
77
+ * Compute fatigue effects from extended session duration.
78
+ *
79
+ * Below 30 minutes: no pressure (grace period).
80
+ * Beyond 30 min: logarithmic growth (diminishing returns).
81
+ * All values non-negative.
82
+ */
83
+ export function computeHomeostaticPressure(sessionMinutes) {
84
+ if (sessionMinutes < 30) {
85
+ return { cortAccumulation: 0, daDepletion: 0, neDepletion: 0 };
86
+ }
87
+ // Effective minutes beyond the grace period
88
+ const effective = sessionMinutes - 30;
89
+ // Logarithmic growth → diminishing returns
90
+ // ln(1 + x) grows slowly; scale factors tuned so 1h ≈ moderate, 10h ≈ high but bounded
91
+ const base = Math.log1p(effective / 30); // ln(1 + effective/30)
92
+ return {
93
+ cortAccumulation: parseFloat((base * 4).toFixed(4)),
94
+ daDepletion: parseFloat((base * 3).toFixed(4)),
95
+ neDepletion: parseFloat((base * 2.5).toFixed(4)),
96
+ };
97
+ }
98
+ // ── Energy Budgets (v9) ─────────────────────────────────────
99
+ // Finite cognitive/social resources that deplete during interaction.
100
+ // Extraverts GAIN social energy from interaction; introverts LOSE it.
101
+ /** Stimulus-specific attention costs (higher = more draining) */
102
+ const ATTENTION_COSTS = {
103
+ intellectual: 5,
104
+ conflict: 5,
105
+ authority: 4,
106
+ vulnerability: 3,
107
+ sarcasm: 3,
108
+ criticism: 3,
109
+ surprise: 2,
110
+ };
111
+ /** Stimulus-specific decision costs */
112
+ const DECISION_COSTS = {
113
+ conflict: 4,
114
+ authority: 4,
115
+ vulnerability: 3,
116
+ criticism: 2,
117
+ sarcasm: 2,
118
+ };
119
+ /**
120
+ * Deplete energy budgets from a single interaction turn.
121
+ *
122
+ * - Attention: -3/turn base, extra for intellectual/conflict
123
+ * - Social energy: extraverts +2/turn (charging), introverts -3/turn (draining)
124
+ * - Decision capacity: varies by stimulus complexity
125
+ *
126
+ * Extraverts can exceed 100 (up to 120 — "supercharged").
127
+ */
128
+ export function computeEnergyDepletion(budgets, stimulus, isExtravert) {
129
+ const extravertMax = 120;
130
+ const introvertMax = 100;
131
+ const max = isExtravert ? extravertMax : introvertMax;
132
+ // Attention: base -3, extra from stimulus
133
+ const attentionCost = 3 + (stimulus ? (ATTENTION_COSTS[stimulus] ?? 0) : 0);
134
+ const attention = clamp(budgets.attention - attentionCost, 0, 100);
135
+ // Social energy: E charges, I drains
136
+ const socialDelta = isExtravert ? 2 : -3;
137
+ const socialEnergy = clamp(budgets.socialEnergy + socialDelta, 0, max);
138
+ // Decision capacity: base -1, extra from stimulus
139
+ const decisionCost = 1 + (stimulus ? (DECISION_COSTS[stimulus] ?? 0) : 0);
140
+ const decisionCapacity = clamp(budgets.decisionCapacity - decisionCost, 0, 100);
141
+ return { attention, socialEnergy, decisionCapacity };
142
+ }
143
+ /**
144
+ * Recover energy budgets during absence (between sessions or long pauses).
145
+ *
146
+ * - Attention: +20/hour
147
+ * - Social energy: extraverts -3/hour (drain when alone), introverts +15/hour (recharge)
148
+ * - Decision capacity: +25/hour
149
+ */
150
+ export function computeEnergyRecovery(budgets, minutesElapsed, isExtravert) {
151
+ if (minutesElapsed <= 0)
152
+ return { ...budgets };
153
+ const hours = minutesElapsed / 60;
154
+ const extravertMax = 120;
155
+ const introvertMax = 100;
156
+ const max = isExtravert ? extravertMax : introvertMax;
157
+ const attention = clamp(budgets.attention + hours * 20, 0, 100);
158
+ // E drains alone, I recharges alone
159
+ const socialDelta = isExtravert ? -3 * hours : 15 * hours;
160
+ const socialEnergy = clamp(budgets.socialEnergy + socialDelta, 0, max);
161
+ const decisionCapacity = clamp(budgets.decisionCapacity + hours * 25, 0, 100);
162
+ return { attention, socialEnergy, decisionCapacity };
163
+ }