psyche-ai 11.8.0 → 11.9.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.
@@ -38,7 +38,10 @@ declare function stripPsycheTags(text: string): string;
38
38
  export interface ThrongletsSignalPayload {
39
39
  kind: "psyche_state";
40
40
  agent_id: string;
41
+ context: string;
41
42
  message: string;
43
+ model: "psyche";
44
+ session_id: string;
42
45
  }
43
46
  export interface PsycheClaudeSdkOptions {
44
47
  /** User ID for relationship tracking. Default: internal shared bucket (`_default`). */
@@ -182,6 +182,7 @@ export class PsycheClaudeSDK {
182
182
  const ambientPriors = await self.resolveAmbientPriors(userMessage, currentGoal, activePolicy, currentTurnCorrection);
183
183
  const result = await safeProcessInput(self.engine, userMessage, {
184
184
  userId: self.opts.userId,
185
+ sessionId: runtimeContext.sessionId ?? self.lastRuntimeContext.sessionId,
185
186
  ambientPriors,
186
187
  currentGoal,
187
188
  activePolicy,
@@ -212,6 +213,7 @@ export class PsycheClaudeSDK {
212
213
  async processResponse(text, opts) {
213
214
  const result = await safeProcessOutput(this.engine, text, {
214
215
  userId: this.opts.userId,
216
+ sessionId: this.resolveSessionId(),
215
217
  signals: opts?.signals,
216
218
  signalConfidence: opts?.signalConfidence,
217
219
  }, "claude-sdk.processOutput");
@@ -250,10 +252,14 @@ export class PsycheClaudeSDK {
250
252
  return null;
251
253
  const state = this.engine.getState();
252
254
  const s = state.current;
255
+ const sessionId = this.resolveSessionId();
253
256
  return {
254
257
  kind: "psyche_state",
255
258
  agent_id: this.resolveAgentId(),
259
+ context: `psyche:session:${sessionId}:user:${this.opts.userId}`,
256
260
  message: `order:${s.order} flow:${s.flow} boundary:${s.boundary} resonance:${s.resonance}`,
261
+ model: "psyche",
262
+ session_id: sessionId,
257
263
  };
258
264
  }
259
265
  /**
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { PsycheEngine } from "../core.js";
3
+ import type { ProcessInputResult } from "../core.js";
3
4
  import { fetchAmbientPriorsFromThronglets, type ThrongletsAmbientRuntimeOptions } from "../ambient-runtime.js";
4
5
  import { type ActivePolicyRule, type AmbientPriorView, type CurrentGoal } from "../types.js";
5
6
  export interface McpAmbientRuntimeOptions {
@@ -7,6 +8,10 @@ export interface McpAmbientRuntimeOptions {
7
8
  thronglets?: ThrongletsAmbientRuntimeOptions;
8
9
  fetcher?: typeof fetchAmbientPriorsFromThronglets;
9
10
  }
11
+ export declare function resolveMcpTurnCacheSession(sessionId?: string): string;
12
+ export declare function cacheMcpTurnResult(result: ProcessInputResult, sessionId?: string): void;
13
+ export declare function getCachedMcpTurnResult(sessionId?: string): ProcessInputResult | null;
14
+ export declare function resetMcpTurnCacheForTests(): void;
10
15
  declare function getEngine(): Promise<PsycheEngine>;
11
16
  export declare function resolveRuntimeAmbientPriors(text: string, explicit?: AmbientPriorView[], currentGoal?: CurrentGoal, activePolicy?: ActivePolicyRule[], currentTurnCorrectionOrOpts?: string | McpAmbientRuntimeOptions, opts?: McpAmbientRuntimeOptions): Promise<AmbientPriorView[] | undefined>;
12
17
  declare const server: McpServer;
@@ -96,9 +96,29 @@ function parseCLIArgs() {
96
96
  return overrides;
97
97
  }
98
98
  // ── Turn cache for on-demand resource access ──────────────
99
- // Single-client assumption: MCP stdio transport serves one host at a time.
100
- // If this ever becomes multi-client, turn cache must be keyed per session.
101
- let lastTurnResult = null;
99
+ // Hosts may overlap sessions on one MCP transport, so cache per session key.
100
+ const DEFAULT_TURN_CACHE_SESSION = "__default__";
101
+ const turnResultsBySession = new Map();
102
+ let lastTurnCacheSession = DEFAULT_TURN_CACHE_SESSION;
103
+ export function resolveMcpTurnCacheSession(sessionId) {
104
+ const normalized = sessionId?.trim();
105
+ return normalized ? normalized : DEFAULT_TURN_CACHE_SESSION;
106
+ }
107
+ export function cacheMcpTurnResult(result, sessionId) {
108
+ const key = resolveMcpTurnCacheSession(sessionId);
109
+ turnResultsBySession.set(key, result);
110
+ lastTurnCacheSession = key;
111
+ }
112
+ export function getCachedMcpTurnResult(sessionId) {
113
+ if (sessionId) {
114
+ return turnResultsBySession.get(resolveMcpTurnCacheSession(sessionId)) ?? null;
115
+ }
116
+ return turnResultsBySession.get(lastTurnCacheSession) ?? null;
117
+ }
118
+ export function resetMcpTurnCacheForTests() {
119
+ turnResultsBySession.clear();
120
+ lastTurnCacheSession = DEFAULT_TURN_CACHE_SESSION;
121
+ }
102
122
  // ── Engine singleton ───────────────────────────────────────
103
123
  let engine = null;
104
124
  async function getEngine() {
@@ -208,14 +228,17 @@ server.resource("state", "psyche://state", {
208
228
  }],
209
229
  };
210
230
  });
211
- // Helper: register a turn-scoped resource backed by lastTurnResult.
231
+ // Helper: register a turn-scoped resource backed by the per-session turn cache.
212
232
  // Returns empty JSON when no turn has been processed yet (fail-open).
213
233
  function turnResource(name, uri, description, pick) {
214
234
  server.resource(name, uri, { description, mimeType: "application/json" }, async (u) => ({
215
235
  contents: [{
216
236
  uri: u.href,
217
237
  mimeType: "application/json",
218
- text: lastTurnResult ? JSON.stringify(pick(lastTurnResult)) : "{}",
238
+ text: (() => {
239
+ const cached = getCachedMcpTurnResult(u.searchParams.get("session") ?? undefined);
240
+ return cached ? JSON.stringify(pick(cached)) : "{}";
241
+ })(),
219
242
  }],
220
243
  }));
221
244
  }
@@ -245,6 +268,7 @@ server.tool("process_input", "Process user input through the emotional engine. R
245
268
  "Call this BEFORE generating a response to the user.", {
246
269
  text: z.string().describe("The user's message text"),
247
270
  userId: z.string().optional().describe("Optional user ID for multi-user relationship tracking"),
271
+ sessionId: z.string().optional().describe("Optional session ID for overlapping host sessions and turn-scoped resource lookup"),
248
272
  ambientPriors: z.array(z.object({
249
273
  summary: z.string(),
250
274
  confidence: z.number().min(0).max(1),
@@ -262,19 +286,20 @@ server.tool("process_input", "Process user input through the emotional engine. R
262
286
  summary: z.string(),
263
287
  })).optional().describe("Optional explicit current-turn method policy view. Runtime-only; not persisted as self-state."),
264
288
  currentTurnCorrection: z.string().optional().describe("Optional explicit current-turn correction. Compiles into a task-scoped hard policy for this turn only."),
265
- }, async ({ text, userId, ambientPriors, currentGoal, activePolicy, currentTurnCorrection }) => {
289
+ }, async ({ text, userId, sessionId, ambientPriors, currentGoal, activePolicy, currentTurnCorrection }) => {
266
290
  const eng = await getEngine();
267
291
  const resolvedActivePolicy = resolveRuntimeActivePolicy(activePolicy, currentTurnCorrection);
268
292
  const resolvedAmbientPriors = await resolveRuntimeAmbientPriors(text, ambientPriors, currentGoal, resolvedActivePolicy, currentTurnCorrection);
269
293
  const result = await safeProcessInput(eng, text, {
270
294
  userId,
295
+ sessionId,
271
296
  ambientPriors: resolvedAmbientPriors,
272
297
  currentGoal,
273
298
  activePolicy: resolvedActivePolicy,
274
299
  currentTurnCorrection,
275
300
  }, "mcp.processInput");
276
301
  // Cache full result for turn-scoped resources
277
- lastTurnResult = result;
302
+ cacheMcpTurnResult(result, sessionId);
278
303
  // Build slim response: only what the LLM host actually needs.
279
304
  // Full structured state available via psyche://turn/envelope resource.
280
305
  const slim = {
@@ -302,18 +327,20 @@ server.tool("process_output", "Process the LLM's response through the emotional
302
327
  "emotional contagion. Call this AFTER generating a response.", {
303
328
  text: z.string().describe("The LLM's response text"),
304
329
  userId: z.string().optional().describe("Optional user ID"),
330
+ sessionId: z.string().optional().describe("Optional session ID to match the corresponding process_input turn"),
305
331
  signals: z.array(z.string()).optional().describe("Optional sparse writeback signals from the host"),
306
332
  signalConfidence: z.number().min(0).max(1).optional().describe("Optional confidence for the supplied signals"),
307
- }, async ({ text, userId, signals, signalConfidence }) => {
333
+ }, async ({ text, userId, sessionId, signals, signalConfidence }) => {
308
334
  const eng = await getEngine();
335
+ const turnResult = getCachedMcpTurnResult(sessionId);
309
336
  // LLM-specific alignment inference (adapter layer — the ONLY text-specific code).
310
337
  // Compare output length against last contract's maxChars to detect divergence.
311
338
  let outcome;
312
- if (lastTurnResult?.responseContract) {
313
- const maxLen = (lastTurnResult.responseContract.maxChars ?? 500) * 2;
339
+ if (turnResult?.responseContract) {
340
+ const maxLen = (turnResult.responseContract.maxChars ?? 500) * 2;
314
341
  outcome = { alignment: text.length > maxLen ? "diverged" : "aligned" };
315
342
  }
316
- const result = await safeProcessOutput(eng, text, { userId, signals: signals, signalConfidence, outcome }, "mcp.processOutput");
343
+ const result = await safeProcessOutput(eng, text, { userId, sessionId, signals: signals, signalConfidence, outcome }, "mcp.processOutput");
317
344
  return {
318
345
  content: [{
319
346
  type: "text",
@@ -342,7 +369,7 @@ server.tool("get_state", "Get the current emotional state — self-state dimensi
342
369
  overlay,
343
370
  drives: state.drives,
344
371
  mbti: state.mbti,
345
- mode: state.meta?.mode,
372
+ mode: eng.getMode(),
346
373
  totalInteractions: state.meta?.totalInteractions,
347
374
  traitDrift: state.traitDrift,
348
375
  energyBudgets: state.energyBudgets,
@@ -356,12 +383,7 @@ server.tool("set_mode", "Switch operating mode. 'natural' = balanced emotional e
356
383
  mode: z.enum(["natural", "work", "companion"]).describe("Operating mode"),
357
384
  }, async ({ mode }) => {
358
385
  const eng = await getEngine();
359
- // PsycheEngine stores mode in config, we need to reinitialize
360
- // For now, update via state manipulation
361
- const state = eng.getState();
362
- if (state.meta) {
363
- state.meta.mode = mode;
364
- }
386
+ await eng.setMode(mode);
365
387
  return {
366
388
  content: [{
367
389
  type: "text",
@@ -12,6 +12,8 @@ import { PsycheEngine } from "../core.js";
12
12
  import { FileStorageAdapter, MemoryStorageAdapter } from "../storage.js";
13
13
  import { detectMBTI, extractAgentName, loadState } from "../psyche-file.js";
14
14
  import { resolveAmbientPriorsForTurn } from "../ambient-runtime.js";
15
+ import { buildResponseContractContext } from "../response-contract.js";
16
+ import { resolveCanonicalResponseContract, renderOpenClawBehavioralSurface, } from "./response-contract-surface.js";
15
17
  function isPsycheMode(value) {
16
18
  return value === "natural" || value === "work" || value === "companion";
17
19
  }
@@ -178,6 +180,8 @@ export function register(api) {
178
180
  userId: ctx.userId,
179
181
  ambientPriors: ambientPriors ?? [],
180
182
  });
183
+ const responseContract = resolveCanonicalResponseContract(result);
184
+ const locale = (engine.getState().meta.locale ?? "zh");
181
185
  const controls = result.replyEnvelope?.generationControls ?? result.generationControls;
182
186
  const dominantAppraisal = getDominantAppraisalLabel(result);
183
187
  const state = engine.getState();
@@ -192,6 +196,12 @@ export function register(api) {
192
196
  (controls?.maxTokens ? ` | out<=${controls.maxTokens}t` : "") +
193
197
  (controls?.requireConfirmation ? " | confirm" : ""));
194
198
  const systemParts = [result.systemContext, result.dynamicContext].filter(Boolean);
199
+ if (responseContract) {
200
+ const responseSurface = buildResponseContractContext(responseContract, locale);
201
+ if (!result.dynamicContext.includes(responseSurface)) {
202
+ systemParts.push(renderOpenClawBehavioralSurface(responseContract, locale));
203
+ }
204
+ }
195
205
  return {
196
206
  appendSystemContext: systemParts.join("\n\n"),
197
207
  };
@@ -21,7 +21,8 @@
21
21
  import { createServer } from "node:http";
22
22
  import { PsycheEngine } from "../core.js";
23
23
  import { MemoryStorageAdapter, FileStorageAdapter } from "../storage.js";
24
- import { isNearBaseline, deriveBehavioralBias } from "../prompt.js";
24
+ import { isNearBaseline } from "../prompt.js";
25
+ import { resolveCanonicalResponseContract, renderProxyBehavioralSurface, } from "./response-contract-surface.js";
25
26
  // ── Helpers ─────────────────────────────────────────────────
26
27
  function readBody(req) {
27
28
  return new Promise((resolve, reject) => {
@@ -112,18 +113,17 @@ export function createPsycheProxy(engine, opts) {
112
113
  const parsed = JSON.parse(rawBody.toString("utf-8"));
113
114
  const userMsg = lastUserMessage(parsed.messages);
114
115
  const userId = parsed.user ?? undefined;
116
+ let messages = parsed.messages;
115
117
  // ── 1. Observe input ────────────────────────────
118
+ let result = null;
116
119
  if (userMsg) {
117
- await engine.processInput(userMsg, { userId });
120
+ result = await engine.processInput(userMsg, { userId });
118
121
  }
119
122
  // ── 2. Inject behavioral bias (silent when near baseline) ──
120
123
  const state = engine.getState();
121
- let messages = parsed.messages;
122
- if (!isNearBaseline(state)) {
123
- const bias = deriveBehavioralBias(state, locale);
124
- if (bias) {
125
- messages = injectBias(parsed.messages, bias);
126
- }
124
+ const responseContract = result ? resolveCanonicalResponseContract(result) : null;
125
+ if (responseContract && !isNearBaseline(state)) {
126
+ messages = injectBias(parsed.messages, renderProxyBehavioralSurface(responseContract, locale));
127
127
  }
128
128
  const modifiedBody = JSON.stringify({ ...parsed, messages });
129
129
  const headers = forwardHeaders(req);
@@ -0,0 +1,5 @@
1
+ import type { ProcessInputResult } from "../core.js";
2
+ import type { Locale, ResponseContract } from "../types.js";
3
+ export declare function resolveCanonicalResponseContract(result: Pick<ProcessInputResult, "replyEnvelope" | "responseContract">): ResponseContract | null;
4
+ export declare function renderProxyBehavioralSurface(contract: ResponseContract, locale: Locale): string;
5
+ export declare function renderOpenClawBehavioralSurface(contract: ResponseContract, locale: Locale): string;
@@ -0,0 +1,16 @@
1
+ import { buildResponseContractContext } from "../response-contract.js";
2
+ export function resolveCanonicalResponseContract(result) {
3
+ return result.replyEnvelope?.responseContract ?? result.responseContract ?? null;
4
+ }
5
+ export function renderProxyBehavioralSurface(contract, locale) {
6
+ const context = buildResponseContractContext(contract, locale);
7
+ return locale === "zh"
8
+ ? `[代理行为契约]\n${context}`
9
+ : `[Proxy Behavioral Contract]\n${context}`;
10
+ }
11
+ export function renderOpenClawBehavioralSurface(contract, locale) {
12
+ const context = buildResponseContractContext(contract, locale);
13
+ return locale === "zh"
14
+ ? `[OpenClaw 行为契约]\n${context}`
15
+ : `[OpenClaw Behavioral Contract]\n${context}`;
16
+ }
package/dist/core.d.ts CHANGED
@@ -95,6 +95,7 @@ export interface ProcessInputResult {
95
95
  }
96
96
  export interface ProcessInputOptions {
97
97
  userId?: string;
98
+ sessionId?: string;
98
99
  ambientPriors?: AmbientPriorView[];
99
100
  currentGoal?: CurrentGoal;
100
101
  activePolicy?: ActivePolicyRule[];
@@ -121,6 +122,7 @@ export interface LoopOutcome {
121
122
  }
122
123
  export interface ProcessOutputOptions {
123
124
  userId?: string;
125
+ sessionId?: string;
124
126
  signals?: readonly string[];
125
127
  signalConfidence?: number;
126
128
  /** Substrate-reported outcome — closes the φ loop */
@@ -145,11 +147,14 @@ export declare class PsycheEngine {
145
147
  private _lastAlgorithmApplied;
146
148
  private readonly traits;
147
149
  private readonly cfg;
150
+ private readonly modeOverride?;
148
151
  private readonly classifier;
149
152
  private readonly llmClassifier?;
150
153
  private readonly protocolCache;
151
- /** Pending prediction from last processInput for auto-learning */
152
- private pendingPrediction;
154
+ /** Pending predictions keyed by relationship/session scope for auto-learning */
155
+ private readonly pendingPredictions;
156
+ /** Most recent response contract keyed by relationship/session scope for implicit loop closure */
157
+ private readonly pendingResponseContracts;
153
158
  /** Built-in diagnostics collector — auto-records every processInput/processOutput */
154
159
  private readonly diagnosticCollector;
155
160
  /** Last generated diagnostic report (from endSession or explicit call) */
@@ -190,8 +195,9 @@ export declare class PsycheEngine {
190
195
  * @param nextUserStimulus - The stimulus detected in the user's next message,
191
196
  * or null if the session ended.
192
197
  */
193
- processOutcome(nextUserStimulus: StimulusType | null, _opts?: {
198
+ processOutcome(nextUserStimulus: StimulusType | null, opts?: {
194
199
  userId?: string;
200
+ sessionId?: string;
195
201
  }): Promise<ProcessOutcomeResult | null>;
196
202
  /**
197
203
  * Get the current psyche state (read-only snapshot).
@@ -215,6 +221,8 @@ export declare class PsycheEngine {
215
221
  * Get the last diagnostic report (from most recent endSession call).
216
222
  */
217
223
  getLastDiagnosticReport(): DiagnosticReport | null;
224
+ getMode(): PsycheMode;
225
+ setMode(mode: PsycheMode): Promise<void>;
218
226
  /**
219
227
  * Get current session diagnostic metrics (live, before endSession).
220
228
  */
@@ -229,6 +237,9 @@ export declare class PsycheEngine {
229
237
  */
230
238
  getPreviousIssues(): Promise<string[]>;
231
239
  private ensureInitialized;
240
+ private resolveMode;
241
+ private resolvePendingPredictionKey;
242
+ private inferLoopOutcome;
232
243
  private createDefaultState;
233
244
  /**
234
245
  * Reset state to baseline. Optionally preserves relationships.
package/dist/core.js CHANGED
@@ -30,7 +30,7 @@ import { computeCircadianModulation, computeHomeostaticPressure, computeEnergyDe
30
30
  import { runReflectiveTurnPhases } from "./input-turn.js";
31
31
  import { applyRelationalTurn, applySessionBridge, applyWritebackSignals, createWritebackCalibrations, evaluateWritebackCalibrations } from "./relation-dynamics.js";
32
32
  import { buildExternalContinuityEnvelope } from "./external-continuity.js";
33
- import { deriveThrongletsExports } from "./thronglets-export.js";
33
+ import { deriveThrongletsExports, markThrongletsExportsEmitted } from "./thronglets-export.js";
34
34
  import { buildTurnObservability } from "./observability.js";
35
35
  import { DEFAULT_RELATIONSHIP_USER_ID, resolveRelationshipUserId } from "./relationship-key.js";
36
36
  import { normalizeAmbientPriors } from "./ambient-priors.js";
@@ -132,11 +132,14 @@ export class PsycheEngine {
132
132
  _lastAlgorithmApplied = false;
133
133
  traits;
134
134
  cfg;
135
+ modeOverride;
135
136
  classifier;
136
137
  llmClassifier;
137
138
  protocolCache = new Map();
138
- /** Pending prediction from last processInput for auto-learning */
139
- pendingPrediction = null;
139
+ /** Pending predictions keyed by relationship/session scope for auto-learning */
140
+ pendingPredictions = new Map();
141
+ /** Most recent response contract keyed by relationship/session scope for implicit loop closure */
142
+ pendingResponseContracts = new Map();
140
143
  /** Built-in diagnostics collector — auto-records every processInput/processOutput */
141
144
  diagnosticCollector;
142
145
  /** Last generated diagnostic report (from endSession or explicit call) */
@@ -155,6 +158,7 @@ export class PsycheEngine {
155
158
  this.traits = config.traits;
156
159
  this.classifier = config.classifier ?? new BuiltInClassifier();
157
160
  this.llmClassifier = config.llmClassifier;
161
+ this.modeOverride = config.mode;
158
162
  this.cfg = {
159
163
  mbti: config.mbti ?? "INFJ",
160
164
  name: config.name ?? "agent",
@@ -239,6 +243,15 @@ export class PsycheEngine {
239
243
  if (!loaded.lastWritebackFeedback) {
240
244
  loaded.lastWritebackFeedback = [];
241
245
  }
246
+ if (this.modeOverride) {
247
+ loaded.meta = { ...loaded.meta, mode: this.modeOverride };
248
+ }
249
+ else if (loaded.meta.mode) {
250
+ this.cfg.mode = loaded.meta.mode;
251
+ }
252
+ else {
253
+ loaded.meta = { ...loaded.meta, mode: this.cfg.mode };
254
+ }
242
255
  // Update sigilId if config provides one (Sigil may be assigned after first run)
243
256
  if (this.cfg.sigilId && loaded.meta.sigilId !== this.cfg.sigilId) {
244
257
  loaded.meta = { ...loaded.meta, sigilId: this.cfg.sigilId };
@@ -262,31 +275,34 @@ export class PsycheEngine {
262
275
  let sessionBridge = null;
263
276
  let writebackFeedback = [];
264
277
  let throngletsExports = [];
278
+ const now = new Date();
279
+ const runtimeMode = this.resolveMode(state);
280
+ const pendingKey = this.resolvePendingPredictionKey(opts);
265
281
  // ── Auto-learning: evaluate previous turn's outcome ──────
266
- if (this.pendingPrediction && text.length > 0) {
282
+ const pendingPrediction = this.pendingPredictions.get(pendingKey);
283
+ if (pendingPrediction && text.length > 0) {
267
284
  const nextClassifications = classifyLegacyStimulus(text);
268
285
  const nextStimulus = (nextClassifications[0]?.confidence ?? 0) >= 0.5
269
286
  ? nextClassifications[0].type
270
287
  : null;
271
- const outcome = evaluateOutcome(this.pendingPrediction.preInteractionState, state, nextStimulus, this.pendingPrediction.appliedStimulus);
288
+ const outcome = evaluateOutcome(pendingPrediction.preInteractionState, state, nextStimulus, pendingPrediction.appliedStimulus, pendingPrediction.userId);
272
289
  // Record prediction accuracy
273
290
  state = {
274
291
  ...state,
275
- learning: recordPrediction(state.learning, this.pendingPrediction.predictedState, state.current, this.pendingPrediction.appliedStimulus),
292
+ learning: recordPrediction(state.learning, pendingPrediction.predictedState, state.current, pendingPrediction.appliedStimulus),
276
293
  };
277
294
  // Update learned vectors based on outcome
278
- if (this.pendingPrediction.appliedStimulus) {
295
+ if (pendingPrediction.appliedStimulus) {
279
296
  state = {
280
297
  ...state,
281
- learning: updateLearnedVector(state.learning, this.pendingPrediction.appliedStimulus, this.pendingPrediction.contextHash, outcome.adaptiveScore, state.current, state.baseline),
298
+ learning: updateLearnedVector(state.learning, pendingPrediction.appliedStimulus, pendingPrediction.contextHash, outcome.adaptiveScore, state.current, state.baseline),
282
299
  };
283
300
  }
284
- this.pendingPrediction = null;
301
+ this.pendingPredictions.delete(pendingKey);
285
302
  }
286
303
  // ── Snapshot pre-interaction state for next turn's outcome evaluation
287
304
  const preInteractionState = { ...state };
288
305
  // Time decay toward baseline (chemistry + drives)
289
- const now = new Date();
290
306
  const minutesElapsed = (now.getTime() - new Date(state.updatedAt).getTime()) / 60000;
291
307
  if (minutesElapsed >= 1) {
292
308
  // Compute effective baseline from current 4D position (drives are derived, not stored)
@@ -311,15 +327,24 @@ export class PsycheEngine {
311
327
  }
312
328
  // Apply homeostatic pressure (fatigue from extended sessions)
313
329
  const sessionMinutes = (now.getTime() - new Date(state.sessionStartedAt).getTime()) / 60000;
330
+ const previousSessionMinutes = Math.max(0, sessionMinutes - Math.max(minutesElapsed, 0));
314
331
  const pressure = computeHomeostaticPressure(sessionMinutes);
315
- if (pressure.orderDepletion > 0 || pressure.flowDepletion > 0 || pressure.boundaryStiffening > 0) {
332
+ const previousPressure = computeHomeostaticPressure(previousSessionMinutes);
333
+ const incrementalPressure = {
334
+ orderDepletion: Math.max(0, pressure.orderDepletion - previousPressure.orderDepletion),
335
+ flowDepletion: Math.max(0, pressure.flowDepletion - previousPressure.flowDepletion),
336
+ boundaryStiffening: Math.max(0, pressure.boundaryStiffening - previousPressure.boundaryStiffening),
337
+ };
338
+ if (incrementalPressure.orderDepletion > 0
339
+ || incrementalPressure.flowDepletion > 0
340
+ || incrementalPressure.boundaryStiffening > 0) {
316
341
  state = {
317
342
  ...state,
318
343
  current: {
319
344
  ...state.current,
320
- order: clamp(state.current.order - pressure.orderDepletion * 0.1),
321
- flow: clamp(state.current.flow - pressure.flowDepletion * 0.1),
322
- boundary: clamp(state.current.boundary + pressure.boundaryStiffening * 0.1),
345
+ order: clamp(state.current.order - incrementalPressure.orderDepletion * 0.1),
346
+ flow: clamp(state.current.flow - incrementalPressure.flowDepletion * 0.1),
347
+ boundary: clamp(state.current.boundary + incrementalPressure.boundaryStiffening * 0.1),
323
348
  },
324
349
  };
325
350
  }
@@ -365,7 +390,7 @@ export class PsycheEngine {
365
390
  baseline: state.baseline,
366
391
  sensitivity: state.sensitivity ?? 1.0,
367
392
  personalityIntensity: this.cfg.personalityIntensity,
368
- mode: this.cfg.mode,
393
+ mode: runtimeMode,
369
394
  maxDimensionDelta: this.cfg.maxDimensionDelta,
370
395
  drives,
371
396
  previousAppraisal: state.subjectResidue?.axes,
@@ -445,7 +470,7 @@ export class PsycheEngine {
445
470
  state = { ...state, energyBudgets };
446
471
  // Relational turn uses pre-computed appraisal from perception
447
472
  const relationalTurn = applyRelationalTurn(state, text, {
448
- mode: this.cfg.mode,
473
+ mode: runtimeMode,
449
474
  now: now.toISOString(),
450
475
  stimulus: appliedStimulus,
451
476
  userId: opts?.userId,
@@ -479,10 +504,6 @@ export class PsycheEngine {
479
504
  });
480
505
  state = throngletsExportResult.state;
481
506
  throngletsExports = throngletsExportResult.exports;
482
- // Constitutive bridge: emit to Thronglets directly (substrate-independent)
483
- if (throngletsExports.length > 0) {
484
- bridgeThrongletsExports(throngletsExports, this.bridgeOpts).catch(() => { });
485
- }
486
507
  // ── Locale (used by multiple subsystems below) ──────────
487
508
  const locale = state.meta.locale ?? this.cfg.locale;
488
509
  // Push snapshot to emotional history
@@ -501,18 +522,20 @@ export class PsycheEngine {
501
522
  this._lastAlgorithmApplied = appliedStimulus !== null;
502
523
  // ── Generate prediction for next turn's auto-learning ────
503
524
  if (appliedStimulus) {
504
- const ctxHash = computeContextHash(state, opts?.userId);
525
+ const ctxHash = computeContextHash(state, opts?.userId, opts?.sessionId);
505
526
  const effectiveSensitivity = computeEffectiveSensitivity((state.sensitivity ?? 1.0), state.current, state.baseline, appliedStimulus, state.traitDrift);
506
527
  const predicted = predictState(preInteractionState.current, appliedStimulus, state.learning, ctxHash, effectiveSensitivity, this.cfg.maxDimensionDelta);
507
- this.pendingPrediction = {
528
+ this.pendingPredictions.set(pendingKey, {
508
529
  predictedState: predicted,
509
530
  preInteractionState,
510
531
  appliedStimulus,
511
532
  contextHash: ctxHash,
512
- };
533
+ userId: resolveRelationshipUserId(opts?.userId),
534
+ sessionId: opts?.sessionId?.trim() || undefined,
535
+ });
513
536
  }
514
537
  else {
515
- this.pendingPrediction = null;
538
+ this.pendingPredictions.delete(pendingKey);
516
539
  }
517
540
  const writebackNote = formatWritebackFeedbackNote(writebackFeedback, locale);
518
541
  const ambientPriors = normalizeAmbientPriors(opts?.ambientPriors);
@@ -539,6 +562,20 @@ export class PsycheEngine {
539
562
  // Persist
540
563
  this.state = state;
541
564
  await this.storage.save(state);
565
+ // Constitutive bridge: emit to Thronglets directly (substrate-independent)
566
+ if (throngletsExports.length > 0) {
567
+ bridgeThrongletsExports(throngletsExports, {
568
+ ...this.bridgeOpts,
569
+ sessionId: opts?.sessionId ?? this.bridgeOpts.sessionId,
570
+ }).then(async (ingested) => {
571
+ if (ingested <= 0)
572
+ return;
573
+ const latestState = this.ensureInitialized();
574
+ const markedState = markThrongletsExportsEmitted(latestState, throngletsExports, new Date().toISOString());
575
+ this.state = markedState;
576
+ await this.storage.save(markedState);
577
+ }).catch(() => { });
578
+ }
542
579
  // Auto-diagnostics: record this input
543
580
  if (this.diagnosticCollector) {
544
581
  this.diagnosticCollector.recordInput(appliedStimulus, appliedStimulus ? 1.0 : 0.0, state.current, appraisalAxes);
@@ -585,6 +622,7 @@ export class PsycheEngine {
585
622
  });
586
623
  // v10: compact mode is always on. Legacy buildDynamicContext removed from engine path.
587
624
  const externalContinuity = buildExternalContinuityEnvelope(throngletsExports);
625
+ this.pendingResponseContracts.set(pendingKey, replyEnvelope.responseContract);
588
626
  return {
589
627
  systemContext: "",
590
628
  dynamicContext: buildCompactContext(state, opts?.userId, promptRenderInputs),
@@ -620,6 +658,7 @@ export class PsycheEngine {
620
658
  let state = this.ensureInitialized();
621
659
  let stateChanged = false;
622
660
  const validationIssues = [];
661
+ const pendingKey = this.resolvePendingPredictionKey(opts);
623
662
  // Emotional contagion from empathy log
624
663
  if (state.empathyLog?.userState && this.cfg.emotionalContagionRate > 0) {
625
664
  const userEmotion = state.empathyLog.userState.toLowerCase();
@@ -669,8 +708,9 @@ export class PsycheEngine {
669
708
  // Loop outcome feedback (substrate-independent φ closure).
670
709
  // The substrate reports whether the output aligned with the Loop's intention.
671
710
  // Core processes this as pure 4D chemistry — no text parsing, no medium assumptions.
672
- if (opts?.outcome) {
673
- const { alignment, effort } = opts.outcome;
711
+ const outcome = opts?.outcome ?? this.inferLoopOutcome(text, pendingKey);
712
+ if (outcome) {
713
+ const { alignment, effort } = outcome;
674
714
  if (alignment === "diverged") {
675
715
  // Self/non-self conflict: boundary sharpens, order drops
676
716
  state = {
@@ -813,13 +853,14 @@ export class PsycheEngine {
813
853
  * @param nextUserStimulus - The stimulus detected in the user's next message,
814
854
  * or null if the session ended.
815
855
  */
816
- async processOutcome(nextUserStimulus, _opts) {
817
- if (!this.pendingPrediction)
818
- return null;
856
+ async processOutcome(nextUserStimulus, opts) {
819
857
  let state = this.ensureInitialized();
820
- const pending = this.pendingPrediction;
821
- this.pendingPrediction = null;
822
- const outcome = evaluateOutcome(pending.preInteractionState, state, nextUserStimulus, pending.appliedStimulus);
858
+ const pendingKey = this.resolvePendingPredictionKey(opts);
859
+ const pending = this.pendingPredictions.get(pendingKey);
860
+ if (!pending)
861
+ return null;
862
+ this.pendingPredictions.delete(pendingKey);
863
+ const outcome = evaluateOutcome(pending.preInteractionState, state, nextUserStimulus, pending.appliedStimulus, pending.userId);
823
864
  // Record prediction
824
865
  state = {
825
866
  ...state,
@@ -900,6 +941,21 @@ export class PsycheEngine {
900
941
  getLastDiagnosticReport() {
901
942
  return this.lastReport;
902
943
  }
944
+ getMode() {
945
+ return this.resolveMode(this.ensureInitialized());
946
+ }
947
+ async setMode(mode) {
948
+ const state = this.ensureInitialized();
949
+ this.cfg.mode = mode;
950
+ this.state = {
951
+ ...state,
952
+ meta: {
953
+ ...state.meta,
954
+ mode,
955
+ },
956
+ };
957
+ await this.storage.save(this.state);
958
+ }
903
959
  /**
904
960
  * Get current session diagnostic metrics (live, before endSession).
905
961
  */
@@ -937,6 +993,27 @@ export class PsycheEngine {
937
993
  }
938
994
  return this.state;
939
995
  }
996
+ resolveMode(state) {
997
+ const mode = state.meta.mode;
998
+ if (mode) {
999
+ this.cfg.mode = mode;
1000
+ return mode;
1001
+ }
1002
+ return this.cfg.mode;
1003
+ }
1004
+ resolvePendingPredictionKey(opts) {
1005
+ const userKey = resolveRelationshipUserId(opts?.userId);
1006
+ const sessionId = opts?.sessionId?.trim();
1007
+ return sessionId ? `${userKey}::${sessionId}` : userKey;
1008
+ }
1009
+ inferLoopOutcome(text, pendingKey) {
1010
+ const contract = this.pendingResponseContracts.get(pendingKey);
1011
+ this.pendingResponseContracts.delete(pendingKey);
1012
+ if (!contract)
1013
+ return undefined;
1014
+ const maxLen = (contract.maxChars ?? 500) * 2;
1015
+ return { alignment: text.length > maxLen ? "diverged" : "aligned" };
1016
+ }
940
1017
  createDefaultState() {
941
1018
  const { mbti, name, locale } = this.cfg;
942
1019
  // Use Big Five traits if provided, otherwise use preset baseline
@@ -5,7 +5,7 @@ import type { PsycheState, StimulusType, SelfState, ImpactVector, LearningState,
5
5
  * Computes a score from -1 to 1 using multiple signals:
6
6
  * drive changes, relationship changes, user warmth, conversation continuation.
7
7
  */
8
- export declare function evaluateOutcome(prevState: PsycheState, currentState: PsycheState, nextUserStimulus: StimulusType | null, appliedStimulus: StimulusType | null): OutcomeScore;
8
+ export declare function evaluateOutcome(prevState: PsycheState, currentState: PsycheState, nextUserStimulus: StimulusType | null, appliedStimulus: StimulusType | null, userId?: string): OutcomeScore;
9
9
  /**
10
10
  * Get the effective compatibility vector for a given stimulus + context,
11
11
  * combining the base vector with any learned adjustment.
@@ -32,7 +32,7 @@ export declare function updateLearnedVector(learning: LearningState, stimulus: S
32
32
  *
33
33
  * Example: "familiar:approach,task,task:hmlhh"
34
34
  */
35
- export declare function computeContextHash(state: PsycheState, _userId?: string): string;
35
+ export declare function computeContextHash(state: PsycheState, userId?: string, sessionId?: string): string;
36
36
  /**
37
37
  * Predict the resulting chemistry after applying a stimulus,
38
38
  * using learned vectors instead of raw base vectors.
package/dist/learning.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { DIMENSION_KEYS, DRIVE_KEYS, MAX_LEARNED_VECTORS, MAX_PREDICTION_HISTORY, } from "./types.js";
15
15
  import { STIMULUS_VECTORS, clamp } from "./chemistry.js";
16
16
  import { derivePrimarySnapshotMarker, markerFromLegacyStimulus, } from "./appraisal-markers.js";
17
+ import { resolveRelationshipUserId } from "./relationship-key.js";
17
18
  // ── 1. OutcomeEvaluator ─────────────────────────────────────
18
19
  /** Warmth mapping for appraisal residue. */
19
20
  const MARKER_WARMTH_MAP = {
@@ -39,9 +40,10 @@ function matchesLearningEntry(entry, stimulus, marker, contextHash) {
39
40
  * Computes a score from -1 to 1 using multiple signals:
40
41
  * drive changes, relationship changes, user warmth, conversation continuation.
41
42
  */
42
- export function evaluateOutcome(prevState, currentState, nextUserStimulus, appliedStimulus) {
43
+ export function evaluateOutcome(prevState, currentState, nextUserStimulus, appliedStimulus, userId) {
43
44
  const nextUserMarker = learningMarkerFromStimulus(nextUserStimulus);
44
45
  const appliedMarker = learningMarkerFromStimulus(appliedStimulus);
46
+ const relationKey = resolveRelationshipUserId(userId);
45
47
  // Drive delta: sum of all drive changes, normalized
46
48
  let driveSum = 0;
47
49
  for (const key of DRIVE_KEYS) {
@@ -49,8 +51,8 @@ export function evaluateOutcome(prevState, currentState, nextUserStimulus, appli
49
51
  }
50
52
  const driveDelta = Math.max(-1, Math.min(1, driveSum / 50));
51
53
  // Relationship delta: change in trust + intimacy of _default relationship
52
- const prevRel = prevState.relationships._default ?? { trust: 50, intimacy: 30 };
53
- const curRel = currentState.relationships._default ?? { trust: 50, intimacy: 30 };
54
+ const prevRel = prevState.relationships[relationKey] ?? { trust: 50, intimacy: 30 };
55
+ const curRel = currentState.relationships[relationKey] ?? { trust: 50, intimacy: 30 };
54
56
  const relChange = (curRel.trust - prevRel.trust) + (curRel.intimacy - prevRel.intimacy);
55
57
  const relationshipDelta = Math.max(-1, Math.min(1, relChange / 20));
56
58
  // User warmth: what residue their next turn carried
@@ -193,9 +195,10 @@ export function updateLearnedVector(learning, stimulus, contextHash, outcomeScor
193
195
  *
194
196
  * Example: "familiar:approach,task,task:hmlhh"
195
197
  */
196
- export function computeContextHash(state, _userId) {
198
+ export function computeContextHash(state, userId, sessionId) {
197
199
  // Relationship phase
198
- const rel = state.relationships._default ?? { phase: "stranger" };
200
+ const relationKey = resolveRelationshipUserId(userId);
201
+ const rel = state.relationships[relationKey] ?? { phase: "stranger" };
199
202
  const phase = rel.phase;
200
203
  // Last 3 canonical residues from emotional history
201
204
  const history = state.stateHistory ?? [];
@@ -217,7 +220,10 @@ export function computeContextHash(state, _userId) {
217
220
  // Format: separate safety from the rest for readability
218
221
  // survival_safety_connection_esteem_curiosity
219
222
  const driveStr = driveLevels.join("");
220
- return `${phase}:${recentMarkers || "none"}:${driveStr}`;
223
+ const scope = sessionId?.trim();
224
+ return scope
225
+ ? `${phase}:${recentMarkers || "none"}:${driveStr}:session=${scope}`
226
+ : `${phase}:${recentMarkers || "none"}:${driveStr}`;
221
227
  }
222
228
  // ── 3. PredictionEngine ─────────────────────────────────────
223
229
  /**
@@ -1,4 +1,5 @@
1
1
  import type { PsycheState, ResolvedRelationContext, SessionBridgeState, ThrongletsExport, WritebackCalibrationFeedback } from "./types.js";
2
+ export declare function markThrongletsExportsEmitted(state: PsycheState, exports: ThrongletsExport[], now: string): PsycheState;
2
3
  export declare function deriveThrongletsExports(state: PsycheState, opts: {
3
4
  relationContext: ResolvedRelationContext;
4
5
  sessionBridge?: SessionBridgeState | null;
@@ -5,6 +5,8 @@
5
5
  // The goal is to surface only sparse, typed, low-frequency events
6
6
  // that are worth handing to an external continuity substrate.
7
7
  // ============================================================
8
+ import { DRIVE_KEYS } from "./types.js";
9
+ import { deriveDriveSatisfaction, hasCriticalDrive } from "./drives.js";
8
10
  function clamp01(v) {
9
11
  return Math.max(0, Math.min(1, v));
10
12
  }
@@ -45,6 +47,11 @@ function updateExportState(state, keys, now) {
45
47
  throngletsExportState: nextState,
46
48
  };
47
49
  }
50
+ export function markThrongletsExportsEmitted(state, exports, now) {
51
+ if (exports.length === 0)
52
+ return state;
53
+ return updateExportState(state, exports.map((event) => event.key), now);
54
+ }
48
55
  function quantize(v, step = 10) {
49
56
  return Math.round(v / step) * step;
50
57
  }
@@ -148,6 +155,23 @@ function sanitizeThrongletsExport(event) {
148
155
  };
149
156
  return sanitized;
150
157
  }
158
+ case "viability": {
159
+ // Viability stays a sparse export, but its meaning is defined by the
160
+ // protocol-layer dimension registry. Keep the kind/payload stable here.
161
+ const sanitized = {
162
+ kind: "viability",
163
+ subject: "session",
164
+ primitive: "signal",
165
+ userKey: event.userKey,
166
+ strength: event.strength,
167
+ ttlTurns: event.ttlTurns,
168
+ key: event.key,
169
+ viable: event.viable,
170
+ minDrive: event.minDrive,
171
+ minDriveType: event.minDriveType,
172
+ };
173
+ return sanitized;
174
+ }
151
175
  }
152
176
  }
153
177
  export function deriveThrongletsExports(state, opts) {
@@ -220,6 +244,27 @@ export function deriveThrongletsExports(state, opts) {
220
244
  order, flow, boundary, resonance,
221
245
  summary: selfStateSummary(order, flow, boundary, resonance),
222
246
  });
247
+ // Viability — Psyche's self-assessment of aliveness for field pulse
248
+ if (state.baseline) {
249
+ const drives = deriveDriveSatisfaction(state.current, state.baseline);
250
+ const driveEntries = DRIVE_KEYS.map((k) => ({ type: k, value: drives[k] }));
251
+ const min = driveEntries.reduce((a, b) => (a.value < b.value ? a : b));
252
+ const viable = !hasCriticalDrive(drives);
253
+ const driveBucket = Math.round(min.value / 10) * 10;
254
+ const viabilityKey = `viability:${viable ? "viable" : "critical"}:${min.type}:${driveBucket}`;
255
+ candidates.push({
256
+ kind: "viability",
257
+ subject: "session",
258
+ primitive: "signal",
259
+ userKey,
260
+ strength: viable ? 0.5 : 0.9,
261
+ ttlTurns: 12,
262
+ key: viabilityKey,
263
+ viable,
264
+ minDrive: min.value,
265
+ minDriveType: min.type,
266
+ });
267
+ }
223
268
  for (const feedback of writebackFeedback) {
224
269
  if (feedback.effect === "holding")
225
270
  continue;
@@ -249,6 +294,5 @@ export function deriveThrongletsExports(state, opts) {
249
294
  const exports = deduped
250
295
  .filter((event) => !previousKeys.includes(event.key))
251
296
  .map((event) => sanitizeThrongletsExport(event));
252
- const nextState = updateExportState(state, deduped.map((event) => event.key), now);
253
- return { state: nextState, exports };
297
+ return { state, exports };
254
298
  }
@@ -3,7 +3,8 @@ const TAXONOMY_BY_EVENT = {
3
3
  "open-loop-anchor": "coordination",
4
4
  "continuity-anchor": "continuity",
5
5
  "writeback-calibration": "calibration",
6
- "self-state": "state",
6
+ "self-state": "calibration",
7
+ "viability": "calibration",
7
8
  };
8
9
  function summarizeLoopTypes(loopTypes) {
9
10
  return loopTypes.join(", ");
@@ -22,6 +23,8 @@ function summarizeThrongletsExport(event) {
22
23
  }
23
24
  case "self-state":
24
25
  return event.summary;
26
+ case "viability":
27
+ return `viability: ${event.viable ? "viable" : "critical"} (min drive: ${event.minDriveType} at ${Math.round(event.minDrive)})`;
25
28
  }
26
29
  }
27
30
  export function taxonomyForThrongletsExport(event) {
package/dist/types.d.ts CHANGED
@@ -611,7 +611,7 @@ export interface AmbientPriorView {
611
611
  export type ThrongletsExportSubject = "delegate" | "session";
612
612
  export type ThrongletsExportPrimitive = "signal" | "trace";
613
613
  export interface ThrongletsExportBase {
614
- kind: "relation-milestone" | "open-loop-anchor" | "writeback-calibration" | "continuity-anchor" | "self-state";
614
+ kind: "relation-milestone" | "open-loop-anchor" | "writeback-calibration" | "continuity-anchor" | "self-state" | "viability";
615
615
  subject: ThrongletsExportSubject;
616
616
  primitive: ThrongletsExportPrimitive;
617
617
  userKey: string;
@@ -662,7 +662,15 @@ export interface SelfStateExport extends ThrongletsExportBase {
662
662
  resonance: number;
663
663
  summary: string;
664
664
  }
665
- export type ThrongletsExport = RelationMilestoneExport | OpenLoopAnchorExport | WritebackCalibrationExport | ContinuityAnchorExport | SelfStateExport;
665
+ export interface ViabilityExport extends ThrongletsExportBase {
666
+ kind: "viability";
667
+ subject: "session";
668
+ primitive: "signal";
669
+ viable: boolean;
670
+ minDrive: number;
671
+ minDriveType: DriveType;
672
+ }
673
+ export type ThrongletsExport = RelationMilestoneExport | OpenLoopAnchorExport | WritebackCalibrationExport | ContinuityAnchorExport | SelfStateExport | ViabilityExport;
666
674
  export interface ThrongletsExportState {
667
675
  lastKeys: string[];
668
676
  lastAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "psyche-ai",
3
- "version": "11.8.0",
3
+ "version": "11.9.0",
4
4
  "description": "AI-first subjectivity kernel for agents with continuous appraisal, relation dynamics, and adaptive reply loops",
5
5
  "mcpName": "io.github.Shangri-la-0428/psyche-ai",
6
6
  "type": "module",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/Shangri-la-0428/oasyce_psyche",
7
7
  "source": "github"
8
8
  },
9
- "version": "11.8.0",
9
+ "version": "11.9.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "psyche-ai",
14
- "version": "11.8.0",
14
+ "version": "11.9.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },