kward 0.68.0 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +34 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +32 -25
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +74 -56
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +18 -0
  11. data/doc/extensibility.md +89 -128
  12. data/doc/getting-started.md +52 -54
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -97
  16. data/doc/releasing.md +3 -1
  17. data/doc/rpc.md +1 -1
  18. data/doc/usage.md +125 -144
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/lib/kward/agent.rb +1 -1
  22. data/lib/kward/cli/commands.rb +10 -3
  23. data/lib/kward/cli/compaction.rb +3 -3
  24. data/lib/kward/cli/interactive_turn.rb +3 -1
  25. data/lib/kward/cli/memory_commands.rb +16 -16
  26. data/lib/kward/cli/plugins.rb +3 -3
  27. data/lib/kward/cli/prompt_interface.rb +15 -13
  28. data/lib/kward/cli/rendering.rb +35 -46
  29. data/lib/kward/cli/runtime_helpers.rb +13 -2
  30. data/lib/kward/cli/sessions.rb +21 -21
  31. data/lib/kward/cli/settings.rb +49 -43
  32. data/lib/kward/cli/slash_commands.rb +6 -4
  33. data/lib/kward/cli/stats.rb +2 -2
  34. data/lib/kward/cli/sysprompt.rb +57 -0
  35. data/lib/kward/cli/tool_summaries.rb +5 -1
  36. data/lib/kward/cli.rb +14 -2
  37. data/lib/kward/cli_transcript_formatter.rb +36 -5
  38. data/lib/kward/compactor.rb +2 -2
  39. data/lib/kward/config_files.rb +45 -10
  40. data/lib/kward/conversation.rb +41 -9
  41. data/lib/kward/memory/manager.rb +131 -14
  42. data/lib/kward/message_access.rb +6 -0
  43. data/lib/kward/model/context_usage.rb +11 -10
  44. data/lib/kward/model/model_info.rb +18 -1
  45. data/lib/kward/model/payloads.rb +89 -10
  46. data/lib/kward/model/stream_parser.rb +258 -25
  47. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  48. data/lib/kward/prompt_interface/transcript_renderer.rb +20 -11
  49. data/lib/kward/prompts.rb +61 -7
  50. data/lib/kward/rpc/server.rb +7 -2
  51. data/lib/kward/rpc/session_manager.rb +18 -2
  52. data/lib/kward/rpc/session_metrics.rb +2 -2
  53. data/lib/kward/rpc/transcript_normalizer.rb +47 -0
  54. data/lib/kward/session_store.rb +40 -1
  55. data/lib/kward/starter_pack_installer.rb +2 -2
  56. data/lib/kward/tools/fetch_content.rb +41 -0
  57. data/lib/kward/tools/fetch_raw.rb +40 -0
  58. data/lib/kward/tools/registry.rb +9 -2
  59. data/lib/kward/tools/search/web.rb +3 -3
  60. data/lib/kward/tools/search/web_fetch.rb +202 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  64. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  65. data/templates/default/fulldoc/html/js/kward.js +296 -0
  66. data/templates/default/fulldoc/html/setup.rb +8 -0
  67. data/templates/default/layout/html/breadcrumb.erb +11 -0
  68. data/templates/default/layout/html/layout.erb +141 -0
  69. data/templates/default/layout/html/setup.rb +139 -0
  70. metadata +14 -1
@@ -482,7 +482,7 @@ module Kward
482
482
  kept_messages: kept_messages,
483
483
  turn_prefix_messages: cut.turn_prefix_messages,
484
484
  split_turn: cut.split_turn,
485
- tokens_before: @estimator.context_tokens(@conversation.messages),
485
+ tokens_before: @estimator.context_tokens(@conversation.context_messages),
486
486
  previous_summary: previous_entry ? compaction_summary(previous_entry) : nil,
487
487
  file_ops: file_ops,
488
488
  settings: @settings
@@ -866,7 +866,7 @@ module Kward
866
866
  context_window ||= @settings.context_window
867
867
  return nil unless context_window
868
868
 
869
- context_tokens ||= Compaction::TokenEstimator.new.context_tokens(@conversation.messages)
869
+ context_tokens ||= Compaction::TokenEstimator.new.context_tokens(@conversation.context_messages)
870
870
  reserve_tokens = auto_compaction_reserve_tokens(context_window: context_window.to_i)
871
871
  return nil unless context_tokens.to_i > context_window.to_i - reserve_tokens
872
872
 
@@ -77,6 +77,7 @@ module Kward
77
77
  "sessions" => {
78
78
  "auto_resume" => false
79
79
  },
80
+ "enforce_workspace_agents_file" => false,
80
81
  "tools" => {
81
82
  "workspace_guardrails" => true
82
83
  }
@@ -206,6 +207,12 @@ module Kward
206
207
  sessions["auto_resume"] == true
207
208
  end
208
209
 
210
+ # Returns whether workspace AGENTS.md contents should be injected directly
211
+ # instead of a compact read-when-relevant instruction.
212
+ def enforce_workspace_agents_file?(config = read_config)
213
+ config["enforce_workspace_agents_file"] == true
214
+ end
215
+
209
216
  # Returns the nested web-search config object, or an empty config when absent.
210
217
  def web_search_config(config = read_config)
211
218
  value = config["web_search"]
@@ -236,12 +243,25 @@ module Kward
236
243
  overlay_settings(config)
237
244
  end
238
245
 
239
- # Reads global agent instructions from the config directory.
246
+ # Reads global principle instructions from the config directory.
247
+ #
248
+ # `PRINCIPLES.md` is preferred. `AGENTS.md` remains a backwards-compatible
249
+ # alias for existing installations.
240
250
  #
241
251
  # @return [String, nil] prompt text, or nil when absent/too large
242
252
  def agents_prompt
243
- path = File.join(config_dir, "AGENTS.md")
244
- read_prompt_file(path, "Kward prompt file")
253
+ path = config_principles_path
254
+ return read_prompt_file(path, "Kward principles file") if File.exist?(path)
255
+
256
+ read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
257
+ end
258
+
259
+ def config_principles_path
260
+ File.join(config_dir, "PRINCIPLES.md")
261
+ end
262
+
263
+ def config_agents_path
264
+ File.join(config_dir, "AGENTS.md")
245
265
  end
246
266
 
247
267
  # Builds persona prompt text from default, workspace, model, reasoning,
@@ -299,22 +319,30 @@ module Kward
299
319
 
300
320
  characters = crew_characters(personas)
301
321
  entries = []
302
-
303
- add_persona_entry(entries, "default", resolved_persona_text(personas["default"], characters: characters))
322
+ active_persona = { layer: "default", value: personas["default"], name: nil }
304
323
 
305
324
  workspaces = personas["workspaces"]
306
325
  if workspaces.is_a?(Hash)
307
326
  root = canonical_workspace_root(workspace_root)
308
327
  workspaces.each do |path, key|
309
328
  if canonical_workspace_root(path) == root
310
- add_persona_entry(entries, "workspace", resolved_persona_text(key, characters: characters), name: path)
329
+ active_persona = { layer: "workspace", value: key, name: path }
311
330
  break
312
331
  end
313
332
  end
314
333
  end
315
334
 
316
335
  models = personas["models"]
317
- add_persona_entry(entries, "model", resolved_persona_text(models[model.to_s], characters: characters), name: model.to_s) if models.is_a?(Hash) && !model.to_s.empty?
336
+ if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
337
+ active_persona = { layer: "model", value: models[model.to_s], name: model.to_s }
338
+ end
339
+
340
+ add_persona_entry(
341
+ entries,
342
+ active_persona.fetch(:layer),
343
+ resolved_persona_text(active_persona.fetch(:value), characters: characters),
344
+ name: active_persona[:name]
345
+ )
318
346
 
319
347
  modifiers = personas["persona_modifiers"]
320
348
  if modifiers.is_a?(Hash)
@@ -336,10 +364,17 @@ module Kward
336
364
 
337
365
  entries
338
366
  end
367
+
368
+ def workspace_agents_path(workspace_root)
369
+ File.join(canonical_workspace_root(workspace_root), "AGENTS.md")
370
+ end
371
+
372
+ def workspace_agents_file?(workspace_root)
373
+ File.exist?(workspace_agents_path(workspace_root))
374
+ end
375
+
339
376
  def workspace_agents_prompt(workspace_root)
340
- root = canonical_workspace_root(workspace_root)
341
- path = File.join(root, "AGENTS.md")
342
- read_prompt_file(path, "workspace AGENTS.md")
377
+ read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
343
378
  end
344
379
 
345
380
  def read_prompt_file(path, label)
@@ -21,8 +21,10 @@ module Kward
21
21
  class Conversation
22
22
  DEFAULT_SYSTEM_MESSAGE = Object.new.freeze
23
23
 
24
- # @return [Array<Hash>] ordered transcript entries sent to providers and persisted in sessions
24
+ # @return [Array<Hash>] ordered durable transcript entries, excluding runtime system prompt state
25
25
  attr_reader :messages
26
+ # @return [Hash, nil] current system prompt included when building provider request context
27
+ attr_reader :system_message
26
28
  # @return [Set<String>] resolved paths read by file tools during the active context
27
29
  attr_reader :read_paths
28
30
  # @return [String] canonical workspace root used for prompts and file guardrails
@@ -45,12 +47,16 @@ module Kward
45
47
  attr_accessor :on_tool_execution
46
48
  # @return [Proc, nil] callback invoked when runtime metadata should be persisted
47
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
48
52
  # @return [String, nil] memory prompt context injected into refreshed system messages
49
53
  attr_accessor :memory_context
50
54
  # @return [Hash, nil] metadata for the last memory retrieval attached to the session
51
55
  attr_accessor :last_memory_retrieval
52
56
  # @return [PluginRegistry, nil] registry used to collect plugin prompt context
53
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
54
60
 
55
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)
56
62
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
@@ -59,10 +65,17 @@ module Kward
59
65
  @reasoning_effort = reasoning_effort
60
66
  @plugin_registry = plugin_registry
61
67
  @messages = []
68
+ restored_system_message, transcript_messages = split_system_message(messages)
62
69
  if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
63
- 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
64
76
  end
65
- @system_message_enabled = !!(system_message || messages.find { |message| MessageAccess.role(message) == "system" })
77
+ @system_message = system_message
78
+ @system_message_enabled = !@system_message.nil?
66
79
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
67
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
68
81
  end
@@ -72,13 +85,13 @@ module Kward
72
85
  @memory_context = memory_context
73
86
  @session_memories = Array(session_memories)
74
87
  @last_memory_retrieval = last_memory_retrieval
75
- @messages << system_message unless system_message.nil?
76
- @messages.concat(messages)
88
+ @messages.concat(transcript_messages)
77
89
  @read_paths = Set.new(read_paths)
78
90
  @on_append = on_append
79
91
  @on_compact = on_compact
80
92
  @on_tool_execution = on_tool_execution
81
93
  @on_runtime_update = on_runtime_update
94
+ @on_system_message_change = nil
82
95
  end
83
96
 
84
97
  # Appends a user message and normalizes image attachment syntax.
@@ -110,6 +123,11 @@ module Kward
110
123
  @on_tool_execution&.call(tool_call, content)
111
124
  end
112
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
+
113
131
  # Rebuilds the system message from current config, memory, plugins, and
114
132
  # workspace AGENTS.md state.
115
133
  #
@@ -119,9 +137,10 @@ module Kward
119
137
  def refresh_system_message!
120
138
  return nil unless @system_message_enabled
121
139
 
122
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: @memory_context, plugin_context: plugin_prompt_context)
123
- index = @messages.index { |message| MessageAccess.role(message) == "system" }
124
- 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)
125
144
  @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort)
126
145
  @workspace_agents_mtime = workspace_agents_mtime
127
146
  replacement
@@ -172,7 +191,7 @@ module Kward
172
191
  message[:from_hook] = from_hook
173
192
  message[:details] = details || {}
174
193
  end
175
- @messages = @messages.select { |item| MessageAccess.role(item) == "system" }
194
+ @messages = []
176
195
  @messages << message
177
196
  @messages.concat(Array(keep_messages))
178
197
  @read_paths.clear
@@ -197,6 +216,19 @@ module Kward
197
216
 
198
217
  private
199
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
+
200
232
  def workspace_agents_mtime
201
233
  path = File.join(@workspace_root, "AGENTS.md")
202
234
  File.exist?(path) ? File.mtime(path) : nil
@@ -23,6 +23,7 @@ module Kward
23
23
  DEFAULT_SOFT_TTL_DAYS = 60
24
24
  DEFAULT_SOFT_CONFIDENCE = 0.65
25
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])
26
27
 
27
28
  # Details for the most recent retrieval, used by `/memory why`.
28
29
  attr_reader :last_retrieval
@@ -148,10 +149,20 @@ module Kward
148
149
  # @param ttl_days [Integer, nil] time-to-live in days
149
150
  # @return [Hash] stored memory record
150
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
+
151
162
  record = {
152
163
  "id" => next_id("soft", soft_memories(include_inactive: true).map { |item| item["id"] }),
153
- "text" => clean_text(normalize_memory_text(text)),
154
- "scope" => clean_scope(scope),
164
+ "text" => normalized_text,
165
+ "scope" => normalized_scope,
155
166
  "tags" => clean_tags(tags),
156
167
  "confidence" => [[confidence.to_f, 0.0].max, 1.0].min,
157
168
  "hits" => 0,
@@ -162,8 +173,6 @@ module Kward
162
173
  "source" => source,
163
174
  "status" => "active"
164
175
  }
165
- raise ArgumentError, "Memory text cannot be empty" if record["text"].empty?
166
- raise ArgumentError, "Refusing to persist emotional or dependency-forming memory automatically" if source == "inferred" && unsafe_soft_text?(record["text"])
167
176
 
168
177
  append_soft(record)
169
178
  append_event("add", event_ref(record, layer: "soft"))
@@ -232,16 +241,20 @@ module Kward
232
241
  end
233
242
 
234
243
  def list(include_inactive: false)
235
- { "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 }
236
247
  end
237
248
 
238
249
  def hierarchy(workspace_root: Dir.pwd, include_inactive: false)
239
250
  workspace = workspace_scope(workspace_root)
240
251
  core = core_memories
252
+ soft = soft_memories(include_inactive: include_inactive)
253
+ soft = deduplicate_soft_records(soft) unless include_inactive
241
254
  {
242
255
  "global_core" => core.select { |item| item["scope"] == "global" },
243
256
  "workspace_core" => core.select { |item| item["scope"] == workspace },
244
- "workspace_soft" => soft_memories(include_inactive: include_inactive).select { |item| item["scope"] == workspace }
257
+ "workspace_soft" => soft.select { |item| item["scope"] == workspace }
245
258
  }
246
259
  end
247
260
 
@@ -271,8 +284,7 @@ module Kward
271
284
  core_reasons = core.map { |item| reason_for(item, layer: "core", score: 1.0, reasons: ["scope match", "core memories are preferred"]) }
272
285
 
273
286
  soft_records_all = soft_memories(include_inactive: true)
274
- soft_scored = soft_records_all.filter_map do |item|
275
- next unless item["status"] == "active"
287
+ soft_scored = deduplicate_soft_records(soft_records_all.select { |item| item["status"] == "active" }).filter_map do |item|
276
288
  next unless item["scope"] == workspace
277
289
  next if expired?(item)
278
290
 
@@ -354,14 +366,118 @@ module Kward
354
366
  def infer_soft_from_text(text, workspace_root: Dir.pwd, client: nil, existing_texts: [])
355
367
  candidates = heuristic_candidates(text)
356
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"] }
357
372
  candidates.filter_map do |candidate|
358
- summarized = summarize_text(candidate, client: client)
373
+ summarized = normalize_inferred_memory_text(summarize_text(candidate, client: client), source_text: candidate)
359
374
  normalized = normalize_for_comparison(summarized)
360
375
  # Skip if this text already exists in provided list or existing soft memories
361
376
  next if existing_set.include?(normalized)
362
- 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
363
478
 
364
- add_soft(summarized, scope: workspace_scope(workspace_root), tags: ["workflow"], confidence: 0.55, source: "inferred")
479
+ seen << record
480
+ deduplicated << record
365
481
  end
366
482
  end
367
483
 
@@ -404,16 +520,17 @@ module Kward
404
520
  You are a memory text reformulation assistant. Your task is to transform user-generated text into proper third-person descriptive memory statements.
405
521
 
406
522
  Rules:
407
- - 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")
408
524
  - Remove conversational filler, preambles, and action descriptions
409
525
  - Keep only the factual preference or fact
410
- - 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
411
528
  - Preserve workflow-related technical preferences as-is
412
529
  - Keep the text concise (under 100 characters)
413
530
  - If the text is already a good memory statement, return it unchanged
414
531
 
415
532
  Examples:
416
- - "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"
417
534
  - "We should prefer TDD for this project" → "Prefer TDD for this project"
418
535
  - "Remember that the user prefers minitest" → "The user prefers minitest"
419
536
  - "But first we need to remember that we are using TDD" → "Use TDD"
@@ -59,5 +59,11 @@ module Kward
59
59
  calls = value(message, :tool_calls) || value(message, :toolCalls)
60
60
  calls.is_a?(Array) ? calls : []
61
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
62
68
  end
63
69
  end
@@ -14,9 +14,8 @@ module Kward
14
14
  def call(provider:, model:, context_window:, context_parts:)
15
15
  return nil unless OPENAI_CONTEXT_PROVIDERS.include?(provider.to_s)
16
16
  return nil unless context_window
17
- return nil if contains_image?(context_parts)
18
17
 
19
- parts = stringify_keys(context_parts || {})
18
+ parts = redact_image_data(stringify_keys(context_parts || {}))
20
19
  return nil unless contains_session_content?(parts)
21
20
 
22
21
  payload = prompt_payload(parts)
@@ -58,21 +57,23 @@ module Kward
58
57
  false
59
58
  end
60
59
 
61
- def contains_image?(value)
60
+ def redact_image_data(value)
62
61
  case value
63
62
  when Hash
64
- type = value[:type] || value["type"]
65
- return true if ["image", "input_image", "image_url"].include?(type.to_s)
66
- return true if value.key?(:image_url) || value.key?("image_url")
67
-
68
- value.any? { |_key, item| contains_image?(item) }
63
+ value.each_with_object({}) do |(key, item), result|
64
+ result[key] = image_data_key?(key) ? "[image omitted from token estimate]" : redact_image_data(item)
65
+ end
69
66
  when Array
70
- value.any? { |item| contains_image?(item) }
67
+ value.map { |item| redact_image_data(item) }
71
68
  else
72
- false
69
+ value
73
70
  end
74
71
  end
75
72
 
73
+ def image_data_key?(key)
74
+ ["data", "image_url"].include?(key.to_s)
75
+ end
76
+
76
77
  def stringify_keys(value)
77
78
  return value unless value.is_a?(Hash)
78
79
 
@@ -71,6 +71,23 @@ module Kward
71
71
  /(?:\A|\/)gpt-5\.3-codex-spark\z/
72
72
  ].freeze
73
73
 
74
+ CODEX_CONTEXT_WINDOWS = [
75
+ [/\Agpt-5\.5/, 400_000],
76
+ [/\Agpt-5\.4-mini/, 400_000],
77
+ [/\Agpt-5\.4/, 1_050_000],
78
+ [/\Agpt-5-mini/, 400_000],
79
+ [/\Agpt-5-codex/, 400_000],
80
+ [/\Agpt-5\.3-codex-spark/, 128_000],
81
+ [/\Agpt-5\.3-codex/, 400_000],
82
+ [/\Agpt-5\.2-codex/, 400_000],
83
+ [/\Agpt-5/, 400_000],
84
+ [/\Agpt-4\.1/, 1_047_576],
85
+ [/\Agpt-4o/, 128_000],
86
+ [/\Ao3/, 200_000],
87
+ [/\Ao4/, 200_000],
88
+ [/\Agpt-4/, 128_000],
89
+ [/\Agpt-3\.5-turbo/, 16_385]
90
+ ].freeze
74
91
  OPENAI_CONTEXT_WINDOWS = [
75
92
  [/\Agpt-5\.5/, 1_050_000],
76
93
  [/\Agpt-5\.4-mini/, 400_000],
@@ -214,7 +231,7 @@ module Kward
214
231
  def context_window(provider, id)
215
232
  case provider
216
233
  when "Codex"
217
- pattern_context_window(OPENAI_CONTEXT_WINDOWS, id)
234
+ pattern_context_window(CODEX_CONTEXT_WINDOWS, id)
218
235
  when "OpenRouter"
219
236
  openrouter_context_window(id)
220
237
  when "Copilot"
@@ -239,6 +239,7 @@ module Kward
239
239
  "edit_file" => "Edit",
240
240
  "run_shell_command" => "Bash",
241
241
  "web_search" => "WebSearch",
242
+ "fetch_content" => "WebFetch",
242
243
  "ask_user_question" => "AskUserQuestion"
243
244
  }[name.to_s]
244
245
  return mapped if mapped
@@ -291,16 +292,21 @@ module Kward
291
292
  output: plain_content(content).to_s
292
293
  }
293
294
  when "assistant"
294
- content = plain_content(content)
295
- input << codex_message("assistant", content.to_s) unless content.to_s.empty?
296
- MessageAccess.tool_calls(message).each do |tool_call|
297
- function = tool_call[:function] || tool_call["function"] || {}
298
- input << {
299
- type: "function_call",
300
- call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
301
- name: function[:name] || function["name"],
302
- arguments: function[:arguments] || function["arguments"] || "{}"
303
- }
295
+ response_items = codex_replay_response_items(message)
296
+ if response_items.empty?
297
+ content = plain_content(content)
298
+ input << codex_message("assistant", content.to_s) unless content.to_s.empty?
299
+ MessageAccess.tool_calls(message).each do |tool_call|
300
+ function = tool_call[:function] || tool_call["function"] || {}
301
+ input << {
302
+ type: "function_call",
303
+ call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
304
+ name: function[:name] || function["name"],
305
+ arguments: function[:arguments] || function["arguments"] || "{}"
306
+ }
307
+ end
308
+ else
309
+ input.concat(response_items)
304
310
  end
305
311
  when "compactionSummary"
306
312
  summary = MessageAccess.summary(message) || content
@@ -332,6 +338,79 @@ module Kward
332
338
  { type: "message", role: role, content: [{ type: type, text: text }] }
333
339
  end
334
340
 
341
+ def codex_replay_response_items(message)
342
+ items = MessageAccess.response_items(message)
343
+ return [] if items.empty?
344
+
345
+ items.filter_map { |item| codex_replay_response_item(item) }
346
+ end
347
+
348
+ def codex_replay_response_item(item)
349
+ return nil unless item.is_a?(Hash)
350
+
351
+ case item[:type] || item["type"]
352
+ when "reasoning"
353
+ codex_replay_reasoning_item(item)
354
+ when "message"
355
+ codex_replay_message_item(item)
356
+ when "function_call", "custom_tool_call"
357
+ codex_replay_tool_call_item(item)
358
+ end
359
+ end
360
+
361
+ def codex_replay_reasoning_item(item)
362
+ result = { type: "reasoning" }
363
+ summary = item[:summary] || item["summary"]
364
+ content = item[:content] || item["content"]
365
+ encrypted_content = item[:encrypted_content] || item["encrypted_content"]
366
+ result[:summary] = summary if summary.is_a?(Array)
367
+ result[:content] = content if content.is_a?(Array)
368
+ result[:encrypted_content] = encrypted_content if encrypted_content
369
+ result
370
+ end
371
+
372
+ def codex_replay_message_item(item)
373
+ content = item[:content] || item["content"]
374
+ return nil unless content.is_a?(Array)
375
+
376
+ result = { type: "message", role: item[:role] || item["role"] || "assistant", content: codex_replay_message_content(content) }
377
+ phase = item[:phase] || item["phase"]
378
+ result[:phase] = phase if phase
379
+ result
380
+ end
381
+
382
+ def codex_replay_message_content(content)
383
+ content.filter_map do |part|
384
+ next unless part.is_a?(Hash)
385
+
386
+ type = part[:type] || part["type"]
387
+ next unless ["output_text", "text", "refusal"].include?(type)
388
+
389
+ replay_part = { type: type }
390
+ text = part[:text] || part["text"]
391
+ refusal = part[:refusal] || part["refusal"]
392
+ replay_part[:text] = text.to_s if text || type != "refusal"
393
+ replay_part[:refusal] = refusal.to_s if refusal
394
+ annotations = part[:annotations] || part["annotations"]
395
+ replay_part[:annotations] = annotations if annotations.is_a?(Array)
396
+ replay_part
397
+ end
398
+ end
399
+
400
+ def codex_replay_tool_call_item(item)
401
+ type = item[:type] || item["type"]
402
+ result = { type: type }
403
+ call_id = item[:call_id] || item["call_id"]
404
+ name = item[:name] || item["name"]
405
+ arguments = item[:arguments] || item["arguments"]
406
+ input = item[:input] || item["input"]
407
+ result[:call_id] = call_id if call_id
408
+ result[:name] = name if name
409
+ result[:arguments] = arguments if arguments
410
+ result[:input] = input if input
411
+ result
412
+ end
413
+
335
414
  def plain_content(content)
336
415
  return content unless content.is_a?(Array)
337
416