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