kward 0.66.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "set"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "../config_files"
|
|
7
|
+
require_relative "../message_access"
|
|
8
|
+
require_relative "../model/client"
|
|
9
|
+
|
|
10
|
+
module Kward
|
|
11
|
+
module Memory
|
|
12
|
+
# Manages Kward's opt-in structured memory store.
|
|
13
|
+
#
|
|
14
|
+
# Core memories are explicit, high-trust instructions. Soft memories are
|
|
15
|
+
# confidence-scored contextual hints. Session memory is handled by session
|
|
16
|
+
# persistence, while this manager owns global/workspace JSON and JSONL files
|
|
17
|
+
# plus the audit event log.
|
|
18
|
+
class Manager
|
|
19
|
+
CORE_LIMIT = 6
|
|
20
|
+
SOFT_LIMIT = 6
|
|
21
|
+
DEFAULT_SOFT_TTL_DAYS = 60
|
|
22
|
+
DEFAULT_SOFT_CONFIDENCE = 0.65
|
|
23
|
+
EMOTIONAL_PATTERN = /\b(love|loves|romantic|intimate|dependency|depend on me|need me|flirty|crush)\b/i
|
|
24
|
+
|
|
25
|
+
# Details for the most recent retrieval, used by `/memory why`.
|
|
26
|
+
attr_reader :last_retrieval
|
|
27
|
+
|
|
28
|
+
def self.for_config_dir(config_dir, now: nil)
|
|
29
|
+
new(
|
|
30
|
+
config_path: File.join(config_dir, "config.json"),
|
|
31
|
+
core_path: File.join(config_dir, "memory", "core.json"),
|
|
32
|
+
soft_path: File.join(config_dir, "memory", "soft.jsonl"),
|
|
33
|
+
events_path: File.join(config_dir, "memory", "events.jsonl"),
|
|
34
|
+
now: now
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
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
|
+
@config_path = config_path
|
|
40
|
+
@core_path = core_path
|
|
41
|
+
@soft_path = soft_path
|
|
42
|
+
@events_path = events_path
|
|
43
|
+
@now = now
|
|
44
|
+
@last_retrieval = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] whether memory prompt injection is enabled
|
|
48
|
+
def enabled?
|
|
49
|
+
config = ConfigFiles.read_config(@config_path)
|
|
50
|
+
memory = config["memory"]
|
|
51
|
+
memory.is_a?(Hash) && memory["enabled"] == true
|
|
52
|
+
rescue StandardError
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def enable
|
|
57
|
+
config = ConfigFiles.read_config(@config_path)
|
|
58
|
+
memory = config["memory"].is_a?(Hash) ? config["memory"].dup : {}
|
|
59
|
+
memory["enabled"] = true
|
|
60
|
+
config["memory"] = memory
|
|
61
|
+
ConfigFiles.write_config(config, @config_path)
|
|
62
|
+
ensure_storage!
|
|
63
|
+
append_event("enable", {})
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def disable
|
|
68
|
+
config = ConfigFiles.read_config(@config_path)
|
|
69
|
+
memory = config["memory"].is_a?(Hash) ? config["memory"].dup : {}
|
|
70
|
+
memory["enabled"] = false
|
|
71
|
+
config["memory"] = memory
|
|
72
|
+
ConfigFiles.write_config(config, @config_path)
|
|
73
|
+
append_event("disable", {})
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def auto_summary_enabled?
|
|
78
|
+
config = ConfigFiles.read_config(@config_path)
|
|
79
|
+
memory = config["memory"]
|
|
80
|
+
memory.is_a?(Hash) && memory["auto_summary"] == true
|
|
81
|
+
rescue StandardError
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def auto_summary_enabled=(value)
|
|
86
|
+
set_auto_summary(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def set_auto_summary(value)
|
|
90
|
+
config = ConfigFiles.read_config(@config_path)
|
|
91
|
+
memory = config["memory"].is_a?(Hash) ? config["memory"].dup : {}
|
|
92
|
+
enabled = value ? true : false
|
|
93
|
+
previous = memory["auto_summary"] == true
|
|
94
|
+
if enabled
|
|
95
|
+
memory["auto_summary"] = true
|
|
96
|
+
else
|
|
97
|
+
memory.delete("auto_summary")
|
|
98
|
+
end
|
|
99
|
+
config["memory"] = memory
|
|
100
|
+
ConfigFiles.write_config(config, @config_path)
|
|
101
|
+
append_event(enabled ? "auto_summary_enable" : "auto_summary_disable", {}) unless previous == enabled
|
|
102
|
+
auto_summary_enabled?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def auto_summary_enable
|
|
106
|
+
set_auto_summary(true)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def auto_summary_disable
|
|
110
|
+
set_auto_summary(false)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Stores an explicit high-trust memory.
|
|
114
|
+
#
|
|
115
|
+
# @param text [String] memory text
|
|
116
|
+
# @param scope [String] `global` or `workspace:<path>` scope
|
|
117
|
+
# @param tags [Array<String>] searchable tags
|
|
118
|
+
# @return [Hash] stored memory record
|
|
119
|
+
def add_core(text, scope: "global", tags: [], source: "explicit_user_instruction", pinned: true)
|
|
120
|
+
record = {
|
|
121
|
+
"id" => next_id("core", core_memories.map { |item| item["id"] }),
|
|
122
|
+
"text" => clean_text(normalize_memory_text(text)),
|
|
123
|
+
"scope" => clean_scope(scope),
|
|
124
|
+
"tags" => clean_tags(tags),
|
|
125
|
+
"created_at" => timestamp,
|
|
126
|
+
"updated_at" => timestamp,
|
|
127
|
+
"source" => source,
|
|
128
|
+
"pinned" => pinned ? true : false
|
|
129
|
+
}
|
|
130
|
+
raise ArgumentError, "Memory text cannot be empty" if record["text"].empty?
|
|
131
|
+
|
|
132
|
+
memories = core_memories
|
|
133
|
+
memories << record
|
|
134
|
+
write_core(memories)
|
|
135
|
+
append_event("add", event_ref(record, layer: "core"))
|
|
136
|
+
record
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Stores a contextual hint with confidence and optional expiry.
|
|
140
|
+
#
|
|
141
|
+
# @param text [String] memory text
|
|
142
|
+
# @param scope [String] `global` or `workspace:<path>` scope
|
|
143
|
+
# @param tags [Array<String>] searchable tags
|
|
144
|
+
# @param confidence [Numeric] confidence clamped between 0.0 and 1.0
|
|
145
|
+
# @param ttl_days [Integer, nil] time-to-live in days
|
|
146
|
+
# @return [Hash] stored memory record
|
|
147
|
+
def add_soft(text, scope: "global", tags: [], confidence: DEFAULT_SOFT_CONFIDENCE, ttl_days: DEFAULT_SOFT_TTL_DAYS, source: "manual")
|
|
148
|
+
record = {
|
|
149
|
+
"id" => next_id("soft", soft_memories(include_inactive: true).map { |item| item["id"] }),
|
|
150
|
+
"text" => clean_text(normalize_memory_text(text)),
|
|
151
|
+
"scope" => clean_scope(scope),
|
|
152
|
+
"tags" => clean_tags(tags),
|
|
153
|
+
"confidence" => [[confidence.to_f, 0.0].max, 1.0].min,
|
|
154
|
+
"hits" => 0,
|
|
155
|
+
"created_at" => timestamp,
|
|
156
|
+
"updated_at" => timestamp,
|
|
157
|
+
"last_seen_at" => timestamp,
|
|
158
|
+
"ttl_days" => ttl_days.to_i <= 0 ? DEFAULT_SOFT_TTL_DAYS : ttl_days.to_i,
|
|
159
|
+
"source" => source,
|
|
160
|
+
"status" => "active"
|
|
161
|
+
}
|
|
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
|
+
|
|
165
|
+
append_soft(record)
|
|
166
|
+
append_event("add", event_ref(record, layer: "soft"))
|
|
167
|
+
record
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def promote_soft_to_core(id)
|
|
171
|
+
soft = soft_memories.find { |item| item["id"] == id.to_s }
|
|
172
|
+
raise ArgumentError, "Unknown active soft memory: #{id}" unless soft
|
|
173
|
+
|
|
174
|
+
core = add_core(soft["text"], scope: soft["scope"], tags: soft["tags"], source: "promoted_soft_memory", pinned: true)
|
|
175
|
+
forget_memory(id)
|
|
176
|
+
append_event("promote", { "from_id" => soft["id"], "to_id" => core["id"] })
|
|
177
|
+
core
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def forget_memory(id)
|
|
181
|
+
id = id.to_s
|
|
182
|
+
memories = core_memories
|
|
183
|
+
if memories.any? { |item| item["id"] == id }
|
|
184
|
+
write_core(memories.reject { |item| item["id"] == id })
|
|
185
|
+
append_event("forget", { "id" => id, "layer" => "core" })
|
|
186
|
+
return true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
records = soft_memories(include_inactive: true)
|
|
190
|
+
found = false
|
|
191
|
+
records.each do |item|
|
|
192
|
+
next unless item["id"] == id && item["status"] != "forgotten"
|
|
193
|
+
|
|
194
|
+
item["text"] = "[forgotten]"
|
|
195
|
+
item["tags"] = []
|
|
196
|
+
item["confidence"] = 0.0
|
|
197
|
+
item["hits"] = 0
|
|
198
|
+
item["status"] = "forgotten"
|
|
199
|
+
item["updated_at"] = timestamp
|
|
200
|
+
found = true
|
|
201
|
+
end
|
|
202
|
+
if found
|
|
203
|
+
write_soft(records)
|
|
204
|
+
append_event("forget", { "id" => id, "layer" => "soft" })
|
|
205
|
+
end
|
|
206
|
+
found
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def list(include_inactive: false)
|
|
210
|
+
{ "core" => core_memories, "soft" => soft_memories(include_inactive: include_inactive) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def inspect_memory
|
|
214
|
+
list(include_inactive: true).merge("enabled" => enabled?, "paths" => paths)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Selects bounded core and soft memories relevant to an interactive turn.
|
|
218
|
+
#
|
|
219
|
+
# Retrieval is scoped to global and workspace memories, prefers core
|
|
220
|
+
# memories, and scores soft memories by text/tag overlap, confidence, and
|
|
221
|
+
# expiry.
|
|
222
|
+
#
|
|
223
|
+
# @param input [String] current user input
|
|
224
|
+
# @param workspace_root [String] active workspace root
|
|
225
|
+
# @return [Hash] retrieval result for prompt injection and explanation
|
|
226
|
+
def retrieve_relevant(input:, workspace_root: Dir.pwd, max_core: CORE_LIMIT, max_soft: SOFT_LIMIT)
|
|
227
|
+
unless enabled?
|
|
228
|
+
@last_retrieval = { "enabled" => false, "core" => [], "soft" => [], "reasons" => [] }
|
|
229
|
+
return @last_retrieval
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
scopes = scopes_for(workspace_root)
|
|
233
|
+
terms = terms_for(input)
|
|
234
|
+
core = core_memories.select { |item| scopes.include?(item["scope"]) }.first(max_core)
|
|
235
|
+
core_reasons = core.map { |item| reason_for(item, layer: "core", score: 1.0, reasons: ["scope match", "core memories are preferred"]) }
|
|
236
|
+
|
|
237
|
+
soft_records_all = soft_memories(include_inactive: true)
|
|
238
|
+
soft_scored = soft_records_all.filter_map do |item|
|
|
239
|
+
next unless item["status"] == "active"
|
|
240
|
+
next unless scopes.include?(item["scope"])
|
|
241
|
+
next if expired?(item)
|
|
242
|
+
|
|
243
|
+
score, reasons = soft_score(item, terms)
|
|
244
|
+
next if score <= 0
|
|
245
|
+
|
|
246
|
+
[item, score, reasons]
|
|
247
|
+
end
|
|
248
|
+
soft = soft_scored.sort_by { |item, score, _reasons| [-score, -item["confidence"].to_f, item["id"].to_s] }.first(max_soft)
|
|
249
|
+
soft_records = soft.map(&:first)
|
|
250
|
+
touch_soft_records(soft_records_all, soft_records)
|
|
251
|
+
soft_reasons = soft.map { |item, score, reasons| reason_for(item, layer: "soft", score: score, reasons: reasons) }
|
|
252
|
+
|
|
253
|
+
@last_retrieval = {
|
|
254
|
+
"enabled" => true,
|
|
255
|
+
"scopes" => scopes,
|
|
256
|
+
"core" => core,
|
|
257
|
+
"soft" => soft_records,
|
|
258
|
+
"reasons" => core_reasons + soft_reasons
|
|
259
|
+
}
|
|
260
|
+
append_event("retrieve", { "core_ids" => core.map { |item| item["id"] }, "soft_ids" => soft_records.map { |item| item["id"] }, "scopes" => scopes })
|
|
261
|
+
@last_retrieval
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# @return [Hash] explanation data for the most recent retrieval
|
|
265
|
+
def explain_retrieval
|
|
266
|
+
@last_retrieval || { "enabled" => enabled?, "core" => [], "soft" => [], "reasons" => [], "message" => "No memory retrieval has run yet." }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def memory_block(retrieval)
|
|
270
|
+
core = Array(retrieval["core"])
|
|
271
|
+
soft = Array(retrieval["soft"])
|
|
272
|
+
return nil if core.empty? && soft.empty?
|
|
273
|
+
|
|
274
|
+
lines = ["<kward_memory>"]
|
|
275
|
+
unless core.empty?
|
|
276
|
+
lines << "Core Memories:"
|
|
277
|
+
core.each { |item| lines << "- [#{item["id"]}] #{item["text"]}" }
|
|
278
|
+
lines << ""
|
|
279
|
+
end
|
|
280
|
+
unless soft.empty?
|
|
281
|
+
lines << "Relevant Soft Memories:"
|
|
282
|
+
soft.each { |item| lines << "- [#{item["id"]}] #{item["text"]}" }
|
|
283
|
+
lines << ""
|
|
284
|
+
end
|
|
285
|
+
lines << "Rules:"
|
|
286
|
+
lines << "- Core memories override soft memories."
|
|
287
|
+
lines << "- Soft memories are contextual hints, not guaranteed facts."
|
|
288
|
+
lines << "</kward_memory>"
|
|
289
|
+
lines.join("\n")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def summarize_conversation(conversation, client: nil)
|
|
293
|
+
text = messages_for_summarization(conversation).map { |message| MessageAccess.content(message) }.compact.join("\n")
|
|
294
|
+
existing_texts = Array(conversation.session_memories).map { |memory| memory["text"] }
|
|
295
|
+
records = infer_soft_from_text(text, workspace_root: conversation.workspace_root, client: client, existing_texts: existing_texts)
|
|
296
|
+
conversation.session_memories.concat(records.map { |record| record.slice("id", "text", "scope", "tags") }) if conversation.session_memories.respond_to?(:concat)
|
|
297
|
+
records
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def infer_soft_from_text(text, workspace_root: Dir.pwd, client: nil, existing_texts: [])
|
|
301
|
+
candidates = heuristic_candidates(text)
|
|
302
|
+
existing_set = Set.new(existing_texts.map { |t| normalize_for_comparison(t) })
|
|
303
|
+
candidates.filter_map do |candidate|
|
|
304
|
+
summarized = summarize_text(candidate, client: client)
|
|
305
|
+
normalized = normalize_for_comparison(summarized)
|
|
306
|
+
# Skip if this text already exists in provided list or existing soft memories
|
|
307
|
+
next if existing_set.include?(normalized)
|
|
308
|
+
next if soft_memories.any? { |m| normalize_for_comparison(m["text"]) == normalized }
|
|
309
|
+
|
|
310
|
+
add_soft(summarized, scope: workspace_scope(workspace_root), tags: ["workflow"], confidence: 0.55, source: "inferred")
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def summarize_text(text, client: nil)
|
|
315
|
+
summarizer_client = client
|
|
316
|
+
unless summarizer_client
|
|
317
|
+
return text unless should_use_llm_summarization?
|
|
318
|
+
|
|
319
|
+
summarizer_client = default_client
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
summary = llm_summarize(summarizer_client, text)
|
|
323
|
+
summary.empty? ? text : summary
|
|
324
|
+
rescue StandardError
|
|
325
|
+
text
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def should_use_llm_summarization?
|
|
329
|
+
# Check if we have valid credentials to make LLM calls
|
|
330
|
+
client = default_client
|
|
331
|
+
return false unless client
|
|
332
|
+
|
|
333
|
+
token = client.instance_variable_get(:@openai_access_token) ||
|
|
334
|
+
client.instance_variable_get(:@openrouter_api_key) ||
|
|
335
|
+
client.send(:github_access_token)
|
|
336
|
+
token.to_s.length > 10
|
|
337
|
+
rescue StandardError
|
|
338
|
+
false
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def default_client
|
|
342
|
+
@default_client ||= Kward::Client.new
|
|
343
|
+
rescue StandardError
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def llm_summarize(client, text)
|
|
348
|
+
messages = [
|
|
349
|
+
{ role: "system", content: <<~SYSTEM },
|
|
350
|
+
You are a memory text reformulation assistant. Your task is to transform user-generated text into proper third-person descriptive memory statements.
|
|
351
|
+
|
|
352
|
+
Rules:
|
|
353
|
+
- Convert first-person statements ("I like", "we prefer") to third-person ("The captain likes", "The user prefers")
|
|
354
|
+
- Remove conversational filler, preambles, and action descriptions
|
|
355
|
+
- Keep only the factual preference or fact
|
|
356
|
+
- Use "The captain" or "The user" as the subject for personal preferences
|
|
357
|
+
- Preserve workflow-related technical preferences as-is
|
|
358
|
+
- Keep the text concise (under 100 characters)
|
|
359
|
+
- If the text is already a good memory statement, return it unchanged
|
|
360
|
+
|
|
361
|
+
Examples:
|
|
362
|
+
- "I like to eat the most important meal today: steak" → "The captain likes eating steak"
|
|
363
|
+
- "We should prefer TDD for this project" → "Prefer TDD for this project"
|
|
364
|
+
- "Remember that the user prefers minitest" → "The user prefers minitest"
|
|
365
|
+
- "But first we need to remember that we are using TDD" → "Use TDD"
|
|
366
|
+
- "The user usually asks for tests" → "The user usually asks for tests"
|
|
367
|
+
- "Prefer evidence from code, tests, logs" → "Prefer evidence from code, tests, logs"
|
|
368
|
+
SYSTEM
|
|
369
|
+
{ role: "user", content: "Reformulate this as a memory statement: #{text}" }
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
response = client.chat(messages, max_tokens: 100, reasoning: false)
|
|
373
|
+
content = response.dig("content") || response[:content]
|
|
374
|
+
return text unless content
|
|
375
|
+
|
|
376
|
+
content.strip
|
|
377
|
+
rescue StandardError
|
|
378
|
+
text
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def paths
|
|
382
|
+
{ "core" => @core_path, "soft" => @soft_path, "events" => @events_path }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private
|
|
386
|
+
|
|
387
|
+
def messages_for_summarization(conversation)
|
|
388
|
+
conversation.messages.select { |message| MessageAccess.role(message) == "user" }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def ensure_storage!
|
|
392
|
+
FileUtils.mkdir_p(File.dirname(@core_path), mode: 0o700)
|
|
393
|
+
write_core([]) unless File.exist?(@core_path)
|
|
394
|
+
File.open(@soft_path, File::WRONLY | File::CREAT | File::APPEND, 0o600) {} unless File.exist?(@soft_path)
|
|
395
|
+
File.open(@events_path, File::WRONLY | File::CREAT | File::APPEND, 0o600) {} unless File.exist?(@events_path)
|
|
396
|
+
[@core_path, @soft_path, @events_path].each { |path| File.chmod(0o600, path) if File.exist?(path) }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def core_memories
|
|
400
|
+
return [] unless File.exist?(@core_path)
|
|
401
|
+
|
|
402
|
+
data = JSON.parse(File.read(@core_path))
|
|
403
|
+
data.is_a?(Array) ? data : Array(data["memories"])
|
|
404
|
+
rescue JSON::ParserError
|
|
405
|
+
raise "Invalid Kward core memory JSON: #{@core_path}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def write_core(records)
|
|
409
|
+
ensure_parent(@core_path)
|
|
410
|
+
File.open(@core_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
411
|
+
file.write(JSON.pretty_generate(records))
|
|
412
|
+
file.write("\n")
|
|
413
|
+
end
|
|
414
|
+
File.chmod(0o600, @core_path)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def soft_memories(include_inactive: false)
|
|
418
|
+
return [] unless File.exist?(@soft_path)
|
|
419
|
+
|
|
420
|
+
File.readlines(@soft_path, chomp: true).filter_map do |line|
|
|
421
|
+
next if line.strip.empty?
|
|
422
|
+
|
|
423
|
+
JSON.parse(line)
|
|
424
|
+
rescue JSON::ParserError
|
|
425
|
+
nil
|
|
426
|
+
end.select { |item| include_inactive || item["status"] == "active" }
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def append_soft(record)
|
|
430
|
+
ensure_parent(@soft_path)
|
|
431
|
+
File.open(@soft_path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
|
|
432
|
+
file.write(JSON.generate(record))
|
|
433
|
+
file.write("\n")
|
|
434
|
+
end
|
|
435
|
+
File.chmod(0o600, @soft_path)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def write_soft(records)
|
|
439
|
+
ensure_parent(@soft_path)
|
|
440
|
+
File.open(@soft_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
441
|
+
records.each do |record|
|
|
442
|
+
file.write(JSON.generate(record))
|
|
443
|
+
file.write("\n")
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
File.chmod(0o600, @soft_path)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def append_event(type, payload)
|
|
450
|
+
ensure_parent(@events_path)
|
|
451
|
+
event = { "type" => type, "timestamp" => timestamp }.merge(payload)
|
|
452
|
+
File.open(@events_path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
|
|
453
|
+
file.write(JSON.generate(event))
|
|
454
|
+
file.write("\n")
|
|
455
|
+
end
|
|
456
|
+
File.chmod(0o600, @events_path)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def ensure_parent(path)
|
|
460
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def next_id(prefix, ids)
|
|
464
|
+
number = ids.filter_map { |id| id.to_s[/\A#{Regexp.escape(prefix)}_(\d+)\z/, 1]&.to_i }.max.to_i + 1
|
|
465
|
+
format("%s_%03d", prefix, number)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def timestamp
|
|
469
|
+
(@now || Time.now.utc).utc.iso8601(3)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def clean_text(text)
|
|
473
|
+
text.to_s.strip.gsub(/[\r\n]+/, " ")
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def normalize_for_comparison(text)
|
|
477
|
+
text.to_s.strip.gsub(/\s+/, " ")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def clean_scope(scope)
|
|
481
|
+
value = scope.to_s.strip
|
|
482
|
+
value.empty? ? "global" : value
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def clean_tags(tags)
|
|
486
|
+
Array(tags).flat_map { |tag| tag.to_s.split(/[,\s]+/) }.map(&:strip).reject(&:empty?).uniq.first(10)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def scopes_for(workspace_root)
|
|
490
|
+
["global", workspace_scope(workspace_root)]
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def workspace_scope(workspace_root)
|
|
494
|
+
"workspace:#{ConfigFiles.canonical_workspace_root(workspace_root)}"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def terms_for(input)
|
|
498
|
+
input.to_s.downcase.scan(/[a-z0-9_\-]{3,}/).uniq
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def soft_score(item, terms)
|
|
502
|
+
reasons = ["scope match"]
|
|
503
|
+
text_terms = terms_for(item["text"])
|
|
504
|
+
overlap = terms & text_terms
|
|
505
|
+
tags = Array(item["tags"]).map(&:to_s)
|
|
506
|
+
tag_overlap = terms & tags
|
|
507
|
+
return [0, reasons] if overlap.empty? && tag_overlap.empty?
|
|
508
|
+
|
|
509
|
+
score = item["confidence"].to_f
|
|
510
|
+
if overlap.any?
|
|
511
|
+
score += [overlap.length * 0.15, 0.45].min
|
|
512
|
+
reasons << "text overlap: #{overlap.first(5).join(", ")}"
|
|
513
|
+
end
|
|
514
|
+
if tag_overlap.any?
|
|
515
|
+
score += 0.25
|
|
516
|
+
reasons << "tag overlap: #{tag_overlap.join(", ")}"
|
|
517
|
+
end
|
|
518
|
+
reasons << "confidence #{format("%.2f", item["confidence"].to_f)}"
|
|
519
|
+
[score, reasons]
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def touch_soft_records(all_records, selected_records)
|
|
523
|
+
ids = Set.new(selected_records.map { |item| item["id"] })
|
|
524
|
+
return if ids.empty?
|
|
525
|
+
|
|
526
|
+
now = timestamp
|
|
527
|
+
changed = false
|
|
528
|
+
all_records.each do |item|
|
|
529
|
+
next unless ids.include?(item["id"])
|
|
530
|
+
next unless item["status"] == "active"
|
|
531
|
+
|
|
532
|
+
item["hits"] = item["hits"].to_i + 1
|
|
533
|
+
item["last_seen_at"] = now
|
|
534
|
+
item["updated_at"] = now
|
|
535
|
+
changed = true
|
|
536
|
+
end
|
|
537
|
+
write_soft(all_records) if changed
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def reason_for(item, layer:, score:, reasons:)
|
|
541
|
+
{ "id" => item["id"], "layer" => layer, "score" => score.round(3), "reasons" => reasons }
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def expired?(item)
|
|
545
|
+
ttl = item["ttl_days"].to_i
|
|
546
|
+
return false if ttl <= 0
|
|
547
|
+
|
|
548
|
+
last_seen = Time.parse(item["last_seen_at"].to_s)
|
|
549
|
+
last_seen < (@now || Time.now.utc).utc - (ttl * 86_400)
|
|
550
|
+
rescue StandardError
|
|
551
|
+
false
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def event_ref(record, layer:)
|
|
555
|
+
{ "id" => record["id"], "layer" => layer, "scope" => record["scope"], "tags" => record["tags"] }
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def heuristic_candidates(text)
|
|
559
|
+
lines = text.to_s.split(/[
|
|
560
|
+
.;]/).map(&:strip)
|
|
561
|
+
lines.filter_map do |line|
|
|
562
|
+
# Skip lines that look like memory display output (e.g., "- soft_001 [workspace:...] text")
|
|
563
|
+
next if line.match?(/\A-\s*(core|soft)_\d+\s/)
|
|
564
|
+
|
|
565
|
+
candidate = explicit_memory_candidate(line) || personal_memory_candidate(line)
|
|
566
|
+
next if candidate.to_s.empty? || unsafe_soft_text?(candidate)
|
|
567
|
+
|
|
568
|
+
clean_text(candidate)
|
|
569
|
+
end.reject(&:empty?).uniq.first(5)
|
|
570
|
+
end
|
|
571
|
+
def explicit_memory_candidate(line)
|
|
572
|
+
line.to_s[/\b(?:important information|remember this|please remember|note that)\s*:\s*(.+)\z/i, 1]
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def personal_memory_candidate(line)
|
|
576
|
+
text = line.to_s.strip
|
|
577
|
+
# Skip URL fragments like "com/kaiwood/kward]..."
|
|
578
|
+
return nil if text.match?(/\A[\w\/\.]+\]/)
|
|
579
|
+
return nil unless text.match?(/\b(?:i|we|user|captain)\b/i)
|
|
580
|
+
return nil unless text.match?(/\b(?:prefer|prefers|usually|always|workflow|project|use|uses|avoid|avoids|like|likes)\b/i)
|
|
581
|
+
|
|
582
|
+
text
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def unsafe_soft_text?(text)
|
|
586
|
+
text.to_s.match?(EMOTIONAL_PATTERN)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def normalize_memory_text(text)
|
|
590
|
+
normalized = text.to_s.strip
|
|
591
|
+
|
|
592
|
+
# Strip workspace context if it appears at the start: [workspace:/path/to/dir]
|
|
593
|
+
normalized = normalized.sub(/\A\[workspace:[^\]]*\]\s*/, '')
|
|
594
|
+
# Also strip any remaining fragment like "Com/kaiwood/kward]" that may be left over from malformed input
|
|
595
|
+
normalized = normalized.sub(/\A[\w\/\-\.]+\]\s*/, '') if normalized.match?(/\A[\w\/\-\.]+\]/)
|
|
596
|
+
|
|
597
|
+
# Only apply aggressive transformations if we detect a preamble pattern
|
|
598
|
+
has_preamble = normalized.match?(/\b(?:But\s+first|Remember\s+that|Please\s+remember|Note\s+that|I\s+should\s+remember)\b/i)
|
|
599
|
+
|
|
600
|
+
# Remove verbose preambles
|
|
601
|
+
preamble_patterns = [
|
|
602
|
+
/\bBut\s+first\s+we\s+always\s+need\s+to\s+remember\s+that\s+/i,
|
|
603
|
+
/\bRemember\s+that\s+/i,
|
|
604
|
+
/\bPlease\s+remember\s+to\s+/i,
|
|
605
|
+
/\bNote\s+that\s+/i,
|
|
606
|
+
/\bI\s+should\s+remember\s+that\s+/i
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
if has_preamble
|
|
610
|
+
preamble_patterns.each do |pattern|
|
|
611
|
+
normalized = normalized.sub(pattern) { "" }
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Mapping from gerund (-ing form) to imperative base form
|
|
615
|
+
gerund_map = {
|
|
616
|
+
"using" => "Use",
|
|
617
|
+
"testing" => "Test",
|
|
618
|
+
"relying" => "Rely",
|
|
619
|
+
"preferring" => "Prefer",
|
|
620
|
+
"avoiding" => "Avoid",
|
|
621
|
+
"keeping" => "Keep",
|
|
622
|
+
"writing" => "Write",
|
|
623
|
+
"reading" => "Read",
|
|
624
|
+
"checking" => "Check",
|
|
625
|
+
"reviewing" => "Review",
|
|
626
|
+
"validating" => "Validate"
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
# Transform "we are X" to imperative: "we are using" -> "Use"
|
|
630
|
+
normalized = normalized.sub(/\bwe\s+are\s+(\w+ing)\b/i) do |_match|
|
|
631
|
+
gerund = Regexp.last_match(1).downcase
|
|
632
|
+
gerund_map[gerund] || gerund.capitalize
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Transform "we should X" to imperative: "we should prefer" -> "Prefer"
|
|
636
|
+
normalized = normalized.sub(/\bwe\s+should\s+(\w+)\b/i) do |_match|
|
|
637
|
+
word = Regexp.last_match(1)
|
|
638
|
+
word.capitalize
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Remove remaining "we" or "I" at the line start
|
|
642
|
+
normalized = normalized.sub(/\b(we|i)\s+/i, '')
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# Capitalize first letter if lowercase
|
|
646
|
+
normalized = normalized.sub(/\A([a-z])/) { |m| m.upcase }
|
|
647
|
+
|
|
648
|
+
normalized.strip
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Kward
|
|
2
|
+
module MessageAccess
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def value(object, key)
|
|
6
|
+
return nil unless object.respond_to?(:key?)
|
|
7
|
+
return object[key] if object.key?(key)
|
|
8
|
+
return object[key.to_s] if object.key?(key.to_s)
|
|
9
|
+
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def role(message)
|
|
14
|
+
value(message, :role)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def content(message)
|
|
18
|
+
value(message, :content)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def display_content(message)
|
|
22
|
+
value(message, :display_content) || value(message, :displayContent)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def summary(message)
|
|
26
|
+
value(message, :summary)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name(message)
|
|
30
|
+
value(message, :name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def tool_call_id(message)
|
|
34
|
+
value(message, :tool_call_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tool_calls(message)
|
|
38
|
+
calls = value(message, :tool_calls)
|
|
39
|
+
calls.is_a?(Array) ? calls : []
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|