prompt_objects 0.1.0 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ad31b1bb32afdf58701be9b0b163314ce9944a1e2df465f81540e36c5e7aa10
4
- data.tar.gz: 865fdc2ee3c868cbc48ac86e4034472e71ea12b51a31d99b71bc446a50a45eb9
3
+ metadata.gz: 0fe57111f23be7238465e5a386d48d8d694cd4599e2113e6378150951efcd303
4
+ data.tar.gz: '07192c4f47af2d7aa9530fdc61b65cb97fba77ae84133a30533d3f07314e0b79'
5
5
  SHA512:
6
- metadata.gz: 1c1cdf27920d51f61ce89feb4df5f7eb8f6d9c37f43ba53c7578d56193fcf9e3016fc465a0d4e4fcac3a74d9c15ac09ecb3031b867f5b04ac155d2e5bb6aeb75
7
- data.tar.gz: abf9df3fff47f105b8e0b516784712c980a155f0adf1b2fc770f329aabde0144d8f4e4b139627d5cc6d734c1041abfc5dc85fc5e65f6dd4a8d023545e6f1bdcb
6
+ metadata.gz: 66d2e26018f49685968bccfd075b2e7cf74fb4bd0cbdf0730f4f3c68c3101be232235beb86a8a1cde73bdf2707c1a679b916d53c8b4c1fb2f9d455d965e4d246
7
+ data.tar.gz: 5fb3e4b088aaf7a6a99e08e144f090c68e2a63b0b009fe43929d93daa8da7b9e6b0c330ae048c2d6bda9fbcdef385000c0f255695a539e922393fc3c84a59e84
data/frontend/src/App.tsx CHANGED
@@ -9,7 +9,7 @@ import { NotificationPanel } from './components/NotificationPanel'
9
9
  import { ThreadsSidebar } from './components/ThreadsSidebar'
10
10
 
11
11
  export default function App() {
12
- const { sendMessage, respondToNotification, createSession, switchSession, switchLLM, createThread } =
12
+ const { sendMessage, respondToNotification, createSession, switchSession, switchLLM, createThread, updatePrompt } =
13
13
  useWebSocket()
14
14
  const { selectedPO, busOpen, notifications } = useStore()
15
15
  const selectedPOData = useSelectedPO()
@@ -72,6 +72,7 @@ export default function App() {
72
72
  createSession={createSession}
73
73
  switchSession={switchSession}
74
74
  createThread={createThread}
75
+ updatePrompt={updatePrompt}
75
76
  />
76
77
  ) : (
77
78
  <Dashboard />
@@ -1,4 +1,5 @@
1
- import type { PromptObject } from '../types'
1
+ import { useState } from 'react'
2
+ import type { PromptObject, CapabilityInfo } from '../types'
2
3
 
3
4
  interface CapabilitiesPanelProps {
4
5
  po: PromptObject
@@ -6,38 +7,134 @@ interface CapabilitiesPanelProps {
6
7
 
7
8
  export function CapabilitiesPanel({ po }: CapabilitiesPanelProps) {
8
9
  const capabilities = po.capabilities || []
10
+ const universalCapabilities = po.universal_capabilities || []
9
11
 
10
12
  return (
11
13
  <div className="h-full overflow-auto p-4">
12
- <h3 className="text-lg font-medium text-white mb-4">Capabilities</h3>
14
+ {/* Declared Capabilities */}
15
+ <div className="mb-6">
16
+ <h3 className="text-lg font-medium text-white mb-3">
17
+ Declared Capabilities
18
+ <span className="ml-2 text-sm text-gray-500">({capabilities.length})</span>
19
+ </h3>
13
20
 
14
- {capabilities.length === 0 ? (
15
- <div className="text-gray-500 text-center py-8">
16
- No capabilities defined.
17
- </div>
18
- ) : (
19
- <div className="space-y-2">
20
- {capabilities.map((cap, index) => (
21
- <div
22
- key={index}
23
- className="bg-po-surface border border-po-border rounded-lg p-3"
24
- >
25
- <div className="font-mono text-sm text-po-accent">{cap}</div>
26
- </div>
21
+ {capabilities.length === 0 ? (
22
+ <div className="text-gray-500 text-sm py-4 px-3 bg-po-bg rounded-lg border border-po-border">
23
+ No capabilities declared. This PO can only use universal capabilities.
24
+ </div>
25
+ ) : (
26
+ <div className="space-y-1">
27
+ {capabilities.map((cap) => (
28
+ <CapabilityItem key={cap.name} capability={cap} accent />
29
+ ))}
30
+ </div>
31
+ )}
32
+ </div>
33
+
34
+ {/* Universal Capabilities */}
35
+ <div>
36
+ <h3 className="text-lg font-medium text-white mb-3">
37
+ Universal Capabilities
38
+ <span className="ml-2 text-sm text-gray-500">({universalCapabilities.length})</span>
39
+ </h3>
40
+ <p className="text-xs text-gray-500 mb-3">
41
+ Available to all Prompt Objects automatically.
42
+ </p>
43
+
44
+ <div className="space-y-1">
45
+ {universalCapabilities.map((cap) => (
46
+ <CapabilityItem key={cap.name} capability={cap} />
27
47
  ))}
28
48
  </div>
49
+ </div>
50
+ </div>
51
+ )
52
+ }
53
+
54
+ interface CapabilityItemProps {
55
+ capability: CapabilityInfo
56
+ accent?: boolean // Use accent color for name (for declared caps)
57
+ }
58
+
59
+ function CapabilityItem({ capability, accent }: CapabilityItemProps) {
60
+ const [expanded, setExpanded] = useState(false)
61
+
62
+ return (
63
+ <div className="bg-po-bg border border-po-border rounded-lg overflow-hidden">
64
+ <button
65
+ onClick={() => setExpanded(!expanded)}
66
+ className="w-full px-3 py-2 flex items-center justify-between hover:bg-po-surface transition-colors"
67
+ >
68
+ <span className={`font-mono text-sm ${accent ? 'text-po-accent' : 'text-gray-300'}`}>
69
+ {capability.name}
70
+ </span>
71
+ <span className="text-gray-500 text-xs">{expanded ? '▼' : '▶'}</span>
72
+ </button>
73
+ {expanded && (
74
+ <div className="px-3 py-2 border-t border-po-border bg-po-surface space-y-3">
75
+ <p className="text-xs text-gray-400">{capability.description}</p>
76
+
77
+ {capability.parameters && (
78
+ <ParametersDisplay parameters={capability.parameters} />
79
+ )}
80
+ </div>
29
81
  )}
82
+ </div>
83
+ )
84
+ }
30
85
 
31
- <div className="mt-6 p-4 bg-po-bg rounded-lg border border-po-border">
32
- <h4 className="text-sm font-medium text-gray-400 mb-2">
33
- Universal Capabilities
34
- </h4>
35
- <p className="text-xs text-gray-500">
36
- All Prompt Objects automatically have access to universal capabilities
37
- like <code className="text-po-accent">ask_human</code>,{' '}
38
- <code className="text-po-accent">think</code>, and{' '}
39
- <code className="text-po-accent">request_capability</code>.
40
- </p>
86
+ interface ParametersDisplayProps {
87
+ parameters: Record<string, unknown>
88
+ }
89
+
90
+ function ParametersDisplay({ parameters }: ParametersDisplayProps) {
91
+ const properties = (parameters.properties as Record<string, unknown>) || {}
92
+ const required = (parameters.required as string[]) || []
93
+
94
+ const propertyNames = Object.keys(properties)
95
+
96
+ if (propertyNames.length === 0) {
97
+ return null
98
+ }
99
+
100
+ return (
101
+ <div>
102
+ <div className="text-xs text-gray-500 mb-2 font-medium">Parameters</div>
103
+ <div className="space-y-2">
104
+ {propertyNames.map((propName) => {
105
+ const prop = properties[propName] as Record<string, unknown>
106
+ const isRequired = required.includes(propName)
107
+
108
+ const propType = prop.type ? String(prop.type) : null
109
+ const propDescription = prop.description ? String(prop.description) : null
110
+ const propEnum = prop.enum as string[] | undefined
111
+
112
+ return (
113
+ <div key={propName} className="bg-po-bg rounded p-2">
114
+ <div className="flex items-center gap-2">
115
+ <span className="font-mono text-xs text-po-accent">{propName}</span>
116
+ {propType && (
117
+ <span className="text-xs text-gray-600">({propType})</span>
118
+ )}
119
+ {isRequired && (
120
+ <span className="text-xs text-red-400">required</span>
121
+ )}
122
+ </div>
123
+ {propDescription && (
124
+ <p className="text-xs text-gray-500 mt-1">{propDescription}</p>
125
+ )}
126
+ {propEnum && propEnum.length > 0 && (
127
+ <div className="mt-1 flex flex-wrap gap-1">
128
+ {propEnum.map((val) => (
129
+ <span key={val} className="text-xs bg-po-surface px-1.5 py-0.5 rounded text-gray-400">
130
+ {val}
131
+ </span>
132
+ ))}
133
+ </div>
134
+ )}
135
+ </div>
136
+ )
137
+ })}
41
138
  </div>
42
139
  </div>
43
140
  )
@@ -133,13 +133,15 @@ function MessageBubble({ message }: { message: Message }) {
133
133
  const isTool = message.role === 'tool'
134
134
 
135
135
  if (isTool) {
136
+ // Tool messages contain results from tool calls
137
+ const results = message.results || []
138
+ if (results.length === 0) return null
139
+
136
140
  return (
137
- <div className="text-xs text-gray-500 bg-po-bg rounded p-2 font-mono">
138
- <div className="text-gray-400 mb-1">Tool Result:</div>
139
- <div className="whitespace-pre-wrap break-all">
140
- {message.content?.slice(0, 500)}
141
- {message.content && message.content.length > 500 && '...'}
142
- </div>
141
+ <div className="space-y-2 ml-11">
142
+ {results.map((result, idx) => (
143
+ <ToolResultDisplay key={result.tool_call_id || idx} result={result} />
144
+ ))}
143
145
  </div>
144
146
  )
145
147
  }
@@ -249,3 +251,38 @@ function ToolCallDisplay({ toolCall }: { toolCall: ToolCall }) {
249
251
  </div>
250
252
  )
251
253
  }
254
+
255
+ function ToolResultDisplay({ result }: { result: { tool_call_id: string; name?: string; content: string } }) {
256
+ const [expanded, setExpanded] = useState(false)
257
+ const content = result.content || ''
258
+ const truncatedContent = content.slice(0, 200)
259
+ const isTruncated = content.length > 200
260
+
261
+ return (
262
+ <div className="text-xs bg-po-surface/50 border border-po-border/30 rounded overflow-hidden">
263
+ <button
264
+ onClick={() => setExpanded(!expanded)}
265
+ className="w-full px-2 py-1 text-left font-mono flex items-center gap-1 hover:bg-po-surface transition-colors"
266
+ >
267
+ <span className="text-gray-500">{expanded ? '▼' : '▶'}</span>
268
+ <span className="text-gray-400">↳ Result</span>
269
+ {result.name && <span className="text-po-accent/70">from {result.name}</span>}
270
+ </button>
271
+ {expanded && (
272
+ <div className="px-2 pb-2 border-t border-po-border/30">
273
+ <pre className="text-gray-400 whitespace-pre-wrap break-all mt-1 text-[10px] leading-relaxed">
274
+ {content}
275
+ </pre>
276
+ </div>
277
+ )}
278
+ {!expanded && (
279
+ <div className="px-2 pb-1 text-gray-500">
280
+ <span className="whitespace-pre-wrap break-all">
281
+ {truncatedContent}
282
+ {isTruncated && '...'}
283
+ </span>
284
+ </div>
285
+ )}
286
+ </div>
287
+ )
288
+ }
@@ -9,6 +9,7 @@ interface PODetailProps {
9
9
  createSession: (target: string, name?: string) => void
10
10
  switchSession: (target: string, sessionId: string) => void
11
11
  createThread: (target: string, name?: string) => void
12
+ updatePrompt: (target: string, prompt: string) => void
12
13
  }
13
14
 
14
15
  export function PODetail({
@@ -16,6 +17,7 @@ export function PODetail({
16
17
  createSession,
17
18
  switchSession,
18
19
  createThread,
20
+ updatePrompt,
19
21
  }: PODetailProps) {
20
22
  const { activeTab, setActiveTab, selectPO } = useStore()
21
23
  const po = useSelectedPO()
@@ -95,7 +97,12 @@ export function PODetail({
95
97
  />
96
98
  )}
97
99
  {activeTab === 'capabilities' && <CapabilitiesPanel po={po} />}
98
- {activeTab === 'prompt' && <PromptPanel po={po} />}
100
+ {activeTab === 'prompt' && (
101
+ <PromptPanel
102
+ po={po}
103
+ onSave={(prompt) => updatePrompt(po.name, prompt)}
104
+ />
105
+ )}
99
106
  </div>
100
107
  </div>
101
108
  )
@@ -1,13 +1,78 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
1
2
  import type { PromptObject } from '../types'
2
3
  import { MarkdownMessage } from './MarkdownMessage'
3
4
 
4
5
  interface PromptPanelProps {
5
6
  po: PromptObject
7
+ onSave?: (prompt: string) => void
6
8
  }
7
9
 
8
- export function PromptPanel({ po }: PromptPanelProps) {
10
+ export function PromptPanel({ po, onSave }: PromptPanelProps) {
9
11
  const prompt = po.prompt || ''
10
12
  const config = po.config || {}
13
+ const [isEditing, setIsEditing] = useState(false)
14
+ const [editedPrompt, setEditedPrompt] = useState(prompt)
15
+ const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
16
+ const saveTimeoutRef = useRef<number | null>(null)
17
+
18
+ // Sync editedPrompt when po.prompt changes from server
19
+ useEffect(() => {
20
+ if (!isEditing) {
21
+ setEditedPrompt(prompt)
22
+ }
23
+ }, [prompt, isEditing])
24
+
25
+ // Debounced auto-save
26
+ const debouncedSave = useCallback((newPrompt: string) => {
27
+ if (saveTimeoutRef.current) {
28
+ clearTimeout(saveTimeoutRef.current)
29
+ }
30
+
31
+ setSaveStatus('unsaved')
32
+
33
+ saveTimeoutRef.current = window.setTimeout(() => {
34
+ if (onSave && newPrompt !== prompt) {
35
+ setSaveStatus('saving')
36
+ onSave(newPrompt)
37
+ // Assume save succeeded - server will broadcast update
38
+ setTimeout(() => setSaveStatus('saved'), 500)
39
+ } else {
40
+ setSaveStatus('saved')
41
+ }
42
+ }, 1000) // 1 second debounce
43
+ }, [onSave, prompt])
44
+
45
+ const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
46
+ const newPrompt = e.target.value
47
+ setEditedPrompt(newPrompt)
48
+ debouncedSave(newPrompt)
49
+ }
50
+
51
+ const handleStartEditing = () => {
52
+ setEditedPrompt(prompt)
53
+ setIsEditing(true)
54
+ }
55
+
56
+ const handleStopEditing = () => {
57
+ // Save any pending changes
58
+ if (saveTimeoutRef.current) {
59
+ clearTimeout(saveTimeoutRef.current)
60
+ }
61
+ if (editedPrompt !== prompt && onSave) {
62
+ onSave(editedPrompt)
63
+ }
64
+ setIsEditing(false)
65
+ setSaveStatus('saved')
66
+ }
67
+
68
+ // Cleanup timeout on unmount
69
+ useEffect(() => {
70
+ return () => {
71
+ if (saveTimeoutRef.current) {
72
+ clearTimeout(saveTimeoutRef.current)
73
+ }
74
+ }
75
+ }, [])
11
76
 
12
77
  return (
13
78
  <div className="h-full overflow-auto p-4">
@@ -23,29 +88,69 @@ export function PromptPanel({ po }: PromptPanelProps) {
23
88
 
24
89
  {/* Prompt/Body */}
25
90
  <div>
26
- <h3 className="text-lg font-medium text-white mb-3">Prompt</h3>
27
- <div className="bg-po-bg rounded-lg border border-po-border p-4">
28
- {prompt ? (
29
- <div className="prose prose-invert max-w-none">
30
- <MarkdownMessage content={prompt} />
31
- </div>
91
+ <div className="flex items-center justify-between mb-3">
92
+ <h3 className="text-lg font-medium text-white">Prompt</h3>
93
+ <div className="flex items-center gap-3">
94
+ {isEditing && (
95
+ <span className={`text-xs ${
96
+ saveStatus === 'saved' ? 'text-green-400' :
97
+ saveStatus === 'saving' ? 'text-yellow-400' :
98
+ 'text-gray-400'
99
+ }`}>
100
+ {saveStatus === 'saved' ? 'Saved' :
101
+ saveStatus === 'saving' ? 'Saving...' :
102
+ 'Unsaved changes'}
103
+ </span>
104
+ )}
105
+ <button
106
+ onClick={isEditing ? handleStopEditing : handleStartEditing}
107
+ className={`px-3 py-1 text-sm rounded transition-colors ${
108
+ isEditing
109
+ ? 'bg-po-accent text-black hover:bg-po-accent/80'
110
+ : 'bg-po-surface border border-po-border text-gray-300 hover:text-white hover:border-po-accent'
111
+ }`}
112
+ >
113
+ {isEditing ? 'Done' : 'Edit'}
114
+ </button>
115
+ </div>
116
+ </div>
117
+
118
+ <div className="bg-po-bg rounded-lg border border-po-border">
119
+ {isEditing ? (
120
+ <textarea
121
+ value={editedPrompt}
122
+ onChange={handlePromptChange}
123
+ className="w-full h-96 p-4 bg-transparent text-gray-200 font-mono text-sm resize-none focus:outline-none focus:ring-1 focus:ring-po-accent rounded-lg"
124
+ placeholder="Enter prompt markdown..."
125
+ spellCheck={false}
126
+ />
32
127
  ) : (
33
- <p className="text-gray-500 italic">No prompt defined</p>
128
+ <div className="p-4">
129
+ {prompt ? (
130
+ <div className="prose prose-invert max-w-none">
131
+ <MarkdownMessage content={prompt} />
132
+ </div>
133
+ ) : (
134
+ <p className="text-gray-500 italic">No prompt defined. Click Edit to add one.</p>
135
+ )}
136
+ </div>
34
137
  )}
35
138
  </div>
36
139
  </div>
37
140
 
38
- {/* Raw source toggle */}
39
- <details className="mt-6">
40
- <summary className="text-sm text-gray-400 cursor-pointer hover:text-white">
41
- View raw source
42
- </summary>
43
- <div className="mt-2 bg-po-bg rounded-lg border border-po-border p-4">
44
- <pre className="text-xs text-gray-400 font-mono whitespace-pre-wrap overflow-x-auto">
45
- {prompt || '(empty)'}
46
- </pre>
47
- </div>
48
- </details>
141
+ {/* Raw source toggle (only in view mode) */}
142
+ {!isEditing && (
143
+ <details className="mt-6">
144
+ <summary className="text-sm text-gray-400 cursor-pointer hover:text-white">
145
+ View raw source
146
+ </summary>
147
+ <div className="mt-2 bg-po-bg rounded-lg border border-po-border p-4">
148
+ <pre className="text-xs text-gray-400 font-mono whitespace-pre-wrap overflow-x-auto">
149
+ {prompt || '(empty)'}
150
+ </pre>
151
+ </div>
152
+ </details>
153
+ )}
49
154
  </div>
50
155
  )
51
156
  }
@@ -352,6 +352,21 @@ export function useWebSocket() {
352
352
  )
353
353
  }, [])
354
354
 
355
+ // Update a PO's prompt (markdown body)
356
+ const updatePrompt = useCallback((target: string, prompt: string) => {
357
+ if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
358
+ console.error('WebSocket not connected')
359
+ return
360
+ }
361
+
362
+ ws.current.send(
363
+ JSON.stringify({
364
+ type: 'update_prompt',
365
+ payload: { target, prompt },
366
+ })
367
+ )
368
+ }, [])
369
+
355
370
  return {
356
371
  sendMessage,
357
372
  respondToNotification,
@@ -359,5 +374,6 @@ export function useWebSocket() {
359
374
  switchSession,
360
375
  switchLLM,
361
376
  createThread,
377
+ updatePrompt,
362
378
  }
363
379
  }
@@ -16,6 +16,7 @@ export interface ToolCall {
16
16
 
17
17
  export interface ToolResult {
18
18
  tool_call_id: string
19
+ name?: string // Name of the tool that was called
19
20
  content: string
20
21
  }
21
22
 
@@ -42,11 +43,21 @@ export interface CurrentSession {
42
43
  messages: Message[]
43
44
  }
44
45
 
46
+ export interface CapabilityInfo {
47
+ name: string
48
+ description: string
49
+ parameters?: Record<string, unknown>
50
+ }
51
+
52
+ // Alias for backwards compatibility
53
+ export type UniversalCapability = CapabilityInfo
54
+
45
55
  export interface PromptObject {
46
56
  name: string
47
57
  description: string
48
58
  status: 'idle' | 'thinking' | 'calling_tool'
49
- capabilities: string[]
59
+ capabilities: CapabilityInfo[]
60
+ universal_capabilities?: CapabilityInfo[]
50
61
  current_session: CurrentSession | null
51
62
  sessions: Session[]
52
63
  prompt?: string // The markdown body/prompt
@@ -40,6 +40,7 @@ module PromptObjects
40
40
  :manifest, :env_path, :auto_commit, :session_store,
41
41
  :current_provider, :current_model
42
42
  attr_accessor :on_po_registered # Callback for when a PO is registered
43
+ attr_accessor :on_po_modified # Callback for when a PO is modified (capabilities changed, etc.)
43
44
 
44
45
  # Initialize from an environment path (with manifest) or objects directory.
45
46
  # @param env_path [String, nil] Path to environment directory (preferred)
@@ -207,6 +208,13 @@ module PromptObjects
207
208
  po
208
209
  end
209
210
 
211
+ # Notify that a PO has been modified (for live updates in web UI).
212
+ # Call this after modifying a PO's config/capabilities programmatically.
213
+ # @param po [PromptObject] The modified PO
214
+ def notify_po_modified(po)
215
+ @on_po_modified&.call(po)
216
+ end
217
+
210
218
  # Load a prompt object by name from the objects directory.
211
219
  # @param name [String] Name of the prompt object (without .md extension)
212
220
  # @return [PromptObject]
@@ -267,13 +275,16 @@ module PromptObjects
267
275
  @registry.register(Universal::Think.new)
268
276
  @registry.register(Universal::CreateCapability.new)
269
277
  @registry.register(Universal::AddCapability.new)
278
+ @registry.register(Universal::RemoveCapability.new)
270
279
  @registry.register(Universal::ListCapabilities.new)
271
280
  @registry.register(Universal::ListPrimitives.new)
272
281
  @registry.register(Universal::AddPrimitive.new)
273
282
  @registry.register(Universal::CreatePrimitive.new)
283
+ @registry.register(Universal::DeletePrimitive.new)
274
284
  @registry.register(Universal::VerifyPrimitive.new)
275
285
  @registry.register(Universal::ModifyPrimitive.new)
276
286
  @registry.register(Universal::RequestPrimitive.new)
287
+ @registry.register(Universal::ModifyPrompt.new)
277
288
  end
278
289
  end
279
290
 
@@ -5,6 +5,7 @@ module PromptObjects
5
5
  # It interprets messages semantically using its markdown "soul" as the system prompt.
6
6
  class PromptObject < Capability
7
7
  attr_reader :config, :body, :history, :session_id, :path
8
+ attr_accessor :on_history_updated # Callback for real-time updates during receive loop
8
9
 
9
10
  # @param config [Hash] Parsed frontmatter (name, description, capabilities)
10
11
  # @param body [String] Markdown body (the "soul" - becomes system prompt)
@@ -95,6 +96,9 @@ module PromptObjects
95
96
  tool_msg = { role: :tool, results: results }
96
97
  @history << tool_msg
97
98
  persist_message(tool_msg)
99
+
100
+ # Notify callback for real-time UI updates (tool calls as they happen)
101
+ notify_history_updated
98
102
  else
99
103
  # No tool calls - we have our final response
100
104
  assistant_msg = { role: :assistant, content: response.content }
@@ -341,6 +345,12 @@ module PromptObjects
341
345
  messages.last&.dig(:id)
342
346
  end
343
347
 
348
+ # Notify the history updated callback if registered.
349
+ # Used for real-time UI updates during the receive loop.
350
+ def notify_history_updated
351
+ @on_history_updated&.call(self, @session_id, @history)
352
+ end
353
+
344
354
  # --- Session Persistence Helpers ---
345
355
 
346
356
  # Load existing session or create a new one.