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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -3
- data/Gemfile.lock +2 -2
- data/README.md +5 -1
- data/doc/configuration.md +43 -1
- data/doc/memory.md +31 -9
- data/doc/rpc.md +41 -21
- data/doc/troubleshooting.md +55 -0
- data/doc/usage.md +41 -6
- data/lib/kward/cli.rb +1155 -195
- data/lib/kward/cli_transcript_formatter.rb +124 -0
- data/lib/kward/compaction/file_operation_tracker.rb +46 -0
- data/lib/kward/compactor.rb +3 -68
- data/lib/kward/config_files.rb +45 -69
- data/lib/kward/memory/manager.rb +66 -7
- data/lib/kward/model/client.rb +2 -195
- data/lib/kward/model/model_info.rb +9 -10
- data/lib/kward/model/payloads.rb +203 -0
- data/lib/kward/prompt_interface/banner.rb +77 -0
- data/lib/kward/prompt_interface.rb +220 -191
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/rpc/runtime_payloads.rb +79 -0
- data/lib/kward/rpc/server.rb +33 -34
- data/lib/kward/rpc/session_manager.rb +518 -159
- data/lib/kward/rpc/tool_event_normalizer.rb +12 -9
- data/lib/kward/rpc/transcript_normalizer.rb +31 -53
- data/lib/kward/session_store.rb +269 -23
- data/lib/kward/session_trash.rb +96 -0
- data/lib/kward/session_tree_renderer.rb +264 -0
- data/lib/kward/tools/registry.rb +3 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +10 -5
- metadata +9 -1
|
@@ -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
|
data/lib/kward/compactor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
next if
|
|
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] =
|
|
387
|
+
mapping[key.to_s] = value
|
|
381
388
|
end
|
|
382
389
|
end
|
|
383
390
|
|
|
384
|
-
def
|
|
385
|
-
raw
|
|
386
|
-
|
|
387
|
-
definition
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
416
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/kward/memory/manager.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
276
|
-
lines << "Core Memories:"
|
|
277
|
-
|
|
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 << "
|
|
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
|