kward 0.67.1 → 0.69.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.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -4,23 +4,78 @@ require_relative "message_access"
4
4
  require_relative "plugin_registry"
5
5
  require_relative "prompts"
6
6
 
7
+ # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
9
+ # Mutable transcript and runtime context for one agent session.
10
+ #
11
+ # `Conversation` owns message ordering, system prompt refresh, read-before-write
12
+ # state, memory prompt context, and persistence hooks. It intentionally stores
13
+ # plain hashes because provider payload builders, session JSONL files, and RPC
14
+ # normalizers all share the same transcript shape. Use `MessageAccess` when
15
+ # reading messages so symbol/string key and legacy field compatibility stays in
16
+ # one place.
17
+ #
18
+ # Frontends should not mutate `messages` directly after attaching a
19
+ # `SessionStore::Session`; use append/compact helpers so persistence callbacks
20
+ # run and session trees stay consistent.
8
21
  class Conversation
9
22
  DEFAULT_SYSTEM_MESSAGE = Object.new.freeze
10
23
 
11
- attr_reader :messages, :read_paths, :workspace_root, :compaction_system_message, :model, :reasoning_effort, :session_memories
12
- attr_accessor :on_append, :on_compact, :on_tool_execution, :memory_context, :last_memory_retrieval, :plugin_registry
13
-
14
- def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
24
+ # @return [Array<Hash>] ordered durable transcript entries, excluding runtime system prompt state
25
+ attr_reader :messages
26
+ # @return [Hash, nil] current system prompt included when building provider request context
27
+ attr_reader :system_message
28
+ # @return [Set<String>] resolved paths read by file tools during the active context
29
+ attr_reader :read_paths
30
+ # @return [String] canonical workspace root used for prompts and file guardrails
31
+ attr_reader :workspace_root
32
+ # @return [Hash, nil] system prompt used when summarizing old context
33
+ attr_reader :compaction_system_message
34
+ # @return [String, nil] provider captured for session/runtime prompts
35
+ attr_reader :provider
36
+ # @return [String, nil] model id captured for session/runtime prompts
37
+ attr_reader :model
38
+ # @return [String, nil] reasoning effort captured for session/runtime prompts
39
+ attr_reader :reasoning_effort
40
+ # @return [Array<Hash>] memories scoped to this conversation session
41
+ attr_reader :session_memories
42
+ # @return [Proc, nil] persistence callback invoked after appending a message
43
+ attr_accessor :on_append
44
+ # @return [Proc, nil] persistence callback invoked after compaction replaces history
45
+ attr_accessor :on_compact
46
+ # @return [Proc, nil] callback invoked when a tool execution record should be persisted
47
+ attr_accessor :on_tool_execution
48
+ # @return [Proc, nil] callback invoked when runtime metadata should be persisted
49
+ attr_accessor :on_runtime_update
50
+ # @return [Proc, nil] callback invoked when the system prompt runtime state changes
51
+ attr_accessor :on_system_message_change
52
+ # @return [String, nil] memory prompt context injected into refreshed system messages
53
+ attr_accessor :memory_context
54
+ # @return [Hash, nil] metadata for the last memory retrieval attached to the session
55
+ attr_accessor :last_memory_retrieval
56
+ # @return [PluginRegistry, nil] registry used to collect plugin prompt context
57
+ attr_accessor :plugin_registry
58
+ # @return [String, nil] plugin prompt context used in the current system prompt
59
+ attr_reader :last_plugin_prompt_context
60
+
61
+ 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)
15
62
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
63
+ @provider = provider
16
64
  @model = model
17
65
  @reasoning_effort = reasoning_effort
18
66
  @plugin_registry = plugin_registry
19
67
  @messages = []
68
+ restored_system_message, transcript_messages = split_system_message(messages)
20
69
  if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
21
- 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)
70
+ if restored_system_message
71
+ system_message = restored_system_message
72
+ else
73
+ @last_plugin_prompt_context = plugin_prompt_context
74
+ system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
75
+ end
22
76
  end
23
- @system_message_enabled = !!(system_message || messages.find { |message| MessageAccess.role(message) == "system" })
77
+ @system_message = system_message
78
+ @system_message_enabled = !@system_message.nil?
24
79
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
25
80
  compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort) : nil
26
81
  end
@@ -30,14 +85,19 @@ module Kward
30
85
  @memory_context = memory_context
31
86
  @session_memories = Array(session_memories)
32
87
  @last_memory_retrieval = last_memory_retrieval
33
- @messages << system_message unless system_message.nil?
34
- @messages.concat(messages)
88
+ @messages.concat(transcript_messages)
35
89
  @read_paths = Set.new(read_paths)
36
90
  @on_append = on_append
37
91
  @on_compact = on_compact
38
92
  @on_tool_execution = on_tool_execution
93
+ @on_runtime_update = on_runtime_update
94
+ @on_system_message_change = nil
39
95
  end
40
96
 
97
+ # Appends a user message and normalizes image attachment syntax.
98
+ #
99
+ # `display_content` is transcript/UI text for cases where the model input is
100
+ # expanded, decorated, or contains encoded attachment content.
41
101
  def append_user(content, display_content: nil)
42
102
  content = ImageAttachments.content_from_text(content) unless content.is_a?(Array)
43
103
  message = { role: "user", content: content }
@@ -63,23 +123,40 @@ module Kward
63
123
  @on_tool_execution&.call(tool_call, content)
64
124
  end
65
125
 
126
+ # @return [Array<Hash>] provider request context: current system prompt plus durable transcript
127
+ def context_messages
128
+ @system_message ? [@system_message] + @messages : @messages.dup
129
+ end
130
+
131
+ # Rebuilds the system message from current config, memory, plugins, and
132
+ # workspace AGENTS.md state.
133
+ #
134
+ # Conversations created with `system_message: nil` keep system prompts
135
+ # disabled; this preserves tests, compaction summaries, and imported
136
+ # transcripts that intentionally do not include runtime instructions.
66
137
  def refresh_system_message!
67
138
  return nil unless @system_message_enabled
68
139
 
69
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: @memory_context, plugin_context: plugin_prompt_context)
70
- index = @messages.index { |message| MessageAccess.role(message) == "system" }
71
- index ? @messages[index] = replacement : @messages.unshift(replacement)
140
+ @last_plugin_prompt_context = plugin_prompt_context
141
+ replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
142
+ @system_message = replacement
143
+ @on_system_message_change&.call(replacement)
72
144
  @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort)
73
145
  @workspace_agents_mtime = workspace_agents_mtime
74
146
  replacement
75
147
  end
76
148
 
77
- def update_runtime_context!(model:, reasoning_effort:)
149
+ def update_runtime_context!(provider: nil, model:, reasoning_effort:)
150
+ @provider = provider unless provider.to_s.empty?
78
151
  @model = model
79
152
  @reasoning_effort = reasoning_effort
80
153
  refresh_system_message!
81
154
  end
82
155
 
156
+ def persist_runtime_context!
157
+ @on_runtime_update&.call(provider: @provider, model: @model, reasoning_effort: @reasoning_effort)
158
+ end
159
+
83
160
  def refresh_system_message_if_workspace_agents_changed!
84
161
  refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
85
162
  end
@@ -95,6 +172,13 @@ module Kward
95
172
  plugin_registry.prompt_context(context)
96
173
  end
97
174
 
175
+ # Replaces most transcript entries with a compaction summary and optional
176
+ # recent messages to keep.
177
+ #
178
+ # Compaction clears read-before-write state because file contents observed
179
+ # before the summary may no longer be represented exactly in the active
180
+ # context. Callers that need file mutation after compaction should read files
181
+ # again through the normal tools.
98
182
  def compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: [])
99
183
  message = if compaction_summary
100
184
  { role: "compactionSummary", summary: summary.to_s }
@@ -107,7 +191,7 @@ module Kward
107
191
  message[:from_hook] = from_hook
108
192
  message[:details] = details || {}
109
193
  end
110
- @messages = @messages.select { |item| MessageAccess.role(item) == "system" }
194
+ @messages = []
111
195
  @messages << message
112
196
  @messages.concat(Array(keep_messages))
113
197
  @read_paths.clear
@@ -132,6 +216,19 @@ module Kward
132
216
 
133
217
  private
134
218
 
219
+ def split_system_message(messages)
220
+ system_message = nil
221
+ transcript_messages = []
222
+ Array(messages).each do |message|
223
+ if MessageAccess.role(message) == "system" && system_message.nil?
224
+ system_message = message
225
+ elsif MessageAccess.role(message) != "system"
226
+ transcript_messages << message
227
+ end
228
+ end
229
+ [system_message, transcript_messages]
230
+ end
231
+
135
232
  def workspace_agents_mtime
136
233
  path = File.join(@workspace_root, "AGENTS.md")
137
234
  File.exist?(path) ? File.mtime(path) : nil
data/lib/kward/events.rb CHANGED
@@ -1,4 +1,6 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Frontend-neutral event objects emitted during agent turns.
2
4
  module Events
3
5
  ReasoningDelta = Struct.new(:delta, keyword_init: true)
4
6
  AssistantDelta = Struct.new(:delta, keyword_init: true)
@@ -1,6 +1,8 @@
1
1
  require "pathname"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Resolves safe output paths for transcript exports.
4
6
  class ExportPath
5
7
  def self.resolve(path, workspace_root:, default_path:, session_dir: nil)
6
8
  explicit = path.to_s.strip
@@ -4,7 +4,9 @@ require "shellwords"
4
4
  require "tmpdir"
5
5
  require "uri"
6
6
 
7
+ # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
9
+ # Image attachment parsing, validation, encoding, and display helpers.
8
10
  module ImageAttachments
9
11
  MAX_IMAGE_BYTES = 20 * 1024 * 1024
10
12
  MIME_TYPES = {
@@ -1,6 +1,8 @@
1
1
  require_relative "message_access"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Markdown renderer for conversation transcripts.
4
6
  class MarkdownTranscript
5
7
  def initialize(conversation)
6
8
  @conversation = conversation
@@ -7,7 +7,9 @@ require_relative "../config_files"
7
7
  require_relative "../message_access"
8
8
  require_relative "../model/client"
9
9
 
10
+ # Namespace for the Kward CLI agent runtime.
10
11
  module Kward
12
+ # Memory subsystem for core, soft, session, and retrieval state.
11
13
  module Memory
12
14
  # Manages Kward's opt-in structured memory store.
13
15
  #
@@ -21,6 +23,7 @@ module Kward
21
23
  DEFAULT_SOFT_TTL_DAYS = 60
22
24
  DEFAULT_SOFT_CONFIDENCE = 0.65
23
25
  EMOTIONAL_PATTERN = /\b(love|loves|romantic|intimate|dependency|depend on me|need me|flirty|crush)\b/i
26
+ MEMORY_DUPLICATE_STOPWORDS = Set.new(%w[a an and as at for in of on or that the this to with])
24
27
 
25
28
  # Details for the most recent retrieval, used by `/memory why`.
26
29
  attr_reader :last_retrieval
@@ -35,6 +38,7 @@ module Kward
35
38
  )
36
39
  end
37
40
 
41
+ # Creates an object for memory storage and retrieval.
38
42
  def initialize(config_path: ConfigFiles.config_path, core_path: ConfigFiles.memory_core_path, soft_path: ConfigFiles.memory_soft_path, events_path: ConfigFiles.memory_events_path, now: nil)
39
43
  @config_path = config_path
40
44
  @core_path = core_path
@@ -145,10 +149,20 @@ module Kward
145
149
  # @param ttl_days [Integer, nil] time-to-live in days
146
150
  # @return [Hash] stored memory record
147
151
  def add_soft(text, scope: "global", tags: [], confidence: DEFAULT_SOFT_CONFIDENCE, ttl_days: DEFAULT_SOFT_TTL_DAYS, source: "manual")
152
+ normalized_scope = clean_scope(scope)
153
+ normalized_text = clean_text(normalize_memory_text(text))
154
+ raise ArgumentError, "Memory text cannot be empty" if normalized_text.empty?
155
+ raise ArgumentError, "Refusing to persist emotional or dependency-forming memory automatically" if source == "inferred" && unsafe_soft_text?(normalized_text)
156
+
157
+ existing = soft_memories.find do |item|
158
+ item["scope"] == normalized_scope && duplicate_memory_text?(normalized_text, [item["text"]])
159
+ end
160
+ return existing if existing
161
+
148
162
  record = {
149
163
  "id" => next_id("soft", soft_memories(include_inactive: true).map { |item| item["id"] }),
150
- "text" => clean_text(normalize_memory_text(text)),
151
- "scope" => clean_scope(scope),
164
+ "text" => normalized_text,
165
+ "scope" => normalized_scope,
152
166
  "tags" => clean_tags(tags),
153
167
  "confidence" => [[confidence.to_f, 0.0].max, 1.0].min,
154
168
  "hits" => 0,
@@ -159,8 +173,6 @@ module Kward
159
173
  "source" => source,
160
174
  "status" => "active"
161
175
  }
162
- raise ArgumentError, "Memory text cannot be empty" if record["text"].empty?
163
- raise ArgumentError, "Refusing to persist emotional or dependency-forming memory automatically" if source == "inferred" && unsafe_soft_text?(record["text"])
164
176
 
165
177
  append_soft(record)
166
178
  append_event("add", event_ref(record, layer: "soft"))
@@ -229,16 +241,20 @@ module Kward
229
241
  end
230
242
 
231
243
  def list(include_inactive: false)
232
- { "core" => core_memories, "soft" => soft_memories(include_inactive: include_inactive) }
244
+ soft = soft_memories(include_inactive: include_inactive)
245
+ soft = deduplicate_soft_records(soft) unless include_inactive
246
+ { "core" => core_memories, "soft" => soft }
233
247
  end
234
248
 
235
249
  def hierarchy(workspace_root: Dir.pwd, include_inactive: false)
236
250
  workspace = workspace_scope(workspace_root)
237
251
  core = core_memories
252
+ soft = soft_memories(include_inactive: include_inactive)
253
+ soft = deduplicate_soft_records(soft) unless include_inactive
238
254
  {
239
255
  "global_core" => core.select { |item| item["scope"] == "global" },
240
256
  "workspace_core" => core.select { |item| item["scope"] == workspace },
241
- "workspace_soft" => soft_memories(include_inactive: include_inactive).select { |item| item["scope"] == workspace }
257
+ "workspace_soft" => soft.select { |item| item["scope"] == workspace }
242
258
  }
243
259
  end
244
260
 
@@ -268,8 +284,7 @@ module Kward
268
284
  core_reasons = core.map { |item| reason_for(item, layer: "core", score: 1.0, reasons: ["scope match", "core memories are preferred"]) }
269
285
 
270
286
  soft_records_all = soft_memories(include_inactive: true)
271
- soft_scored = soft_records_all.filter_map do |item|
272
- next unless item["status"] == "active"
287
+ soft_scored = deduplicate_soft_records(soft_records_all.select { |item| item["status"] == "active" }).filter_map do |item|
273
288
  next unless item["scope"] == workspace
274
289
  next if expired?(item)
275
290
 
@@ -299,6 +314,11 @@ module Kward
299
314
  @last_retrieval || { "enabled" => enabled?, "core" => [], "soft" => [], "reasons" => [], "message" => "No memory retrieval has run yet." }
300
315
  end
301
316
 
317
+ # Formats retrieved memories for system prompt injection.
318
+ #
319
+ # Keep this block compact and explicit: it is read by the model, shown in
320
+ # transcripts, and explained by `/memory why`. Do not include inactive or
321
+ # forgotten memories here; retrieval already decides the active set.
302
322
  def memory_block(retrieval)
303
323
  core = Array(retrieval["core"])
304
324
  soft = Array(retrieval["soft"])
@@ -330,6 +350,11 @@ module Kward
330
350
  lines.join("\n")
331
351
  end
332
352
 
353
+ # Infers bounded session/workspace soft memories from a conversation.
354
+ #
355
+ # This is best-effort and intentionally conservative. It may use an LLM when
356
+ # configured, but failures fall back to heuristic text or no-op behavior so
357
+ # memory summarization never blocks normal session flow.
333
358
  def summarize_conversation(conversation, client: nil)
334
359
  text = messages_for_summarization(conversation).map { |message| MessageAccess.content(message) }.compact.join("\n")
335
360
  existing_texts = Array(conversation.session_memories).map { |memory| memory["text"] }
@@ -341,14 +366,118 @@ module Kward
341
366
  def infer_soft_from_text(text, workspace_root: Dir.pwd, client: nil, existing_texts: [])
342
367
  candidates = heuristic_candidates(text)
343
368
  existing_set = Set.new(existing_texts.map { |t| normalize_for_comparison(t) })
369
+ scope = workspace_scope(workspace_root)
370
+ workspace_scopes = [scope, clean_scope("workspace:#{workspace_root}")].uniq
371
+ workspace_soft_texts = soft_memories.select { |memory| workspace_scopes.include?(memory["scope"]) }.map { |memory| memory["text"] }
344
372
  candidates.filter_map do |candidate|
345
- summarized = summarize_text(candidate, client: client)
373
+ summarized = normalize_inferred_memory_text(summarize_text(candidate, client: client), source_text: candidate)
346
374
  normalized = normalize_for_comparison(summarized)
347
375
  # Skip if this text already exists in provided list or existing soft memories
348
376
  next if existing_set.include?(normalized)
349
- next if soft_memories.any? { |m| normalize_for_comparison(m["text"]) == normalized }
377
+ next if duplicate_memory_text?(summarized, existing_texts)
378
+ next if duplicate_memory_text?(summarized, workspace_soft_texts)
379
+
380
+ record = add_soft(summarized, scope: scope, tags: ["workflow"], confidence: 0.55, source: "inferred")
381
+ workspace_soft_texts << record["text"]
382
+ record
383
+ end
384
+ end
385
+
386
+ def normalize_inferred_memory_text(text, source_text: nil)
387
+ normalized = normalize_memory_text(text)
388
+ source = source_text.to_s
389
+ first_person_source = source.match?(/\b(?:i|we|my|our)\b/i)
390
+
391
+ if first_person_source
392
+ normalized = normalized.sub(/\AThe\s+\w+\s+(prefers|likes|uses|usually|always|wants|believes|thinks|avoids)\b/i) do
393
+ "The user #{Regexp.last_match(1).downcase}"
394
+ end
395
+ normalized = normalized.sub(/\AI\s+(?:(usually|always)\s+)?(prefer|like|use|want|believe|think|avoid)\b/i) do
396
+ adverb = Regexp.last_match(1)
397
+ verb = third_person_verb(Regexp.last_match(2))
398
+ ["The user", adverb&.downcase, verb].compact.join(" ")
399
+ end
400
+ normalized = normalized.sub(/\AWe\s+(?:(usually|always)\s+)?(prefer|like|use|want|believe|think|avoid)\b/i) do
401
+ adverb = Regexp.last_match(1)
402
+ verb = third_person_verb(Regexp.last_match(2))
403
+ ["The user", adverb&.downcase, verb].compact.join(" ")
404
+ end
405
+ normalized = normalized.sub(/\AMy\s+/i, "The user's ")
406
+ normalized = normalized.sub(/\AOur\s+/i, "The user's ")
407
+ end
408
+
409
+ clean_text(normalized)
410
+ end
411
+
412
+ def third_person_verb(verb)
413
+ word = verb.to_s.downcase
414
+ return word if ["usually", "always"].include?(word)
415
+ return "uses" if word == "use"
416
+
417
+ word.end_with?("s") ? word : "#{word}s"
418
+ end
419
+
420
+ def duplicate_memory_text?(text, existing_texts)
421
+ candidate = memory_duplicate_key(text)
422
+ candidate_tokens = memory_duplicate_tokens(text)
423
+ Array(existing_texts).any? do |existing|
424
+ existing_key = memory_duplicate_key(existing)
425
+ next true if existing_key == candidate
426
+
427
+ existing_tokens = memory_duplicate_tokens(existing)
428
+ next false if candidate_tokens.empty? || existing_tokens.empty?
429
+
430
+ overlap = (candidate_tokens & existing_tokens).length
431
+ union = (candidate_tokens | existing_tokens).length
432
+ union.positive? && overlap.to_f / union >= 0.8
433
+ end
434
+ end
435
+
436
+ def memory_duplicate_key(text)
437
+ normalized = normalize_for_comparison(text).downcase
438
+ normalized = normalized.sub(/\Ai\s+(?:(usually|always)\s+)?(prefer|like|use|want|believe|think|avoid)\b/i) do
439
+ ["the user", Regexp.last_match(1)&.downcase, third_person_verb(Regexp.last_match(2))].compact.join(" ")
440
+ end
441
+ normalized = normalized.sub(/\Awe\s+(?:(usually|always)\s+)?(prefer|like|use|want|believe|think|avoid)\b/i) do
442
+ ["the user", Regexp.last_match(1)&.downcase, third_person_verb(Regexp.last_match(2))].compact.join(" ")
443
+ end
444
+ normalized = normalized.sub(/\Amy\s+/i, "the user's ")
445
+ normalized = normalized.sub(/\Aour\s+/i, "the user's ")
446
+ normalized.tr("“”‘’", "\"\"''")
447
+ end
448
+
449
+ def memory_duplicate_tokens(text)
450
+ memory_duplicate_key(text).scan(/[a-z0-9_\-']{3,}/).filter_map do |term|
451
+ token = memory_duplicate_token(term)
452
+ token unless MEMORY_DUPLICATE_STOPWORDS.include?(token)
453
+ end.uniq
454
+ end
455
+
456
+ def memory_duplicate_token(term)
457
+ case term
458
+ when "uses", "using"
459
+ "use"
460
+ when "prefers", "preferring"
461
+ "prefer"
462
+ when "avoids", "avoiding"
463
+ "avoid"
464
+ else
465
+ term.sub(/\A(.{4,})s\z/, '\\1')
466
+ end
467
+ end
468
+
469
+ def deduplicate_soft_records(records)
470
+ seen = []
471
+ records.each_with_object([]) do |record, deduplicated|
472
+ scope = record["scope"]
473
+ text = record["text"]
474
+ duplicate = seen.any? do |seen_record|
475
+ seen_record["scope"] == scope && duplicate_memory_text?(text, [seen_record["text"]])
476
+ end
477
+ next if duplicate
350
478
 
351
- add_soft(summarized, scope: workspace_scope(workspace_root), tags: ["workflow"], confidence: 0.55, source: "inferred")
479
+ seen << record
480
+ deduplicated << record
352
481
  end
353
482
  end
354
483
 
@@ -391,16 +520,17 @@ module Kward
391
520
  You are a memory text reformulation assistant. Your task is to transform user-generated text into proper third-person descriptive memory statements.
392
521
 
393
522
  Rules:
394
- - Convert first-person statements ("I like", "we prefer") to third-person ("The captain likes", "The user prefers")
523
+ - Convert first-person statements ("I like", "we prefer") to third-person ("The user likes", "The user prefers")
395
524
  - Remove conversational filler, preambles, and action descriptions
396
525
  - Keep only the factual preference or fact
397
- - Use "The captain" or "The user" as the subject for personal preferences
526
+ - Always use "The user" as the subject for personal preferences
527
+ - Do not use persona-specific names, titles, roles, or nicknames
398
528
  - Preserve workflow-related technical preferences as-is
399
529
  - Keep the text concise (under 100 characters)
400
530
  - If the text is already a good memory statement, return it unchanged
401
531
 
402
532
  Examples:
403
- - "I like to eat the most important meal today: steak" → "The captain likes eating steak"
533
+ - "I like to eat the most important meal today: steak" → "The user likes eating steak"
404
534
  - "We should prefer TDD for this project" → "Prefer TDD for this project"
405
535
  - "Remember that the user prefers minitest" → "The user prefers minitest"
406
536
  - "But first we need to remember that we are using TDD" → "Use TDD"
@@ -1,7 +1,20 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Compatibility reader for persisted conversation message hashes.
4
+ #
5
+ # Kward stores transcript entries as plain hashes because model payloads,
6
+ # JSONL sessions, plugins, and RPC normalizers all need to pass them around
7
+ # without framework objects. Restored sessions may contain either symbol keys,
8
+ # string keys, or Tauren-style camelCase aliases. `MessageAccess` centralizes
9
+ # those lookup rules so callers do not grow one-off compatibility branches.
2
10
  module MessageAccess
3
11
  module_function
4
12
 
13
+ # Reads a field from a hash-like object using symbol or string keys.
14
+ #
15
+ # @param object [#key?, nil] hash-like object to read
16
+ # @param key [String, Symbol] canonical field name
17
+ # @return [Object, nil] stored value when present
5
18
  def value(object, key)
6
19
  return nil unless object.respond_to?(:key?)
7
20
  return object[key] if object.key?(key)
@@ -10,14 +23,17 @@ module Kward
10
23
  nil
11
24
  end
12
25
 
26
+ # @return [String, nil] message role such as `user`, `assistant`, or `tool`
13
27
  def role(message)
14
28
  value(message, :role)
15
29
  end
16
30
 
31
+ # @return [Object, nil] raw message content
17
32
  def content(message)
18
33
  value(message, :content)
19
34
  end
20
35
 
36
+ # @return [String, nil] UI-facing content preserved separately from model input
21
37
  def display_content(message)
22
38
  value(message, :display_content) || value(message, :displayContent)
23
39
  end
@@ -31,12 +47,23 @@ module Kward
31
47
  end
32
48
 
33
49
  def tool_call_id(message)
34
- value(message, :tool_call_id)
50
+ value(message, :tool_call_id) || value(message, :toolCallId)
35
51
  end
36
52
 
53
+ def tool_name(message)
54
+ value(message, :name) || value(message, :toolName)
55
+ end
56
+
57
+ # @return [Array<Hash>] assistant tool calls, or an empty array
37
58
  def tool_calls(message)
38
- calls = value(message, :tool_calls)
59
+ calls = value(message, :tool_calls) || value(message, :toolCalls)
39
60
  calls.is_a?(Array) ? calls : []
40
61
  end
62
+
63
+ # @return [Array<Hash>] provider-native Responses output items, or an empty array
64
+ def response_items(message)
65
+ items = value(message, :response_items) || value(message, :responseItems)
66
+ items.is_a?(Array) ? items : []
67
+ end
41
68
  end
42
69
  end
@@ -0,0 +1,45 @@
1
+ require_relative "message_access"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Builds user-visible plain text from persisted conversation messages.
6
+ #
7
+ # Conversations may store one value for the model and another value for the UI.
8
+ # Prompt templates, for example, keep expanded instructions in `content` while
9
+ # preserving the submitted slash command in `display_content`. This helper keeps
10
+ # tree navigation, forks, copy/export features, and RPC payloads aligned on the
11
+ # same visible text rules.
12
+ module MessageText
13
+ module_function
14
+
15
+ # Returns the plain text a user should see or edit for a message.
16
+ #
17
+ # User messages prefer `display_content`/`displayContent` when present. Other
18
+ # messages, and user messages without display text, are reduced from their
19
+ # stored content. Array content contributes only textual parts so image and
20
+ # tool-call blocks do not leak implementation details into editable text.
21
+ #
22
+ # @param message [Hash] persisted conversation message
23
+ # @return [String] stripped visible text
24
+ def full_text(message)
25
+ display_content = MessageAccess.display_content(message)
26
+ return display_content.to_s.strip unless display_content.nil?
27
+
28
+ content_text(MessageAccess.content(message)).strip
29
+ end
30
+
31
+ # Converts message content into plain text without applying display-content
32
+ # overrides.
33
+ #
34
+ # @param content [String, Array<Hash>, nil] message content field
35
+ # @return [String] textual content joined with newlines
36
+ def content_text(content)
37
+ case content
38
+ when Array
39
+ content.filter_map { |part| MessageAccess.value(part, :text) }.join("\n")
40
+ else
41
+ content.to_s
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,4 +1,6 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Thin adapter that invokes the configured model client.
2
4
  module ChatInvocation
3
5
  module_function
4
6