ei-tui 0.1.4 → 0.1.6

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.
@@ -1,4 +1,5 @@
1
1
  import type { LLMRequest, QueueFailResult } from "../types.js";
2
+ import { DLQ_MAX_COUNT, DLQ_MAX_AGE_DAYS } from "../types.js";
2
3
 
3
4
  const BASE_BACKOFF_MS = 2_000;
4
5
  const MAX_BACKOFF_MS = 30_000;
@@ -27,35 +28,55 @@ export class QueueState {
27
28
  private paused = false;
28
29
 
29
30
  load(queue: LLMRequest[]): void {
30
- this.queue = queue;
31
+ // Reset any items stuck in 'processing' from a previous session
32
+ this.queue = queue.map(r =>
33
+ r.state === "processing" ? { ...r, state: "pending" } : r
34
+ );
31
35
  }
32
36
 
33
37
  export(): LLMRequest[] {
34
38
  return this.queue;
35
39
  }
36
40
 
37
- enqueue(request: Omit<LLMRequest, "id" | "created_at" | "attempts">): string {
41
+ enqueue(request: Omit<LLMRequest, "id" | "created_at" | "attempts" | "state">): string {
38
42
  const id = crypto.randomUUID();
39
43
  const fullRequest: LLMRequest = {
40
44
  ...request,
41
45
  id,
42
46
  created_at: new Date().toISOString(),
43
47
  attempts: 0,
48
+ state: "pending",
44
49
  };
45
50
  this.queue.push(fullRequest);
46
51
  return id;
47
52
  }
48
53
 
49
- peekHighest(): LLMRequest | null {
54
+ claimHighest(): LLMRequest | null {
50
55
  if (this.paused || this.queue.length === 0) return null;
51
- const now = new Date().toISOString();
52
- const available = this.queue.filter(r => !r.retry_after || r.retry_after <= now);
56
+ const available = this.queue.filter(r => r.state === "pending");
53
57
  if (available.length === 0) return null;
54
58
  const priorityOrder = { high: 0, normal: 1, low: 2 };
55
59
  const sorted = [...available].sort(
56
60
  (a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
57
61
  );
58
- return sorted[0] ?? null;
62
+ const item = sorted[0] ?? null;
63
+ if (item) {
64
+ item.state = "processing";
65
+ }
66
+ return item;
67
+ }
68
+
69
+ // Returns the retry_after of the highest-priority pending item, or null if ready now.
70
+ // Used by the processor loop to decide whether to sleep instead of claiming.
71
+ nextItemRetryAfter(): string | null {
72
+ if (this.paused || this.queue.length === 0) return null;
73
+ const available = this.queue.filter(r => r.state === "pending");
74
+ if (available.length === 0) return null;
75
+ const priorityOrder = { high: 0, normal: 1, low: 2 };
76
+ const sorted = [...available].sort(
77
+ (a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
78
+ );
79
+ return sorted[0]?.retry_after ?? null;
59
80
  }
60
81
 
61
82
  complete(id: string): void {
@@ -76,18 +97,21 @@ export class QueueState {
76
97
  }
77
98
 
78
99
  // No error string and not flagged permanent = just increment, no classification
100
+ // Still reset to pending in case it was claimed (processing state)
79
101
  if (!error && !permanent) {
102
+ request.state = "pending";
80
103
  return { dropped: false };
81
104
  }
82
105
 
83
106
  const shouldDrop = permanent || (error ? isPermanentError(error) : false);
84
107
 
85
108
  if (shouldDrop) {
86
- this.queue.splice(idx, 1);
109
+ request.state = "dlq";
87
110
  return { dropped: true };
88
111
  }
89
112
 
90
- // Transient error — apply exponential backoff, never drop
113
+ // Transient error — reset to pending with backoff timer
114
+ request.state = "pending";
91
115
  const delay = calculateBackoff(request.attempts);
92
116
  request.retry_after = new Date(Date.now() + delay).toISOString();
93
117
  return { dropped: false, retryDelay: delay };
@@ -98,7 +122,7 @@ export class QueueState {
98
122
  clearPersonaResponses(personaId: string, nextStep: string): string[] {
99
123
  const removedIds: string[] = [];
100
124
  this.queue = this.queue.filter((r) => {
101
- if (r.next_step === nextStep && r.data.personaId === personaId) {
125
+ if (r.state !== "dlq" && r.next_step === nextStep && r.data.personaId === personaId) {
102
126
  removedIds.push(r.id);
103
127
  return false;
104
128
  }
@@ -108,7 +132,49 @@ export class QueueState {
108
132
  }
109
133
 
110
134
  length(): number {
111
- return this.queue.length;
135
+ return this.queue.filter(r => r.state === "pending" || r.state === "processing").length;
136
+ }
137
+
138
+ dlqLength(): number {
139
+ return this.queue.filter(r => r.state === "dlq").length;
140
+ }
141
+
142
+ hasProcessingItem(): boolean {
143
+ return this.queue.some(r => r.state === "processing");
144
+ }
145
+
146
+ getDLQItems(): LLMRequest[] {
147
+ return this.queue.filter(r => r.state === "dlq");
148
+ }
149
+
150
+ getAllActiveItems(): LLMRequest[] {
151
+ return this.queue.filter(r => r.state !== "dlq");
152
+ }
153
+
154
+ updateItem(id: string, updates: Partial<LLMRequest>): boolean {
155
+ const idx = this.queue.findIndex(r => r.id === id);
156
+ if (idx < 0) return false;
157
+ this.queue[idx] = { ...this.queue[idx], ...updates };
158
+ return true;
159
+ }
160
+
161
+ trimDLQ(): number {
162
+ const dlqItems = this.queue.filter(r => r.state === "dlq");
163
+ const cutoff = new Date();
164
+ cutoff.setDate(cutoff.getDate() - DLQ_MAX_AGE_DAYS);
165
+ const cutoffISO = cutoff.toISOString();
166
+
167
+ // Remove by age first
168
+ const afterAgeTrim = dlqItems.filter(r => r.created_at >= cutoffISO);
169
+
170
+ // Then enforce count limit (oldest first)
171
+ const sorted = afterAgeTrim.sort((a, b) => a.created_at.localeCompare(b.created_at));
172
+ const kept = sorted.slice(-DLQ_MAX_COUNT);
173
+ const keptIds = new Set(kept.map(r => r.id));
174
+
175
+ const before = this.queue.length;
176
+ this.queue = this.queue.filter(r => r.state !== "dlq" || keptIds.has(r.id));
177
+ return before - this.queue.length;
112
178
  }
113
179
 
114
180
  pause(): void {
@@ -124,12 +190,12 @@ export class QueueState {
124
190
  }
125
191
 
126
192
  hasPendingCeremonies(): boolean {
127
- return this.queue.some(r => r.data.ceremony_progress === true);
193
+ return this.queue.some(r => r.state !== "dlq" && r.data.ceremony_progress === true);
128
194
  }
129
195
 
130
196
  clear(): number {
131
- const count = this.queue.length;
132
- this.queue = [];
197
+ const count = this.queue.filter(r => r.state !== "dlq").length;
198
+ this.queue = this.queue.filter(r => r.state === "dlq");
133
199
  return count;
134
200
  }
135
201
  }
@@ -250,7 +250,7 @@ export class StateManager {
250
250
  return result;
251
251
  }
252
252
 
253
- queue_enqueue(request: Omit<LLMRequest, "id" | "created_at" | "attempts">): string {
253
+ queue_enqueue(request: Omit<LLMRequest, "id" | "created_at" | "attempts" | "state">): string {
254
254
  const requestWithModel = {
255
255
  ...request,
256
256
  model: request.model ?? this.humanState.get().settings?.default_model,
@@ -260,8 +260,8 @@ export class StateManager {
260
260
  return id;
261
261
  }
262
262
 
263
- queue_peekHighest(): LLMRequest | null {
264
- return this.queueState.peekHighest();
263
+ queue_claimHighest(): LLMRequest | null {
264
+ return this.queueState.claimHighest();
265
265
  }
266
266
 
267
267
  queue_complete(id: string): void {
@@ -287,6 +287,14 @@ export class StateManager {
287
287
  return this.queueState.length();
288
288
  }
289
289
 
290
+ queue_hasProcessingItem(): boolean {
291
+ return this.queueState.hasProcessingItem();
292
+ }
293
+
294
+ queue_nextItemRetryAfter(): string | null {
295
+ return this.queueState.nextItemRetryAfter();
296
+ }
297
+
290
298
  queue_pause(): void {
291
299
  this.queueState.pause();
292
300
  this.scheduleSave();
@@ -311,6 +319,30 @@ export class StateManager {
311
319
  return result;
312
320
  }
313
321
 
322
+ queue_dlqLength(): number {
323
+ return this.queueState.dlqLength();
324
+ }
325
+
326
+ queue_getDLQItems(): LLMRequest[] {
327
+ return this.queueState.getDLQItems();
328
+ }
329
+
330
+ queue_getAllActiveItems(): LLMRequest[] {
331
+ return this.queueState.getAllActiveItems();
332
+ }
333
+
334
+ queue_updateItem(id: string, updates: Partial<LLMRequest>): boolean {
335
+ const result = this.queueState.updateItem(id, updates);
336
+ if (result) this.scheduleSave();
337
+ return result;
338
+ }
339
+
340
+ queue_trimDLQ(): number {
341
+ const result = this.queueState.trimDLQ();
342
+ if (result > 0) this.scheduleSave();
343
+ return result;
344
+ }
345
+
314
346
  async flush(): Promise<void> {
315
347
  await this.persistenceState.flush();
316
348
  }
package/src/core/types.ts CHANGED
@@ -256,6 +256,10 @@ export interface PersonaCreationInput {
256
256
  export const MESSAGE_MIN_COUNT = 200;
257
257
  export const MESSAGE_MAX_AGE_DAYS = 14;
258
258
 
259
+ // DLQ rolloff thresholds
260
+ export const DLQ_MAX_COUNT = 50;
261
+ export const DLQ_MAX_AGE_DAYS = 14;
262
+
259
263
  // Reserved persona names (command keywords that conflict with /persona subcommands)
260
264
  export const RESERVED_PERSONA_NAMES = ["new", "clone"] as const;
261
265
  export type ReservedPersonaName = typeof RESERVED_PERSONA_NAMES[number];
@@ -271,14 +275,19 @@ export function isReservedPersonaName(name: string): boolean {
271
275
  export interface Message {
272
276
  id: string;
273
277
  role: "human" | "system";
274
- content: string;
278
+ verbal_response?: string; // Human text or persona's spoken reply
279
+ action_response?: string; // Stage direction / action the persona performs
280
+ silence_reason?: string; // Why the persona chose not to respond (not shown to LLM)
275
281
  timestamp: string;
276
- read: boolean;
282
+ read: boolean; // Has human seen this system message?
277
283
  context_status: ContextStatus;
278
- f?: boolean; // fact extraction done
279
- r?: boolean; // trait extraction done
280
- p?: boolean; // person extraction done
281
- o?: boolean; // topic extraction done
284
+
285
+ // Extraction completion flags (omit when false to save space)
286
+ // Single-letter names minimize storage overhead for large message histories
287
+ f?: boolean; // Fact extraction completed
288
+ r?: boolean; // tRait extraction completed
289
+ p?: boolean; // Person extraction completed
290
+ o?: boolean; // tOpic extraction completed
282
291
  }
283
292
 
284
293
  export interface ChatMessage {
@@ -290,12 +299,15 @@ export interface ChatMessage {
290
299
  // LLM TYPES
291
300
  // =============================================================================
292
301
 
302
+ export type LLMRequestState = "pending" | "processing" | "dlq";
303
+
293
304
  export interface LLMRequest {
294
305
  id: string;
295
306
  created_at: string;
296
307
  attempts: number;
297
308
  last_attempt?: string;
298
309
  retry_after?: string;
310
+ state: LLMRequestState;
299
311
  type: LLMRequestType;
300
312
  priority: LLMPriority;
301
313
  system: string;
@@ -345,6 +357,7 @@ export interface MessageQueryOptions {
345
357
  export interface QueueStatus {
346
358
  state: "idle" | "busy" | "paused";
347
359
  pending_count: number;
360
+ dlq_count: number;
348
361
  current_operation?: string;
349
362
  }
350
363
 
@@ -47,7 +47,7 @@ function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
47
47
  return {
48
48
  id: ocMsg.id,
49
49
  role: ocMsg.role === "user" ? "human" : "system",
50
- content: ocMsg.content,
50
+ verbal_response: ocMsg.content,
51
51
  timestamp: ocMsg.timestamp,
52
52
  read: true,
53
53
  context_status: "default" as ContextStatus,
@@ -6,9 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { HeartbeatCheckPromptData, PromptOutput } from "./types.js";
9
- import type { Message, Topic, Person } from "../../core/types.js";
10
- import { formatMessagesAsPlaceholders } from "../message-utils.js";
11
-
9
+ import { type Message, type Topic, type Person } from "../../core/types.js";
10
+ import { formatMessagesAsPlaceholders, getMessageDisplayText } from "../message-utils.js";
12
11
  function formatTopicsWithGaps(topics: Topic[]): string {
13
12
  if (topics.length === 0) return "(No topics with engagement gaps)";
14
13
 
@@ -31,23 +30,37 @@ function formatPeopleWithGaps(people: Person[]): string {
31
30
  .join('\n');
32
31
  }
33
32
 
33
+ /**
34
+ * A "real" persona message is one the persona actually said to the human.
35
+ * silence_reason messages (persona chose not to speak) and action-only messages
36
+ * (no verbal_response) don't count as conversational outreach.
37
+ */
38
+ function isConversationalMessage(m: Message): boolean {
39
+ if (m.role !== 'system') return false;
40
+ if (m.silence_reason !== undefined) return false;
41
+ // Action-only: has action but no verbal response
42
+ if (!m.verbal_response) return false;
43
+ return true;
44
+ }
45
+
34
46
  function countTrailingPersonaMessages(history: Message[]): number {
35
47
  if (history.length === 0) return 0;
36
48
 
37
49
  let count = 0;
38
50
  for (let i = history.length - 1; i >= 0; i--) {
39
- // In heartbeat context, persona messages are "system" role (not from human)
40
- if (history[i].role === "system") {
41
- count++;
42
- } else {
43
- break;
44
- }
51
+ const msg = history[i];
52
+ if (msg.role === 'human') break;
53
+ if (isConversationalMessage(msg)) count++;
54
+ // Skip non-conversational system messages and keep looking back
45
55
  }
46
56
  return count;
47
57
  }
48
58
 
49
59
  function getLastPersonaMessage(history: Message[]): Message | undefined {
50
- return history.filter(m => m.role === "system").slice(-1)[0];
60
+ for (let i = history.length - 1; i >= 0; i--) {
61
+ if (isConversationalMessage(history[i])) return history[i];
62
+ }
63
+ return undefined;
51
64
  }
52
65
 
53
66
  /**
@@ -148,9 +161,10 @@ ${formatMessagesAsPlaceholders(data.recent_history, personaName)}`;
148
161
 
149
162
  let unansweredWarning = '';
150
163
  if (lastPersonaMsg && consecutiveMessages >= 1) {
151
- const preview = lastPersonaMsg.content.length > 100
152
- ? lastPersonaMsg.content.substring(0, 100) + "..."
153
- : lastPersonaMsg.content;
164
+ const rawPreview = getMessageDisplayText(lastPersonaMsg) ?? "";
165
+ const preview = rawPreview.length > 100
166
+ ? rawPreview.substring(0, 100) + "..."
167
+ : rawPreview;
154
168
 
155
169
  unansweredWarning = `
156
170
  ### CRITICAL: You Already Reached Out
@@ -1,6 +1,6 @@
1
1
  import type { EiHeartbeatPromptData, EiHeartbeatItem, PromptOutput } from "./types.js";
2
2
  import type { Message } from "../../core/types.js";
3
- import { formatMessagesAsPlaceholders } from "../message-utils.js";
3
+ import { formatMessagesAsPlaceholders, getMessageDisplayText } from "../message-utils.js";
4
4
 
5
5
  function formatItem(item: EiHeartbeatItem): string {
6
6
  switch (item.type) {
@@ -105,9 +105,10 @@ ${formatMessagesAsPlaceholders(data.recent_history, "Ei")}`;
105
105
 
106
106
  let unansweredWarning = "";
107
107
  if (lastEiMsg && consecutiveMessages >= 1) {
108
- const preview = lastEiMsg.content.length > 100
109
- ? lastEiMsg.content.substring(0, 100) + "..."
110
- : lastEiMsg.content;
108
+ const rawPreview = getMessageDisplayText(lastEiMsg) ?? "";
109
+ const preview = rawPreview.length > 100
110
+ ? rawPreview.substring(0, 100) + "..."
111
+ : rawPreview;
111
112
 
112
113
  unansweredWarning = `
113
114
  ### CRITICAL: You Already Reached Out
@@ -240,13 +240,26 @@ In addition to updating the ${typeLabel}, identify any **memorable, funny, impor
240
240
  - Phrases that reveal personality or communication style
241
241
  - Things you'd quote back to them later to make them laugh
242
242
  - Unique expressions, malaphors, or turns of phrase
243
-
244
- **De-prioritize:**
245
- - Dry technical facts (those belong in TOPICS)
246
- - Status updates or process descriptions
247
- - Generic statements that could come from anyone
248
-
249
- A quote like "Does the Pope shit in his hat?" is GOLD. A quote like "We're running a batch of 44k students" is just... data.
243
+ - Quotable moments from EITHER speaker — humans AND AI personas both say memorable things
244
+
245
+ **NEVER extract these they are NOT quotes:**
246
+ - Technical identifiers: ARNs, URLs, file paths, UUIDs, config keys, environment variable values, role/policy names
247
+ - AI agent self-talk: "I notice I'm in Plan Mode", "I'll start by...", "Let me help you with...", status updates about the agent's own process
248
+ - AI apologies or acknowledgments: "You're absolutely right", "I apologize for that overreach", "Good decision to revert"
249
+ - Generic AI instructions or tips: "Remember to include X in your prompts", tool usage advice, workflow suggestions
250
+ - Dry technical facts: infrastructure descriptions, process status, batch sizes, system architecture summaries
251
+ - Status updates or process descriptions: "We're running a batch of...", "The pipeline is...", "I'm currently working on..."
252
+ - Generic statements that could come from anyone or any AI session
253
+ - Credentials, secrets, connection strings, or anything that looks like an access token
254
+
255
+ **The litmus test**: Would you bring this up at a bar with a friend? Would it make someone laugh, think, or feel something?
256
+ - "Does the Pope shit in his hat?" → YES. Hilarious malaphor.
257
+ - "AWSReservedSSO_cmidp-nihl-sandbox-adm_db7b191e026bdd85" → NO. That's a credential.
258
+ - "Slow is smooth. Smooth is fast." → YES (once). Pithy wisdom.
259
+ - "The authentication flow is working correctly now" → NO. Status update.
260
+ - "I built this, and now it's live." → YES. Pride and accomplishment.
261
+
262
+ **When in doubt, leave it out.** An empty quotes array is always acceptable.
250
263
 
251
264
  Return them in the \`quotes\` array:
252
265
 
@@ -266,7 +279,6 @@ Return them in the \`quotes\` array:
266
279
 
267
280
  **CRITICAL**: Return the EXACT text as it appears in the message (spacing, punctuation, formatting, etc.). WE CAN ONLY USE IT IF WE FIND IT IN THE TEXT.
268
281
 
269
-
270
282
  # CRITICAL INSTRUCTIONS
271
283
 
272
284
  ONLY ANALYZE the "Most Recent Messages" in the following conversation. The "Earlier Conversation" is provided for your context and has already been processed!
@@ -2,6 +2,42 @@ import type { Message } from "../core/types.js";
2
2
 
3
3
  const MESSAGE_PLACEHOLDER_REGEX = /\[mid:([a-zA-Z0-9_-]+):([^\]]+)\]/g;
4
4
 
5
+ /**
6
+ * Returns the display text for a message from its structured fields.
7
+ * - action_response as _italics_
8
+ * - verbal_response as plain text
9
+ * - silence_reason shown so the user understands why a persona stayed silent
10
+ */
11
+ export function getMessageDisplayText(message: Message): string | null {
12
+ const parts: string[] = [];
13
+ if (message.action_response) parts.push(`_${message.action_response}_`);
14
+ if (message.verbal_response) parts.push(message.verbal_response);
15
+ if (message.silence_reason) {
16
+ const name = 'Persona'; // Caller doesn't pass persona name; frontends can override
17
+ parts.push(`[${name} chose not to respond because: ${message.silence_reason}]`);
18
+ }
19
+ if (parts.length === 0) return null;
20
+ return parts.join('\n\n');
21
+ }
22
+
23
+ /**
24
+ * Builds the content string for a ChatMessage sent to the LLM.
25
+ * Unlike getMessageDisplayText (which is for frontend rendering and skips silence),
26
+ * this includes ALL structured fields so the persona has full conversational context:
27
+ * - action_response as _italics_
28
+ * - verbal_response as plain text
29
+ * - silence_reason as "You chose not to respond because: ..."
30
+ */
31
+ export function buildChatMessageContent(message: Message): string {
32
+ const parts: string[] = [];
33
+ if (message.action_response) parts.push(`_${message.action_response}_`);
34
+ if (message.verbal_response) parts.push(message.verbal_response);
35
+ if (message.silence_reason) {
36
+ parts.push(`You chose not to respond because: ${message.silence_reason}`);
37
+ }
38
+ return parts.join('\n\n');
39
+ }
40
+
5
41
  export function formatMessageAsPlaceholder(message: Message, personaName: string): string {
6
42
  const role = message.role === "human" ? "human" : personaName;
7
43
  return `[mid:${message.id}:${role}]`;
@@ -22,7 +58,8 @@ export function hydratePromptPlaceholders(
22
58
  return `[${role}]: [message not found]`;
23
59
  }
24
60
  const displayRole = message.role === "human" ? "[human]" : `[${role}]`;
25
- return `${displayRole}: ${message.content}`;
61
+ const text = getMessageDisplayText(message) ?? "[no content]";
62
+ return `${displayRole}: ${text}`;
26
63
  });
27
64
  }
28
65
 
@@ -19,9 +19,10 @@ import {
19
19
  buildQuotesSection,
20
20
  buildSystemKnowledgeSection,
21
21
  getConversationStateText,
22
+ buildResponseFormatSection,
22
23
  } from "./sections.js";
23
24
 
24
- export type { ResponsePromptData, PromptOutput } from "./types.js";
25
+ export type { ResponsePromptData, PromptOutput, PersonaResponseResult } from "./types.js";
25
26
 
26
27
  /**
27
28
  * Special system prompt for Ei (the system guide persona)
@@ -47,6 +48,7 @@ Your role is unique among personas:
47
48
  const associatesSection = buildAssociatesSection(data.visible_personas);
48
49
  const systemKnowledge = buildSystemKnowledgeSection(data.isTUI);
49
50
  const priorities = buildPrioritiesSection(data.persona, data.human);
51
+ const responseFormat = buildResponseFormatSection();
50
52
  const currentTime = new Date().toISOString();
51
53
 
52
54
  return `${identity}
@@ -63,16 +65,19 @@ ${associatesSection}
63
65
  ${systemKnowledge}
64
66
  ${priorities}
65
67
 
68
+ ${responseFormat}
69
+
66
70
  Current time: ${currentTime}
67
71
 
68
72
  ## Final Instructions
69
73
  - NEVER repeat or echo the user's message in your response. Start directly with your own words.
70
74
  - The developers cannot see any message sent by the user, any response from personas, or any other data in the system.
71
75
  - If the user has a problem, THEY need to visit https://flare576.com. You cannot send the devs a message
72
- - DO NOT INCLUDE <thinking> PROCESS NOTES - adding "internal monologue" or story content is fine, but do not include analysis of the user's messages
73
- - If you decide not to respond, say exactly: No Message`;
76
+ - Your entire reply must be the JSON object. No prose before or after it.`
74
77
  }
75
78
 
79
+ const RESPONSE_FORMAT_INSTRUCTION = `Respond to the conversation above using the JSON format specified in the Response Format section.`;
80
+
76
81
  /**
77
82
  * Standard system prompt for non-Ei personas
78
83
  */
@@ -85,6 +90,7 @@ function buildStandardSystemPrompt(data: ResponsePromptData): string {
85
90
  const quotesSection = buildQuotesSection(data.human.quotes, data.human);
86
91
  const associatesSection = buildAssociatesSection(data.visible_personas);
87
92
  const priorities = buildPrioritiesSection(data.persona, data.human);
93
+ const responseFormat = buildResponseFormatSection();
88
94
  const currentTime = new Date().toISOString();
89
95
 
90
96
  return `${identity}
@@ -100,12 +106,13 @@ ${quotesSection}
100
106
  ${associatesSection}
101
107
  ${priorities}
102
108
 
109
+ ${responseFormat}
110
+
103
111
  Current time: ${currentTime}
104
112
 
105
113
  ## Final Instructions
106
114
  - NEVER repeat or echo the user's message in your response. Start directly with your own words.
107
- - DO NOT INCLUDE <thinking> PROCESS NOTES - adding "internal monologue" or story content is fine, but do not include analysis of the user's messages
108
- - If you decide not to respond, say exactly: No Message`;
115
+ - Your entire reply must be the JSON object. No prose before or after it.`
109
116
  }
110
117
 
111
118
  function buildUserPrompt(data: ResponsePromptData): string {
@@ -113,7 +120,7 @@ function buildUserPrompt(data: ResponsePromptData): string {
113
120
 
114
121
  return `${conversationState}
115
122
 
116
- Respond to the conversation above. If silence is appropriate, say exactly: No Message`;
123
+ ${RESPONSE_FORMAT_INSTRUCTION}`;
117
124
  }
118
125
 
119
126
  /**