Class: Kward::Conversation

Inherits:
Object
  • Object
show all
Defined in:
lib/kward/conversation.rb

Overview

Mutable transcript and runtime context for one agent session.

Conversation owns message ordering, system prompt refresh, read-before-write state, memory prompt context, and persistence hooks. It intentionally stores plain hashes because provider payload builders, session JSONL files, and RPC normalizers all share the same transcript shape. Use MessageAccess when reading messages so symbol/string key and legacy field compatibility stays in one place.

Frontends should not mutate messages directly after attaching a SessionStore::Session; use append/compact helpers so persistence callbacks run and session trees stay consistent.

Constant Summary collapse

DEFAULT_SYSTEM_MESSAGE =
Object.new.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil) ⇒ Conversation

Returns a new instance of Conversation.



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/kward/conversation.rb', line 55

def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
  @provider = provider
  @model = model
  @reasoning_effort = reasoning_effort
  @plugin_registry = plugin_registry
  @messages = []
  if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
    system_message = messages.any? { |message| MessageAccess.role(message) == "system" } ? nil : Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: memory_context, plugin_context: plugin_prompt_context)
  end
  @system_message_enabled = !!(system_message || messages.find { |message| MessageAccess.role(message) == "system" })
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
    compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort) : nil
  end
  @compaction_system_message = compaction_system_message
  @workspace_agents_mtime = workspace_agents_mtime
  @last_entry_compaction = false
  @memory_context = memory_context
  @session_memories = Array(session_memories)
  @last_memory_retrieval = last_memory_retrieval
  @messages << system_message unless system_message.nil?
  @messages.concat(messages)
  @read_paths = Set.new(read_paths)
  @on_append = on_append
  @on_compact = on_compact
  @on_tool_execution = on_tool_execution
  @on_runtime_update = on_runtime_update
end

Instance Attribute Details

#compaction_system_messageHash? (readonly)

Returns system prompt used when summarizing old context.

Returns:

  • (Hash, nil)

    system prompt used when summarizing old context



31
32
33
# File 'lib/kward/conversation.rb', line 31

def compaction_system_message
  @compaction_system_message
end

#last_memory_retrievalHash?

Returns metadata for the last memory retrieval attached to the session.

Returns:

  • (Hash, nil)

    metadata for the last memory retrieval attached to the session



51
52
53
# File 'lib/kward/conversation.rb', line 51

def last_memory_retrieval
  @last_memory_retrieval
end

#memory_contextString?

Returns memory prompt context injected into refreshed system messages.

Returns:

  • (String, nil)

    memory prompt context injected into refreshed system messages



49
50
51
# File 'lib/kward/conversation.rb', line 49

def memory_context
  @memory_context
end

#messagesArray<Hash> (readonly)

Returns ordered transcript entries sent to providers and persisted in sessions.

Returns:

  • (Array<Hash>)

    ordered transcript entries sent to providers and persisted in sessions



25
26
27
# File 'lib/kward/conversation.rb', line 25

def messages
  @messages
end

#modelString? (readonly)

Returns model id captured for session/runtime prompts.

Returns:

  • (String, nil)

    model id captured for session/runtime prompts



35
36
37
# File 'lib/kward/conversation.rb', line 35

def model
  @model
end

#on_appendProc?

Returns persistence callback invoked after appending a message.

Returns:

  • (Proc, nil)

    persistence callback invoked after appending a message



41
42
43
# File 'lib/kward/conversation.rb', line 41

def on_append
  @on_append
end

#on_compactProc?

Returns persistence callback invoked after compaction replaces history.

Returns:

  • (Proc, nil)

    persistence callback invoked after compaction replaces history



43
44
45
# File 'lib/kward/conversation.rb', line 43

def on_compact
  @on_compact
end

#on_runtime_updateProc?

Returns callback invoked when runtime metadata should be persisted.

Returns:

  • (Proc, nil)

    callback invoked when runtime metadata should be persisted



47
48
49
# File 'lib/kward/conversation.rb', line 47

def on_runtime_update
  @on_runtime_update
end

#on_tool_executionProc?

Returns callback invoked when a tool execution record should be persisted.

Returns:

  • (Proc, nil)

    callback invoked when a tool execution record should be persisted



45
46
47
# File 'lib/kward/conversation.rb', line 45

def on_tool_execution
  @on_tool_execution
end

#plugin_registryPluginRegistry?

Returns registry used to collect plugin prompt context.

Returns:

  • (PluginRegistry, nil)

    registry used to collect plugin prompt context



53
54
55
# File 'lib/kward/conversation.rb', line 53

def plugin_registry
  @plugin_registry
end

#providerString? (readonly)

Returns provider captured for session/runtime prompts.

Returns:

  • (String, nil)

    provider captured for session/runtime prompts



33
34
35
# File 'lib/kward/conversation.rb', line 33

def provider
  @provider
end

#read_pathsSet<String> (readonly)

Returns resolved paths read by file tools during the active context.

Returns:

  • (Set<String>)

    resolved paths read by file tools during the active context



27
28
29
# File 'lib/kward/conversation.rb', line 27

def read_paths
  @read_paths
end

#reasoning_effortString? (readonly)

Returns reasoning effort captured for session/runtime prompts.

Returns:

  • (String, nil)

    reasoning effort captured for session/runtime prompts



37
38
39
# File 'lib/kward/conversation.rb', line 37

def reasoning_effort
  @reasoning_effort
end

#session_memoriesArray<Hash> (readonly)

Returns memories scoped to this conversation session.

Returns:

  • (Array<Hash>)

    memories scoped to this conversation session



39
40
41
# File 'lib/kward/conversation.rb', line 39

def session_memories
  @session_memories
end

#workspace_rootString (readonly)

Returns canonical workspace root used for prompts and file guardrails.

Returns:

  • (String)

    canonical workspace root used for prompts and file guardrails



29
30
31
# File 'lib/kward/conversation.rb', line 29

def workspace_root
  @workspace_root
end

Instance Method Details

#append_assistant(message) ⇒ Object



95
96
97
98
# File 'lib/kward/conversation.rb', line 95

def append_assistant(message)
  message = { role: "assistant", content: message } if message.is_a?(String)
  append_message(message)
end

#append_tool(tool_call_id:, name:, content:) ⇒ Object



100
101
102
103
104
105
106
107
# File 'lib/kward/conversation.rb', line 100

def append_tool(tool_call_id:, name:, content:)
  append_message({
    role: "tool",
    tool_call_id: tool_call_id,
    name: name,
    content: content
  })
end

#append_tool_execution(tool_call:, content:) ⇒ Object



109
110
111
# File 'lib/kward/conversation.rb', line 109

def append_tool_execution(tool_call:, content:)
  @on_tool_execution&.call(tool_call, content)
end

#append_user(content, display_content: nil) ⇒ Object

Appends a user message and normalizes image attachment syntax.

display_content is transcript/UI text for cases where the model input is expanded, decorated, or contains encoded attachment content.



88
89
90
91
92
93
# File 'lib/kward/conversation.rb', line 88

def append_user(content, display_content: nil)
  content = ImageAttachments.content_from_text(content) unless content.is_a?(Array)
  message = { role: "user", content: content }
  message[:display_content] = display_content.to_s unless display_content.nil?
  append_message(message)
end

#compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: []) ⇒ Object

Replaces most transcript entries with a compaction summary and optional recent messages to keep.

Compaction clears read-before-write state because file contents observed before the summary may no longer be represented exactly in the active context. Callers that need file mutation after compaction should read files again through the normal tools.



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/kward/conversation.rb', line 163

def compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: [])
  message = if compaction_summary
              { role: "compactionSummary", summary: summary.to_s }
            else
              { role: "assistant", content: summary.to_s }
            end
  if compaction_summary
    message[:first_kept_entry_id] = first_kept_entry_id if first_kept_entry_id
    message[:tokens_before] = tokens_before if tokens_before
    message[:from_hook] = from_hook
    message[:details] = details || {}
  end
  @messages = @messages.select { |item| MessageAccess.role(item) == "system" }
  @messages << message
  @messages.concat(Array(keep_messages))
  @read_paths.clear
  @last_entry_compaction = true
  @on_compact&.call(message)
  message
end

#last_entry_compaction?Boolean

Returns:

  • (Boolean)


184
185
186
# File 'lib/kward/conversation.rb', line 184

def last_entry_compaction?
  @last_entry_compaction
end

#last_file_change_resultObject



192
193
194
195
196
# File 'lib/kward/conversation.rb', line 192

def last_file_change_result
  @messages.select do |message|
    MessageAccess.role(message) == "tool" && ["write_file", "edit_file"].include?(MessageAccess.name(message))
  end.last
end

#mark_last_entry_compaction!Object



188
189
190
# File 'lib/kward/conversation.rb', line 188

def mark_last_entry_compaction!
  @last_entry_compaction = true
end

#mark_read(path) ⇒ Object



145
146
147
# File 'lib/kward/conversation.rb', line 145

def mark_read(path)
  @read_paths << path
end

#persist_runtime_context!Object



137
138
139
# File 'lib/kward/conversation.rb', line 137

def persist_runtime_context!
  @on_runtime_update&.call(provider: @provider, model: @model, reasoning_effort: @reasoning_effort)
end

#plugin_prompt_contextObject



149
150
151
152
153
154
# File 'lib/kward/conversation.rb', line 149

def plugin_prompt_context
  return nil unless plugin_registry

  context = PluginRegistry::Context.new(conversation: self, workspace_root: @workspace_root)
  plugin_registry.prompt_context(context)
end

#refresh_system_message!Object

Rebuilds the system message from current config, memory, plugins, and workspace AGENTS.md state.

Conversations created with system_message: nil keep system prompts disabled; this preserves tests, compaction summaries, and imported transcripts that intentionally do not include runtime instructions.



119
120
121
122
123
124
125
126
127
128
# File 'lib/kward/conversation.rb', line 119

def refresh_system_message!
  return nil unless @system_message_enabled

  replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: @memory_context, plugin_context: plugin_prompt_context)
  index = @messages.index { |message| MessageAccess.role(message) == "system" }
  index ? @messages[index] = replacement : @messages.unshift(replacement)
  @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort)
  @workspace_agents_mtime = workspace_agents_mtime
  replacement
end

#refresh_system_message_if_workspace_agents_changed!Object



141
142
143
# File 'lib/kward/conversation.rb', line 141

def refresh_system_message_if_workspace_agents_changed!
  refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
end

#update_runtime_context!(provider: nil, model:, reasoning_effort:) ⇒ Object



130
131
132
133
134
135
# File 'lib/kward/conversation.rb', line 130

def update_runtime_context!(provider: nil, model:, reasoning_effort:)
  @provider = provider unless provider.to_s.empty?
  @model = model
  @reasoning_effort = reasoning_effort
  refresh_system_message!
end