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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +34 -0
- data/Gemfile.lock +8 -2
- data/README.md +32 -25
- data/Rakefile +14 -1
- data/doc/authentication.md +74 -56
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +18 -0
- data/doc/extensibility.md +89 -128
- data/doc/getting-started.md +52 -54
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -97
- data/doc/releasing.md +3 -1
- data/doc/rpc.md +1 -1
- data/doc/usage.md +125 -144
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +10 -3
- data/lib/kward/cli/compaction.rb +3 -3
- data/lib/kward/cli/interactive_turn.rb +3 -1
- data/lib/kward/cli/memory_commands.rb +16 -16
- data/lib/kward/cli/plugins.rb +3 -3
- data/lib/kward/cli/prompt_interface.rb +15 -13
- data/lib/kward/cli/rendering.rb +35 -46
- data/lib/kward/cli/runtime_helpers.rb +13 -2
- data/lib/kward/cli/sessions.rb +21 -21
- data/lib/kward/cli/settings.rb +49 -43
- data/lib/kward/cli/slash_commands.rb +6 -4
- data/lib/kward/cli/stats.rb +2 -2
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +5 -1
- data/lib/kward/cli.rb +14 -2
- data/lib/kward/cli_transcript_formatter.rb +36 -5
- data/lib/kward/compactor.rb +2 -2
- data/lib/kward/config_files.rb +45 -10
- data/lib/kward/conversation.rb +41 -9
- data/lib/kward/memory/manager.rb +131 -14
- data/lib/kward/message_access.rb +6 -0
- data/lib/kward/model/context_usage.rb +11 -10
- data/lib/kward/model/model_info.rb +18 -1
- data/lib/kward/model/payloads.rb +89 -10
- data/lib/kward/model/stream_parser.rb +258 -25
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +20 -11
- data/lib/kward/prompts.rb +61 -7
- data/lib/kward/rpc/server.rb +7 -2
- data/lib/kward/rpc/session_manager.rb +18 -2
- data/lib/kward/rpc/session_metrics.rb +2 -2
- data/lib/kward/rpc/transcript_normalizer.rb +47 -0
- data/lib/kward/session_store.rb +40 -1
- data/lib/kward/starter_pack_installer.rb +2 -2
- data/lib/kward/tools/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -0
- data/lib/kward/tools/registry.rb +9 -2
- data/lib/kward/tools/search/web.rb +3 -3
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- metadata +14 -1
data/lib/kward/compactor.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
244
|
-
read_prompt_file(path, "Kward
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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
|
data/lib/kward/memory/manager.rb
CHANGED
|
@@ -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" =>
|
|
154
|
-
"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
|
-
|
|
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" =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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"
|
data/lib/kward/message_access.rb
CHANGED
|
@@ -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
|
|
60
|
+
def redact_image_data(value)
|
|
62
61
|
case value
|
|
63
62
|
when Hash
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
67
|
+
value.map { |item| redact_image_data(item) }
|
|
71
68
|
else
|
|
72
|
-
|
|
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(
|
|
234
|
+
pattern_context_window(CODEX_CONTEXT_WINDOWS, id)
|
|
218
235
|
when "OpenRouter"
|
|
219
236
|
openrouter_context_window(id)
|
|
220
237
|
when "Copilot"
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|