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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. 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