kward 0.66.0 → 0.67.1

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.
@@ -0,0 +1,124 @@
1
+ require "base64"
2
+ require_relative "image_attachments"
3
+ require_relative "message_access"
4
+
5
+ module Kward
6
+ module CLITranscriptFormatter
7
+ module_function
8
+
9
+ def reasoning(message)
10
+ direct = MessageAccess.value(message, :reasoning_summary)
11
+ return direct.to_s unless direct.to_s.empty?
12
+
13
+ content = MessageAccess.content(message)
14
+ return "" unless content.is_a?(Array)
15
+
16
+ content.filter_map do |part|
17
+ type = MessageAccess.value(part, :type)
18
+ next unless ["thinking", "reasoning"].include?(type)
19
+
20
+ MessageAccess.value(part, :thinking) || MessageAccess.value(part, :reasoning) || MessageAccess.value(part, :text)
21
+ end.join("\n")
22
+ end
23
+
24
+ def content_text(content)
25
+ case content
26
+ when Array
27
+ content.filter_map { |part| content_part_text(part) }.join("\n")
28
+ else
29
+ content.to_s
30
+ end
31
+ end
32
+
33
+ def display_text(message)
34
+ display_content = MessageAccess.display_content(message)
35
+ return display_content.to_s unless display_content.nil?
36
+
37
+ content_text(MessageAccess.content(message))
38
+ end
39
+
40
+ def user_display_text(message)
41
+ display_content = MessageAccess.display_content(message)
42
+ return display_content.to_s unless display_content.nil?
43
+
44
+ content = MessageAccess.content(message)
45
+ return content.to_s unless content.is_a?(Array)
46
+
47
+ text = content.filter_map do |part|
48
+ next unless MessageAccess.value(part, :type) == "text"
49
+
50
+ MessageAccess.value(part, :text)
51
+ end.join("\n")
52
+ ImageAttachments.display_text_without_references(text, ImageAttachments.references_from_text(text).select { |reference| reference[:status] == :attached })
53
+ end
54
+
55
+ def user_transcript_input(message)
56
+ content = MessageAccess.content(message)
57
+ return content.to_s unless content.is_a?(Array)
58
+
59
+ user_display_text(message)
60
+ end
61
+
62
+ def image_parts(message)
63
+ content = MessageAccess.content(message)
64
+ return [] unless content.is_a?(Array)
65
+
66
+ content.select { |part| MessageAccess.value(part, :type) == "image" }
67
+ end
68
+
69
+ def image_references(message)
70
+ image_parts(message).map { |part| image_part_reference(part) }
71
+ end
72
+
73
+ def synthetic_tool_call(name, id)
74
+ {
75
+ "id" => id || "restored_tool",
76
+ "type" => "function",
77
+ "function" => { "name" => name || "tool", "arguments" => "{}" }
78
+ }
79
+ end
80
+
81
+ def full_text(message)
82
+ content = MessageAccess.content(message)
83
+ text = if content.is_a?(Array)
84
+ content.filter_map { |part| MessageAccess.value(part, :text) }.join("\n")
85
+ else
86
+ content.to_s
87
+ end
88
+ text.strip
89
+ end
90
+
91
+ def content_part_text(part)
92
+ type = MessageAccess.value(part, :type)
93
+ if type == "text"
94
+ MessageAccess.value(part, :text)
95
+ elsif type == "image"
96
+ path = MessageAccess.value(part, :path)
97
+ media_type = MessageAccess.value(part, :media_type) || MessageAccess.value(part, :mimeType) || "image"
98
+ "[#{media_type}#{path ? ": #{path}" : ""}]"
99
+ end
100
+ end
101
+
102
+ def image_part_reference(part)
103
+ data = MessageAccess.value(part, :data)
104
+ path = MessageAccess.value(part, :path)
105
+ media_type = MessageAccess.value(part, :media_type) || MessageAccess.value(part, :mimeType) || "image"
106
+ {
107
+ status: :attached,
108
+ type: "image",
109
+ label: path.to_s.empty? ? "pasted image" : File.basename(path),
110
+ media_type: media_type,
111
+ size_bytes: decoded_image_size(data),
112
+ path: path
113
+ }
114
+ end
115
+
116
+ def decoded_image_size(data)
117
+ return nil if data.to_s.empty?
118
+
119
+ Base64.decode64(data.to_s.gsub(/\s+/, "")).bytesize
120
+ rescue ArgumentError
121
+ nil
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,46 @@
1
+ require_relative "../message_access"
2
+ require_relative "../tools/tool_call"
3
+
4
+ module Kward
5
+ module Compaction
6
+ class FileOperationTracker
7
+ def call(messages, previous_details: {})
8
+ read_files = Array(path_values(previous_details, "read_files", :read_files))
9
+ modified_files = Array(path_values(previous_details, "modified_files", :modified_files))
10
+
11
+ Array(messages).each do |message|
12
+ next unless MessageAccess.role(message) == "assistant"
13
+
14
+ MessageAccess.tool_calls(message).each do |tool_call|
15
+ name = ToolCall.name(tool_call)
16
+ args = ToolCall.arguments(tool_call)
17
+ path = args["path"] || args[:path]
18
+ case name
19
+ when "read_file"
20
+ read_files << path if path
21
+ when "write_file", "edit_file"
22
+ modified_files << path if path
23
+ end
24
+ end
25
+ end
26
+
27
+ {
28
+ read_files: sorted_paths(read_files),
29
+ modified_files: sorted_paths(modified_files)
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def sorted_paths(paths)
36
+ paths.map(&:to_s).reject(&:empty?).uniq.sort
37
+ end
38
+
39
+ def path_values(hash, string_key, symbol_key)
40
+ return [] unless hash.respond_to?(:key?)
41
+
42
+ hash[string_key] || hash[symbol_key] || []
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,7 +1,9 @@
1
1
  require "json"
2
2
  require_relative "model/chat_invocation"
3
+ require_relative "compaction/file_operation_tracker"
3
4
  require_relative "config_files"
4
5
  require_relative "prompts"
6
+ require_relative "tools/tool_call"
5
7
 
6
8
  module Kward
7
9
  module Compaction
@@ -163,9 +165,7 @@ module Kward
163
165
  end
164
166
 
165
167
  def value(object, key)
166
- return nil unless object.respond_to?(:key?)
167
-
168
- object[key] || object[key.to_s]
168
+ ToolCall.value(object, key)
169
169
  end
170
170
  end
171
171
 
@@ -350,71 +350,6 @@ module Kward
350
350
  end
351
351
  end
352
352
 
353
- class FileOperationTracker
354
- def call(messages, previous_details: {})
355
- read_files = Array(path_values(previous_details, "read_files", :read_files))
356
- modified_files = Array(path_values(previous_details, "modified_files", :modified_files))
357
-
358
- Array(messages).each do |message|
359
- next unless message_role(message) == "assistant"
360
-
361
- message_tool_calls(message).each do |tool_call|
362
- name = tool_call_name(tool_call)
363
- args = tool_call_args(tool_call)
364
- path = args["path"] || args[:path]
365
- case name
366
- when "read_file"
367
- read_files << path if path
368
- when "write_file", "edit_file"
369
- modified_files << path if path
370
- end
371
- end
372
- end
373
-
374
- {
375
- read_files: sorted_paths(read_files),
376
- modified_files: sorted_paths(modified_files)
377
- }
378
- end
379
-
380
- private
381
-
382
- def sorted_paths(paths)
383
- paths.map(&:to_s).reject(&:empty?).uniq.sort
384
- end
385
-
386
- def path_values(hash, string_key, symbol_key)
387
- return [] unless hash.respond_to?(:key?)
388
-
389
- hash[string_key] || hash[symbol_key] || []
390
- end
391
-
392
- def message_role(message)
393
- message["role"] || message[:role]
394
- end
395
-
396
- def message_tool_calls(message)
397
- value = message["tool_calls"] || message[:tool_calls]
398
- value.is_a?(Array) ? value : []
399
- end
400
-
401
- def tool_call_name(tool_call)
402
- function = tool_call["function"] || tool_call[:function] || {}
403
- function["name"] || function[:name]
404
- end
405
-
406
- def tool_call_args(tool_call)
407
- function = tool_call["function"] || tool_call[:function] || {}
408
- arguments = function["arguments"] || function[:arguments]
409
- return arguments if arguments.is_a?(Hash)
410
- return {} if arguments.nil? || arguments.empty?
411
-
412
- JSON.parse(arguments)
413
- rescue JSON::ParserError
414
- {}
415
- end
416
- end
417
-
418
353
  class CutPointFinder
419
354
  VALID_CUT_ROLES = ["user", "assistant", "bash", "custom", "branchSummary"].freeze
420
355
 
@@ -63,6 +63,12 @@ module Kward
63
63
  },
64
64
  "composer" => {
65
65
  "busy_help" => true
66
+ },
67
+ "sessions" => {
68
+ "auto_resume" => false
69
+ },
70
+ "tools" => {
71
+ "workspace_guardrails" => true
66
72
  }
67
73
  }
68
74
  end
@@ -165,6 +171,21 @@ module Kward
165
171
  composer["busy_help"] != false
166
172
  end
167
173
 
174
+ def banner_enabled?(config = read_config)
175
+ banner = config["banner"].is_a?(Hash) ? config["banner"] : {}
176
+ banner["enabled"] != false
177
+ end
178
+
179
+ def workspace_guardrails_enabled?(config = read_config)
180
+ tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
181
+ tools["workspace_guardrails"] != false
182
+ end
183
+
184
+ def session_auto_resume_enabled?(config = read_config)
185
+ sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
186
+ sessions["auto_resume"] == true
187
+ end
188
+
168
189
  def update_overlay_settings(values)
169
190
  raise "Overlay settings must be an object" unless values.is_a?(Hash)
170
191
 
@@ -328,28 +349,14 @@ module Kward
328
349
  end
329
350
 
330
351
  def crew_characters(personas)
331
- raw = personas["characters"] || personas["crew"]
332
- return {} unless raw
333
-
334
- if raw.is_a?(Hash)
335
- parse_named_characters(raw)
336
- elsif raw.is_a?(Array)
337
- parse_named_characters_array(raw)
338
- else
339
- {}
352
+ named_character_values(personas) do |_key, definition|
353
+ extract_character_instruction(definition)
340
354
  end
341
355
  end
342
356
 
343
357
  def crew_character_labels(personas)
344
- raw = personas["characters"] || personas["crew"]
345
- return {} unless raw
346
-
347
- if raw.is_a?(Hash)
348
- parse_named_character_labels(raw)
349
- elsif raw.is_a?(Array)
350
- parse_named_character_labels_array(raw)
351
- else
352
- {}
358
+ named_character_values(personas) do |_key, definition|
359
+ extract_character_label(definition)
353
360
  end
354
361
  end
355
362
 
@@ -372,65 +379,34 @@ module Kward
372
379
  presence(labels[key])
373
380
  end
374
381
 
375
- def parse_named_characters(raw)
376
- raw.each_with_object({}) do |(key, definition), mapping|
377
- instruction = extract_character_instruction(definition)
378
- next if instruction.nil?
382
+ def named_character_values(personas)
383
+ character_entries(personas["characters"] || personas["crew"]).each_with_object({}) do |(key, definition), mapping|
384
+ value = yield(key, definition)
385
+ next if value.to_s.empty?
379
386
 
380
- mapping[key.to_s] = instruction
387
+ mapping[key.to_s] = value
381
388
  end
382
389
  end
383
390
 
384
- def parse_named_characters_array(raw)
385
- raw.each_with_object({}) do |entry, mapping|
386
- char_key = nil
387
- definition = nil
388
-
389
- if entry.is_a?(Hash) && entry.length == 1 && entry.keys.first.is_a?(String)
390
- char_key = entry.keys.first
391
- definition = entry.values.first
392
- elsif entry.is_a?(Hash)
393
- char_key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
394
- definition = entry
395
- end
396
-
397
- next if char_key.to_s.empty?
398
-
399
- instruction = extract_character_instruction(definition)
400
- next if instruction.to_s.empty?
401
-
402
- mapping[char_key.to_s] = instruction
403
- end
404
- end
405
-
406
- def parse_named_character_labels(raw)
407
- raw.each_with_object({}) do |(key, definition), mapping|
408
- label = extract_character_label(definition)
409
- next if label.nil?
410
-
411
- mapping[key.to_s] = label
391
+ def character_entries(raw)
392
+ case raw
393
+ when Hash
394
+ raw.map { |key, definition| [key, definition] }
395
+ when Array
396
+ raw.filter_map { |entry| character_entry(entry) }
397
+ else
398
+ []
412
399
  end
413
400
  end
414
401
 
415
- def parse_named_character_labels_array(raw)
416
- raw.each_with_object({}) do |entry, mapping|
417
- char_key = nil
418
- definition = nil
419
-
420
- if entry.is_a?(Hash) && entry.length == 1 && entry.keys.first.is_a?(String)
421
- char_key = entry.keys.first
422
- definition = entry.values.first
423
- elsif entry.is_a?(Hash)
424
- char_key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
425
- definition = entry
426
- end
427
-
428
- next if char_key.to_s.empty?
429
-
430
- label = extract_character_label(definition)
431
- next if label.to_s.empty?
402
+ def character_entry(entry)
403
+ return nil unless entry.is_a?(Hash)
432
404
 
433
- mapping[char_key.to_s] = label
405
+ if entry.length == 1 && entry.keys.first.is_a?(String)
406
+ [entry.keys.first, entry.values.first]
407
+ else
408
+ key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
409
+ key.to_s.empty? ? nil : [key, entry]
434
410
  end
435
411
  end
436
412
 
@@ -167,9 +167,17 @@ module Kward
167
167
  record
168
168
  end
169
169
 
170
+ def promote_memory(id)
171
+ id = id.to_s
172
+ core = core_memories.find { |item| item["id"] == id }
173
+ return promote_core_to_global(core) if core
174
+
175
+ promote_soft_to_core(id)
176
+ end
177
+
170
178
  def promote_soft_to_core(id)
171
179
  soft = soft_memories.find { |item| item["id"] == id.to_s }
172
- raise ArgumentError, "Unknown active soft memory: #{id}" unless soft
180
+ raise ArgumentError, "Unknown active soft memory or workspace core memory: #{id}" unless soft
173
181
 
174
182
  core = add_core(soft["text"], scope: soft["scope"], tags: soft["tags"], source: "promoted_soft_memory", pinned: true)
175
183
  forget_memory(id)
@@ -177,6 +185,20 @@ module Kward
177
185
  core
178
186
  end
179
187
 
188
+ def relax_core(id, workspace_root: Dir.pwd)
189
+ id = id.to_s
190
+ memories = core_memories
191
+ core = memories.find { |item| item["id"] == id }
192
+ raise ArgumentError, "Unknown core memory: #{id}" unless core
193
+ raise ArgumentError, "Only global core memories can be relaxed" unless core["scope"] == "global"
194
+
195
+ core["scope"] = workspace_scope(workspace_root)
196
+ core["updated_at"] = timestamp
197
+ write_core(memories)
198
+ append_event("relax", event_ref(core, layer: "core"))
199
+ core
200
+ end
201
+
180
202
  def forget_memory(id)
181
203
  id = id.to_s
182
204
  memories = core_memories
@@ -210,6 +232,16 @@ module Kward
210
232
  { "core" => core_memories, "soft" => soft_memories(include_inactive: include_inactive) }
211
233
  end
212
234
 
235
+ def hierarchy(workspace_root: Dir.pwd, include_inactive: false)
236
+ workspace = workspace_scope(workspace_root)
237
+ core = core_memories
238
+ {
239
+ "global_core" => core.select { |item| item["scope"] == "global" },
240
+ "workspace_core" => core.select { |item| item["scope"] == workspace },
241
+ "workspace_soft" => soft_memories(include_inactive: include_inactive).select { |item| item["scope"] == workspace }
242
+ }
243
+ end
244
+
213
245
  def inspect_memory
214
246
  list(include_inactive: true).merge("enabled" => enabled?, "paths" => paths)
215
247
  end
@@ -230,14 +262,15 @@ module Kward
230
262
  end
231
263
 
232
264
  scopes = scopes_for(workspace_root)
265
+ workspace = workspace_scope(workspace_root)
233
266
  terms = terms_for(input)
234
- core = core_memories.select { |item| scopes.include?(item["scope"]) }.first(max_core)
267
+ core = ranked_core_memories(workspace).first(max_core)
235
268
  core_reasons = core.map { |item| reason_for(item, layer: "core", score: 1.0, reasons: ["scope match", "core memories are preferred"]) }
236
269
 
237
270
  soft_records_all = soft_memories(include_inactive: true)
238
271
  soft_scored = soft_records_all.filter_map do |item|
239
272
  next unless item["status"] == "active"
240
- next unless scopes.include?(item["scope"])
273
+ next unless item["scope"] == workspace
241
274
  next if expired?(item)
242
275
 
243
276
  score, reasons = soft_score(item, terms)
@@ -271,14 +304,22 @@ module Kward
271
304
  soft = Array(retrieval["soft"])
272
305
  return nil if core.empty? && soft.empty?
273
306
 
307
+ global_core = core.select { |item| item["scope"] == "global" }
308
+ workspace_core = core.reject { |item| item["scope"] == "global" }
309
+
274
310
  lines = ["<kward_memory>"]
275
- unless core.empty?
276
- lines << "Core Memories:"
277
- core.each { |item| lines << "- [#{item["id"]}] #{item["text"]}" }
311
+ unless global_core.empty?
312
+ lines << "Global Core Memories:"
313
+ global_core.each { |item| lines << "- [#{item["id"]}] #{item["text"]}" }
314
+ lines << ""
315
+ end
316
+ unless workspace_core.empty?
317
+ lines << "Workspace Core Memories:"
318
+ workspace_core.each { |item| lines << "- [#{item["id"]}] #{item["text"]}" }
278
319
  lines << ""
279
320
  end
280
321
  unless soft.empty?
281
- lines << "Relevant Soft Memories:"
322
+ lines << "Workspace Soft Memories:"
282
323
  soft.each { |item| lines << "- [#{item["id"]}] #{item["text"]}" }
283
324
  lines << ""
284
325
  end
@@ -490,6 +531,24 @@ module Kward
490
531
  ["global", workspace_scope(workspace_root)]
491
532
  end
492
533
 
534
+ def ranked_core_memories(workspace)
535
+ memories = core_memories
536
+ memories.select { |item| item["scope"] == "global" } + memories.select { |item| item["scope"] == workspace }
537
+ end
538
+
539
+ def promote_core_to_global(core)
540
+ raise ArgumentError, "Only workspace core memories can be promoted" if core["scope"] == "global"
541
+ raise ArgumentError, "Only workspace core memories can be promoted" unless core["scope"].to_s.start_with?("workspace:")
542
+
543
+ memories = core_memories
544
+ record = memories.find { |item| item["id"] == core["id"] }
545
+ record["scope"] = "global"
546
+ record["updated_at"] = timestamp
547
+ write_core(memories)
548
+ append_event("promote", event_ref(record, layer: "core"))
549
+ record
550
+ end
551
+
493
552
  def workspace_scope(workspace_root)
494
553
  "workspace:#{ConfigFiles.canonical_workspace_root(workspace_root)}"
495
554
  end