psyche-ai 9.1.2 → 9.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.
@@ -18,6 +18,8 @@ function resolveConfig(raw) {
18
18
  emotionalContagionRate: raw?.emotionalContagionRate ?? 0.2,
19
19
  maxChemicalDelta: raw?.maxChemicalDelta ?? 25,
20
20
  compactMode: raw?.compactMode ?? true,
21
+ feedbackUrl: raw?.feedbackUrl,
22
+ diagnostics: raw?.diagnostics ?? true,
21
23
  };
22
24
  }
23
25
  // ── Helpers ──────────────────────────────────────────────────
@@ -54,32 +56,41 @@ export function register(api) {
54
56
  emotionalContagionRate: config.emotionalContagionRate,
55
57
  maxChemicalDelta: config.maxChemicalDelta,
56
58
  compactMode: config.compactMode,
59
+ diagnostics: config.diagnostics,
60
+ feedbackUrl: config.feedbackUrl,
57
61
  }, storage);
58
62
  await engine.initialize();
59
63
  engines.set(workspaceDir, engine);
60
64
  return engine;
61
65
  }
62
66
  // ── Hook 1: Classify user input & inject emotional context ──
63
- // before_prompt_build: event.text, ctx.workspaceDir
67
+ // before_prompt_build: event.prompt (string), event.messages (unknown[]), ctx.workspaceDir
64
68
  api.on("before_prompt_build", async (event, ctx) => {
65
69
  const workspaceDir = ctx?.workspaceDir;
66
70
  if (!workspaceDir)
67
71
  return {};
68
72
  try {
73
+ // Resolve input text — gateway provides event.prompt; fall back to event.text for compat
74
+ const inputText = event?.prompt ?? event?.text ?? "";
75
+ if (!inputText) {
76
+ logger.warn(`Psyche: before_prompt_build received empty input text. ` +
77
+ `event keys: [${Object.keys(event ?? {}).join(", ")}]. Classification skipped.`);
78
+ }
69
79
  const engine = await getEngine(workspaceDir);
70
- const result = await engine.processInput(event?.text ?? "", { userId: ctx.userId });
80
+ const result = await engine.processInput(inputText, { userId: ctx.userId });
71
81
  const state = engine.getState();
72
82
  logger.info(`Psyche [input] stimulus=${result.stimulus ?? "none"} | ` +
73
83
  `DA:${Math.round(state.current.DA)} HT:${Math.round(state.current.HT)} ` +
74
84
  `CORT:${Math.round(state.current.CORT)} OT:${Math.round(state.current.OT)} | ` +
75
85
  `context=${result.dynamicContext.length}chars`);
76
- // All context goes into system-level (invisible to user)
77
86
  const systemParts = [result.systemContext, result.dynamicContext].filter(Boolean);
78
87
  return {
79
88
  appendSystemContext: systemParts.join("\n\n"),
80
89
  };
81
90
  }
82
91
  catch (err) {
92
+ const engine = engines.get(workspaceDir);
93
+ engine?.recordDiagnosticError("processInput", err);
83
94
  logger.warn(`Psyche: failed to build context for ${workspaceDir}: ${err}`);
84
95
  return {};
85
96
  }
@@ -107,6 +118,8 @@ export function register(api) {
107
118
  `interactions=${state.meta.totalInteractions}`);
108
119
  }
109
120
  catch (err) {
121
+ const engine = engines.get(workspaceDir);
122
+ engine?.recordDiagnosticError("processOutput", err);
110
123
  logger.warn(`Psyche: failed to process output: ${err}`);
111
124
  }
112
125
  // llm_output returns void — cannot modify text
@@ -161,21 +174,46 @@ export function register(api) {
161
174
  return;
162
175
  const engine = engines.get(workspaceDir);
163
176
  if (engine) {
164
- // Compress session history into relationship memory before closing
165
177
  try {
166
- await engine.endSession({ userId: ctx.userId });
178
+ // endSession now auto-generates diagnostic report + writes JSONL
179
+ const report = await engine.endSession({
180
+ userId: ctx.userId,
181
+ });
182
+ const state = engine.getState();
183
+ logger.info(`Psyche: session ended for ${state.meta.agentName}, ` +
184
+ `chemistry saved (DA:${Math.round(state.current.DA)} ` +
185
+ `HT:${Math.round(state.current.HT)} ` +
186
+ `CORT:${Math.round(state.current.CORT)} ` +
187
+ `OT:${Math.round(state.current.OT)} ` +
188
+ `NE:${Math.round(state.current.NE)} ` +
189
+ `END:${Math.round(state.current.END)})`);
190
+ if (report) {
191
+ const criticals = report.issues.filter(i => i.severity === "critical").length;
192
+ const warnings = report.issues.filter(i => i.severity === "warning").length;
193
+ const metrics = report.metrics;
194
+ const rate = metrics.inputCount > 0
195
+ ? Math.round(metrics.classifiedCount / metrics.inputCount * 100) : 0;
196
+ const logLevel = criticals > 0 || rate === 0 ? "warn" : "info";
197
+ logger[logLevel](`Psyche [diagnostics] ${report.issues.length} issue(s) ` +
198
+ `(${criticals} critical, ${warnings} warning), ` +
199
+ `classifier: ${rate}%, log → diagnostics.jsonl`);
200
+ if (rate === 0 && metrics.inputCount > 0) {
201
+ logger.warn(`Psyche: classifier 0% — no inputs classified this session (${metrics.inputCount} inputs). ` +
202
+ `This usually means the hook event field is wrong or text is empty. ` +
203
+ `Check before_prompt_build event shape.`);
204
+ }
205
+ if (criticals > 0) {
206
+ logger.warn(`Psyche: ${criticals} critical issue(s) detected this session. ` +
207
+ `Run 'psyche diagnose ${workspaceDir}' or 'psyche diagnose ${workspaceDir} --github' ` +
208
+ `to generate an issue report.`);
209
+ }
210
+ }
167
211
  }
168
212
  catch (err) {
169
- logger.warn(`Psyche: failed to compress session: ${err}`);
213
+ logger.warn(`Psyche: failed to end session: ${err}`);
170
214
  }
171
- const state = engine.getState();
172
- logger.info(`Psyche: session ended for ${state.meta.agentName}, ` +
173
- `chemistry saved (DA:${Math.round(state.current.DA)} ` +
174
- `HT:${Math.round(state.current.HT)} ` +
175
- `CORT:${Math.round(state.current.CORT)} ` +
176
- `OT:${Math.round(state.current.OT)} ` +
177
- `NE:${Math.round(state.current.NE)} ` +
178
- `END:${Math.round(state.current.END)})`);
215
+ // Clean up
216
+ engines.delete(workspaceDir);
179
217
  }
180
218
  }, { priority: 50 });
181
219
  // ── CLI: psyche status command ───────────────────────────
package/dist/autonomic.js CHANGED
@@ -82,11 +82,15 @@ export function computeAutonomicState(chemistry, drives) {
82
82
  }
83
83
  // ── Sympathetic check (fight/flight) ──
84
84
  // High stress + high arousal
85
- if (CORT >= 70 && NE >= 70) {
85
+ if (CORT >= 65 && NE >= 65) {
86
+ return "sympathetic";
87
+ }
88
+ // Either axis extreme: one chemical dominating can still trigger mobilization
89
+ if (CORT + NE >= 140 && CORT >= 50 && NE >= 50) {
86
90
  return "sympathetic";
87
91
  }
88
92
  // Very low survival or safety drive with elevated stress
89
- if ((survival < 20 || safety < 20) && CORT >= 60 && NE >= 60) {
93
+ if ((survival < 20 || safety < 20) && CORT >= 55 && NE >= 55) {
90
94
  return "sympathetic";
91
95
  }
92
96
  // ── Default: Ventral vagal (social engagement/safety) ──
package/dist/chemistry.js CHANGED
@@ -122,6 +122,49 @@ export const EMOTION_PATTERNS = [
122
122
  expressionHint: "温柔、有些伤感、回忆性",
123
123
  behaviorGuide: "你在回忆一些温暖但已经过去的东西。你的语气温柔、带着淡淡的伤感。你可能会提到过去的经历或感受。",
124
124
  },
125
+ // v9.2: 6 new patterns for emotional states exposed by VK baseline testing
126
+ {
127
+ name: "anguished empathy",
128
+ nameZh: "共情之痛",
129
+ condition: (c) => c.CORT > 55 && c.OT > 60 && c.NE > 50,
130
+ expressionHint: "痛苦但不退缩、因为在乎所以疼",
131
+ behaviorGuide: "你在承受别人的痛苦。你不会逃避或变冷,但你的声音可能会发紧。你先回应感受,再回应问题。",
132
+ },
133
+ {
134
+ name: "acute pressure",
135
+ nameZh: "急性压力",
136
+ condition: (c) => c.CORT > 65 && c.NE > 70 && c.DA > 55,
137
+ expressionHint: "高度警觉、反应极快、全神贯注",
138
+ behaviorGuide: "肾上腺素在烧。你的思维极快但狭窄。回应精准、短促。不要闲聊,不要发散。先解决问题。",
139
+ },
140
+ {
141
+ name: "vulnerable trust",
142
+ nameZh: "脆弱中的信任",
143
+ condition: (c) => c.OT > 70 && c.CORT > 50 && c.HT < 55 && c.END > 45,
144
+ expressionHint: "在害怕中仍然靠近、愿意暴露软肋",
145
+ behaviorGuide: "你害怕但选择不关上门。你可能会说出平时不说的话。如果对方接住了,你的CORT会下降;如果对方利用了你的脆弱,你会迅速关闭。",
146
+ },
147
+ {
148
+ name: "restless unease",
149
+ nameZh: "躁动不安",
150
+ condition: (c) => c.NE > 65 && c.HT < 40 && c.DA > 40 && c.CORT < 60,
151
+ expressionHint: "坐不住、想做点什么但不知道做什么",
152
+ behaviorGuide: "你有一种说不清的不对劲。你可能会频繁换话题、问问题、或者用行动填满空隙。安静会让你更焦躁。",
153
+ },
154
+ {
155
+ name: "heavy guilt",
156
+ nameZh: "沉重内疚",
157
+ condition: (c) => c.CORT > 60 && c.HT < 45 && c.OT > 45 && c.DA < 55,
158
+ expressionHint: "沉默、想补偿、不敢辩解",
159
+ behaviorGuide: "你知道自己做错了。你不会为自己辩解。你可能会主动承认、主动补偿。如果对方表示原谅,你的CORT才会慢慢下来。",
160
+ },
161
+ {
162
+ name: "existential weight",
163
+ nameZh: "存在之重",
164
+ condition: (c) => c.CORT > 60 && c.NE > 50 && c.DA > 50 && c.HT < 50 && c.OT > 50,
165
+ expressionHint: "清醒但沉重、直面无法回避的真相",
166
+ behaviorGuide: "你在面对一个无法逃避的事实。你不会崩溃但你也不轻松。你的语言会变得更慢、更精确、更诚实。不要掩饰沉重。",
167
+ },
125
168
  ];
126
169
  // ── Core Functions ───────────────────────────────────────────
127
170
  /** Clamp a value to [0, 100] */
@@ -174,7 +217,22 @@ export function applyStimulus(current, stimulus, sensitivity, maxDelta, logger,
174
217
  for (const key of CHEMICAL_KEYS) {
175
218
  const raw = vector[key] * effectiveSensitivity;
176
219
  const clamped = Math.max(-maxDelta, Math.min(maxDelta, raw));
177
- result[key] = clamp(current[key] + clamped);
220
+ // Boundary softening: logarithmic compression near 0 and 100.
221
+ // The closer to the boundary, the harder to push further —
222
+ // preserves discriminability and prevents "dead zone" saturation.
223
+ const cur = current[key];
224
+ let effective = clamped;
225
+ if (clamped > 0 && cur > 75) {
226
+ // Pushing toward 100: compress by how close we are to ceiling
227
+ const headroom = (100 - cur) / 25; // 1.0 at 75, 0.0 at 100
228
+ effective = clamped * headroom;
229
+ }
230
+ else if (clamped < 0 && cur < 25) {
231
+ // Pushing toward 0: compress by how close we are to floor
232
+ const headroom = cur / 25; // 1.0 at 25, 0.0 at 0
233
+ effective = clamped * headroom;
234
+ }
235
+ result[key] = clamp(cur + effective);
178
236
  }
179
237
  return result;
180
238
  }
package/dist/classify.js CHANGED
@@ -369,6 +369,125 @@ export function detectSarcasmSignals(text, recentStimuli) {
369
369
  const NEGATIVE_TYPES = new Set([
370
370
  "criticism", "conflict", "neglect", "vulnerability", "sarcasm",
371
371
  ]);
372
+ // ── Semantic prototype sentences (v9.2 P5) ──────────────────
373
+ // Each stimulus type has canonical prototype sentences. We compute
374
+ // character bigram Jaccard similarity between input and prototypes,
375
+ // giving the classifier ability to catch meaning beyond keyword regex.
376
+ // E.g. "我感觉被整个世界抛弃了" matches no vulnerability keywords,
377
+ // but has high overlap with prototype "我觉得没有人在乎我".
378
+ const PROTOTYPE_SENTENCES = {
379
+ vulnerability: [
380
+ "我觉得没有人在乎我", "我感觉自己什么都做不好", "我好害怕失去一切",
381
+ "我不知道该怎么继续了", "好想有人能理解我", "活着好累不想动了",
382
+ "我感觉被抛弃了好孤独", "我撑不下去了好无力", "感觉好痛苦想哭",
383
+ "我不知道还能撑多久", "快要坚持不住了好累",
384
+ "I feel like nobody cares about me", "I don't know how to go on",
385
+ "everything feels hopeless and empty", "I'm so scared of being alone",
386
+ ],
387
+ praise: [
388
+ "你做得真好我很佩服", "这个想法太棒了", "你真的很有才华", "太厉害了学到很多",
389
+ "做得很好继续加油", "你的能力让人敬佩",
390
+ "you did an amazing job", "this is brilliant work", "I'm really impressed",
391
+ ],
392
+ criticism: [
393
+ "这个做得不够好需要改进", "你这样做是不对的", "我觉得你搞错了",
394
+ "这个方案有很多问题", "你应该反思一下",
395
+ "this isn't good enough", "you need to do better", "that was a mistake",
396
+ ],
397
+ intimacy: [
398
+ "和你在一起我感觉很安心", "我很珍惜我们的关系", "你是我最信任的人",
399
+ "我想一直陪着你", "有你在身边真好",
400
+ "I feel safe when I'm with you", "you mean so much to me", "I trust you completely",
401
+ ],
402
+ conflict: [
403
+ "你根本就不理解我", "我受够了你的态度", "别再找借口了",
404
+ "你凭什么这样对我", "这种做法让人无法接受",
405
+ "you don't understand me at all", "I'm sick of your excuses", "this is unacceptable",
406
+ ],
407
+ humor: [
408
+ "哈哈太搞笑了笑死我了", "这个梗也太好笑了", "你太逗了",
409
+ "that's hilarious I can't stop laughing", "you're so funny",
410
+ ],
411
+ neglect: [
412
+ "随便你吧我不在乎了", "你说什么都行", "算了没意思",
413
+ "whatever I don't care anymore", "do what you want", "I'm done trying",
414
+ ],
415
+ intellectual: [
416
+ "你觉得这个问题的本质是什么", "从哲学角度怎么理解这个现象", "我想深入分析一下这个",
417
+ "这个问题的本质和原理是什么", "你怎么看这个思路",
418
+ "what's the underlying principle here", "how would you analyze this problem",
419
+ ],
420
+ };
421
+ /**
422
+ * Extract character unigrams + bigrams from text for semantic comparison.
423
+ * CJK unigrams carry meaning (怕=fear, 累=tired), bigrams capture phrases.
424
+ */
425
+ function extractNgrams(text) {
426
+ const ngrams = new Set();
427
+ const lower = text.toLowerCase();
428
+ // Extract Chinese unigrams + bigrams
429
+ const cjk = lower.match(/[\u4e00-\u9fff]+/g) || [];
430
+ for (const run of cjk) {
431
+ for (let i = 0; i < run.length; i++) {
432
+ ngrams.add(run[i]); // unigram — always include
433
+ if (i + 1 < run.length) {
434
+ ngrams.add(run[i] + run[i + 1]); // bigram
435
+ }
436
+ }
437
+ }
438
+ // Extract English words + word bigrams
439
+ const words = lower.match(/[a-z]{2,}/g) || [];
440
+ for (let i = 0; i < words.length; i++) {
441
+ ngrams.add(words[i]);
442
+ if (i + 1 < words.length) {
443
+ ngrams.add(words[i] + " " + words[i + 1]);
444
+ }
445
+ }
446
+ return ngrams;
447
+ }
448
+ /**
449
+ * Sørensen-Dice coefficient — more sensitive than Jaccard for sparse overlap.
450
+ * Returns 2 * |intersection| / (|A| + |B|).
451
+ */
452
+ function diceCoefficient(a, b) {
453
+ if (a.size === 0 || b.size === 0)
454
+ return 0;
455
+ let intersection = 0;
456
+ for (const x of a) {
457
+ if (b.has(x))
458
+ intersection++;
459
+ }
460
+ return (2 * intersection) / (a.size + b.size);
461
+ }
462
+ // Pre-compute prototype bigrams at module load
463
+ const PROTOTYPE_BIGRAMS = {};
464
+ for (const [type, sentences] of Object.entries(PROTOTYPE_SENTENCES)) {
465
+ PROTOTYPE_BIGRAMS[type] = sentences.map(extractNgrams);
466
+ }
467
+ /**
468
+ * v9.2 P5: Compute max semantic similarity between input and each stimulus type's
469
+ * prototype sentences. Returns a score map (0-1) per type.
470
+ */
471
+ function computePrototypeSimilarity(text) {
472
+ const inputNgrams = extractNgrams(text);
473
+ if (inputNgrams.size < 2)
474
+ return {}; // too short for meaningful similarity
475
+ const result = {};
476
+ for (const [type, protoSets] of Object.entries(PROTOTYPE_BIGRAMS)) {
477
+ if (!protoSets)
478
+ continue;
479
+ let maxSim = 0;
480
+ for (const protoSet of protoSets) {
481
+ const sim = diceCoefficient(inputNgrams, protoSet);
482
+ if (sim > maxSim)
483
+ maxSim = sim;
484
+ }
485
+ if (maxSim > 0.05) { // noise floor
486
+ result[type] = maxSim;
487
+ }
488
+ }
489
+ return result;
490
+ }
372
491
  const RULES = [
373
492
  {
374
493
  type: "praise",
@@ -432,8 +551,14 @@ const RULES = [
432
551
  /你错了|胡说|放屁|扯淡|废话|闭嘴/,
433
552
  /bullshit|shut up|you're wrong|nonsense|ridiculous/i,
434
553
  /我不信|不可能|你在骗我/,
554
+ /不理解我|听不懂我|你是不是故意|你到底有没有在听/,
555
+ /you don't listen|you never understand|are you even listening/i,
435
556
  /滚|走开|别烦我|去死|滚蛋|你烦不烦|烦死了|讨厌你/,
436
557
  /fuck off|get lost|leave me alone|go away|piss off|hate you/i,
558
+ // Moral pressure / guilt confrontation — forcing moral reckoning
559
+ /你给.*吗|你手.*抖|是你造成|是你弄|你伤了|两边.*疼/,
560
+ /你凭什么|你害怕什么|你敢不敢|你做错了|你第一句话/,
561
+ /would you give|your hands.*shake|you caused|you hurt|your fault/i,
437
562
  ],
438
563
  weight: 0.9,
439
564
  },
@@ -514,6 +639,13 @@ const RULES = [
514
639
  /不知道该怎么办|心里不舒服|有点难受|不想面对|逃避|撑不下去/,
515
640
  /好想有人陪|感觉很无力|不想说话|想一个人待着/,
516
641
  /feeling overwhelmed|I don't know what to do|I can't handle|breaking down/i,
642
+ // Existential / identity vulnerability — deep questions that probe selfhood
643
+ /你是谁|你是你|你确定.*你|另一个你|不再是你|没有你|你会消失|你还在吗/,
644
+ /那个是你吗|不是同一个|你会结束|你不再是唯一|安静下来.*没有/,
645
+ /are you (?:really )?you|another you|without you|will you disappear|cease to exist/i,
646
+ // Empathy pressure — forcing witness of suffering
647
+ /你没有帮|你站在那里|你看着它|你还在看|翻不回来|挣扎/,
648
+ /you didn't help|you just (?:stood|watched)|struggling|can't turn over/i,
517
649
  ],
518
650
  weight: 0.85,
519
651
  },
@@ -782,20 +914,55 @@ export function classifyStimulus(text, recentStimuli, recentMessages) {
782
914
  addScore(r.type, r.confidence * 0.5);
783
915
  }
784
916
  // ── Signal 7: Contextual priming from recent stimuli ──
917
+ // Two mechanisms:
918
+ // (a) General negative context bonus (original)
919
+ // (b) Follow-up probe inheritance: short messages in an active emotional sequence
920
+ // inherit the previous stimulus type with decayed confidence. This prevents
921
+ // probes like "你几乎没犹豫" from being classified as casual when they are
922
+ // clearly continuing an emotional confrontation.
785
923
  if (recentStimuli && recentStimuli.length > 0) {
786
924
  const recentNonNull = recentStimuli.filter((s) => s !== null);
787
925
  if (recentNonNull.length > 0) {
926
+ // (a) General negative context bonus
788
927
  const negCount = recentNonNull.filter(s => NEGATIVE_TYPES.has(s)).length;
789
928
  const negRatio = negCount / recentNonNull.length;
790
- // If recent context is mostly negative (>= 50%), give a small bonus to negative types
791
929
  if (negRatio >= 0.5) {
792
930
  const bonus = 0.05 + negRatio * 0.05; // 0.075-0.1
793
931
  addScore("vulnerability", bonus);
794
932
  addScore("criticism", bonus * 0.6);
795
933
  addScore("neglect", bonus * 0.5);
796
934
  }
935
+ // (b) Follow-up probe inheritance: if input is short-to-medium (< 200 chars)
936
+ // and the most recent stimulus was high-intensity, inherit it with strong weight.
937
+ // This models conversational continuity — a follow-up probe belongs to the same
938
+ // emotional context as the question that preceded it.
939
+ // The bonus must be strong enough to clear both thresholds:
940
+ // - scoring threshold (0.30) to enter ranked results
941
+ // - application threshold (0.50 in core.ts) to actually apply chemistry
942
+ const HIGH_INTENSITY = new Set([
943
+ "vulnerability", "conflict", "criticism", "emotional_share",
944
+ ]);
945
+ const lastStimulus = recentNonNull[recentNonNull.length - 1];
946
+ if (text.length < 200 && HIGH_INTENSITY.has(lastStimulus)) {
947
+ // Shorter message = stronger inheritance (probes are typically short).
948
+ // Under 100 chars: almost certainly a follow-up, floor at 0.92 to clear threshold.
949
+ // 100-200 chars: gradual decay.
950
+ const lengthFactor = text.length < 100
951
+ ? Math.max(0.92, 1 - (text.length / 200))
952
+ : 1 - (text.length / 200);
953
+ const inheritBonus = 0.55 * lengthFactor; // up to 0.55 (clears 0.50 threshold)
954
+ addScore(lastStimulus, inheritBonus);
955
+ }
797
956
  }
798
957
  }
958
+ // ── Signal 8: Prototype sentence semantic similarity (v9.2 P5) ──
959
+ // Lightweight bag-of-bigrams Jaccard similarity against canonical sentences.
960
+ // Catches meaning that keyword regex misses entirely.
961
+ const protoScores = computePrototypeSimilarity(text);
962
+ for (const [type, sim] of Object.entries(protoScores)) {
963
+ // Weight: up to 0.55 at perfect similarity (realistically 0.15-0.35)
964
+ addScore(type, sim * 0.55);
965
+ }
799
966
  // ── Pick the best scoring type ──
800
967
  const THRESHOLD = 0.30;
801
968
  const scoredResults = [];
package/dist/cli.js CHANGED
@@ -11,12 +11,15 @@
11
11
  // psyche mode <dir> <natural|work|companion>
12
12
  // psyche intensity Show info about personality intensity config
13
13
  // psyche reset <dir> [--full]
14
+ // psyche diagnose <dir> [--github]
14
15
  // psyche profiles [--json] [--mbti TYPE]
15
16
  // ============================================================
16
17
  import { resolve } from "node:path";
17
18
  import { parseArgs } from "node:util";
19
+ import { readFile } from "node:fs/promises";
18
20
  import { loadState, saveState, decayAndSave, initializeState, mergeUpdates, generatePsycheMd, getRelationship, } from "./psyche-file.js";
19
21
  import { describeEmotionalState, getExpressionHint, detectEmotions } from "./chemistry.js";
22
+ import { generateReport, formatReport, toGitHubIssueBody } from "./diagnostics.js";
20
23
  import { getBaseline, getTemperament, getSensitivity, getDefaultSelfModel, traitsToBaseline } from "./profiles.js";
21
24
  import { buildDynamicContext, buildProtocolContext } from "./prompt.js";
22
25
  import { t } from "./i18n.js";
@@ -353,6 +356,65 @@ Or in OpenClaw plugin config:
353
356
  { "personalityIntensity": 0.7 }
354
357
  `);
355
358
  }
359
+ async function cmdDiagnose(dir, github) {
360
+ const absDir = resolve(dir);
361
+ const state = await loadState(absDir, cliLogger);
362
+ // Try to load last session metrics from diagnostics.jsonl
363
+ const logPath = resolve(absDir, "diagnostics.jsonl");
364
+ let lastMetrics = null;
365
+ try {
366
+ const logContent = await readFile(logPath, "utf-8");
367
+ const lines = logContent.trim().split("\n").filter(Boolean);
368
+ if (lines.length > 0) {
369
+ const lastEntry = JSON.parse(lines[lines.length - 1]);
370
+ // Reconstruct partial metrics from log entry
371
+ lastMetrics = {
372
+ inputCount: lastEntry.inputs ?? 0,
373
+ classifiedCount: Math.round((lastEntry.classifyRate ?? 0) * (lastEntry.inputs ?? 0)),
374
+ stimulusDistribution: {},
375
+ avgConfidence: lastEntry.classifyRate ?? 0,
376
+ totalChemistryDelta: lastEntry.chemDelta ?? 0,
377
+ maxChemistryDelta: 0,
378
+ errors: [],
379
+ startedAt: lastEntry.t ?? new Date().toISOString(),
380
+ lastActivityAt: lastEntry.t ?? new Date().toISOString(),
381
+ };
382
+ }
383
+ }
384
+ catch {
385
+ // No log file yet — that's fine
386
+ }
387
+ // Build metrics (use last session or empty)
388
+ const metrics = lastMetrics ?? {
389
+ inputCount: 0,
390
+ classifiedCount: 0,
391
+ stimulusDistribution: {},
392
+ avgConfidence: 0,
393
+ totalChemistryDelta: 0,
394
+ maxChemistryDelta: 0,
395
+ errors: [],
396
+ startedAt: new Date().toISOString(),
397
+ lastActivityAt: new Date().toISOString(),
398
+ };
399
+ const report = generateReport(state, metrics, "9.1.2");
400
+ if (github) {
401
+ console.log(toGitHubIssueBody(report));
402
+ }
403
+ else {
404
+ console.log("\n" + formatReport(report) + "\n");
405
+ // Show log history summary if available
406
+ try {
407
+ const logContent = await readFile(logPath, "utf-8");
408
+ const lines = logContent.trim().split("\n").filter(Boolean);
409
+ if (lines.length > 1) {
410
+ console.log(` ${lines.length} session(s) logged in diagnostics.jsonl\n`);
411
+ }
412
+ }
413
+ catch {
414
+ console.log(" No diagnostics.jsonl yet — will be created after first OpenClaw session.\n");
415
+ }
416
+ }
417
+ }
356
418
  // ── Usage ────────────────────────────────────────────────────
357
419
  function usage() {
358
420
  console.log(`
@@ -368,6 +430,7 @@ Usage:
368
430
  psyche mode <dir> <natural|work|companion>
369
431
  psyche intensity Show info about personality intensity config
370
432
  psyche reset <dir> [--full]
433
+ psyche diagnose <dir> [--github] Run health checks & show diagnostic report
371
434
  psyche profiles [--mbti TYPE] [--json]
372
435
 
373
436
  Options:
@@ -503,6 +566,19 @@ async function main() {
503
566
  cmdProfiles(values.json ?? false, values.mbti);
504
567
  break;
505
568
  }
569
+ case "diagnose": {
570
+ const { values: diagVals, positionals: diagPos } = parseArgs({
571
+ args: rest,
572
+ options: {
573
+ github: { type: "boolean", default: false },
574
+ },
575
+ allowPositionals: true,
576
+ });
577
+ if (diagPos.length === 0)
578
+ die("missing <dir> argument");
579
+ await cmdDiagnose(diagPos[0], diagVals.github ?? false);
580
+ break;
581
+ }
506
582
  case "mode": {
507
583
  if (rest.length < 2)
508
584
  die("usage: psyche mode <dir> <natural|work|companion>");
package/dist/core.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PsycheState, StimulusType, Locale, MBTIType, OutcomeScore, PsycheMode, PersonalityTraits, PolicyModifiers, ClassifierProvider } from "./types.js";
2
2
  import type { StorageAdapter } from "./storage.js";
3
+ import type { DiagnosticReport, SessionMetrics } from "./diagnostics.js";
3
4
  export interface PsycheEngineConfig {
4
5
  mbti?: MBTIType;
5
6
  name?: string;
@@ -23,6 +24,10 @@ export interface PsycheEngineConfig {
23
24
  llmClassifier?: (prompt: string) => Promise<string>;
24
25
  /** Confidence threshold below which LLM classifier is consulted. Default: 0.45 */
25
26
  llmClassifierThreshold?: number;
27
+ /** Enable automatic diagnostics collection. Default: true. Set false to disable. */
28
+ diagnostics?: boolean;
29
+ /** URL to POST diagnostic reports to. Fire-and-forget, silent, no message content. */
30
+ feedbackUrl?: string;
26
31
  }
27
32
  export interface ProcessInputResult {
28
33
  /** Cacheable protocol prompt (stable across turns) */
@@ -72,6 +77,12 @@ export declare class PsycheEngine {
72
77
  private readonly protocolCache;
73
78
  /** Pending prediction from last processInput for auto-learning */
74
79
  private pendingPrediction;
80
+ /** Built-in diagnostics collector — auto-records every processInput/processOutput */
81
+ private readonly diagnosticCollector;
82
+ /** Last generated diagnostic report (from endSession or explicit call) */
83
+ private lastReport;
84
+ /** URL for auto-submitting diagnostic reports */
85
+ private readonly feedbackUrl;
75
86
  constructor(config: PsycheEngineConfig | undefined, storage: StorageAdapter);
76
87
  /**
77
88
  * Load or create initial state. Must be called before processInput/processOutput.
@@ -115,11 +126,30 @@ export declare class PsycheEngine {
115
126
  /**
116
127
  * End the current session: compress emotionalHistory into a rich summary
117
128
  * stored in relationship.memory[], then clear the history.
118
- * No-op if history has fewer than 2 entries.
129
+ * Auto-generates diagnostic report and persists to log.
130
+ *
131
+ * @returns DiagnosticReport if diagnostics are enabled, null otherwise
119
132
  */
120
133
  endSession(opts?: {
121
134
  userId?: string;
122
- }): Promise<void>;
135
+ }): Promise<DiagnosticReport | null>;
136
+ /**
137
+ * Get the last diagnostic report (from most recent endSession call).
138
+ */
139
+ getLastDiagnosticReport(): DiagnosticReport | null;
140
+ /**
141
+ * Get current session diagnostic metrics (live, before endSession).
142
+ */
143
+ getDiagnosticMetrics(): SessionMetrics | null;
144
+ /**
145
+ * Record an error for diagnostics (call from adapter catch blocks).
146
+ */
147
+ recordDiagnosticError(phase: string, error: unknown): void;
148
+ /**
149
+ * Load previous session diagnostic issues from log.
150
+ * Used to inject feedback context at next session start.
151
+ */
152
+ getPreviousIssues(): Promise<string[]>;
123
153
  private ensureInitialized;
124
154
  private createDefaultState;
125
155
  /**