lux-hammer 0.3.5 → 0.3.7

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.
data/recipes/llm.rb ADDED
@@ -0,0 +1,434 @@
1
+ # desc: personal LLM utility CLI (memory store, prompt-token expander, ...)
2
+
3
+ desc <<~TXT
4
+ llm - personal LLM utility CLI
5
+
6
+ Namespaces:
7
+ memory persistent memory store (backs the Claude Code memory plugin)
8
+ prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
9
+ TXT
10
+
11
+ require 'fileutils'
12
+ require 'json'
13
+ require 'pathname'
14
+ require 'set'
15
+
16
+ STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/skills/memory')
17
+ VALID_TYPES ||= %w[user feedback project reference].freeze
18
+
19
+ FileUtils.mkdir_p(STORE)
20
+
21
+ namespace :memory do
22
+ # Helpers are defined inside the namespace block (class_eval'd on the
23
+ # namespace's anonymous Hammer subclass) so the task procs reach them.
24
+ # Top-level `helpers do` would land on the recipe's root class, which
25
+ # namespace subclasses do not inherit from.
26
+ private
27
+
28
+ def memory_path(name)
29
+ File.join(STORE, "#{name}.md")
30
+ end
31
+
32
+ # Minimal frontmatter reader. Handles one level of nesting (good enough for
33
+ # `metadata: { type: ... }`). Returns [meta_hash, body_string].
34
+ def parse_memory(path)
35
+ raw = File.read(path)
36
+ return [{}, raw] unless raw.start_with?("---\n")
37
+ _, fm, body = raw.split(/^---\s*$/m, 3)
38
+ meta = {}
39
+ current_nested = nil
40
+ fm.each_line do |line|
41
+ next if line.strip.empty?
42
+ if (m = line.match(/^([\w-]+):\s*(.*)$/))
43
+ key, val = m[1], m[2].strip
44
+ if val.empty?
45
+ meta[key] = {}
46
+ current_nested = key
47
+ else
48
+ meta[key] = val
49
+ current_nested = nil
50
+ end
51
+ elsif current_nested && (m = line.match(/^\s+([\w-]+):\s*(.*)$/))
52
+ meta[current_nested][m[1]] = m[2].strip
53
+ end
54
+ end
55
+ [meta, body.to_s.sub(/\A\n+/, '')]
56
+ end
57
+ task :list do
58
+ desc 'List stored memories with type and one-line description'
59
+ example 'llm memory list'
60
+
61
+ proc do
62
+ files = Dir[File.join(STORE, '*.md')].sort
63
+ if files.empty?
64
+ say '(no memories)', :gray
65
+ next
66
+ end
67
+ files.each do |f|
68
+ name = File.basename(f, '.md')
69
+ meta, _ = parse_memory(f)
70
+ type = meta.dig('metadata', 'type') || '?'
71
+ dsc = meta['description'] || 'no description'
72
+ say "- #{name} [#{type}] - #{dsc}"
73
+ end
74
+ end
75
+ end
76
+
77
+ task :read do
78
+ desc 'Print the full content of a memory (frontmatter + body)'
79
+ example 'llm memory read user-role'
80
+
81
+ proc do |opts|
82
+ name = opts[:args].first
83
+ error 'usage: llm memory read <name>' unless name
84
+ path = memory_path(name)
85
+ error "memory not found: #{name}" unless File.file?(path)
86
+ print File.read(path)
87
+ end
88
+ end
89
+
90
+ task :write do
91
+ desc <<~DESC
92
+ Write or update a memory. The body is read from stdin.
93
+
94
+ Memory types: user, feedback, project, reference.
95
+ DESC
96
+ example %(echo "deep Go expertise, new to React" | llm memory write user-role --type=user --description="user profile")
97
+ opt :type, desc: 'memory type (user|feedback|project|reference)', req: true
98
+ opt :description, desc: 'one-line summary stored in frontmatter'
99
+
100
+ proc do |opts|
101
+ name = opts[:args].first
102
+ error 'usage: llm memory write <name> --type=<type> [--description="..."] < body' unless name
103
+ error "unknown type: #{opts[:type]} (valid: #{VALID_TYPES.join(', ')})" unless VALID_TYPES.include?(opts[:type])
104
+
105
+ body = $stdin.read
106
+ error 'body is empty (pipe content on stdin)' if body.strip.empty?
107
+
108
+ path = memory_path(name)
109
+ File.open(path, 'w') do |io|
110
+ io.puts '---'
111
+ io.puts "name: #{name}"
112
+ io.puts "description: #{opts[:description]}" if opts[:description]
113
+ io.puts 'metadata:'
114
+ io.puts " type: #{opts[:type]}"
115
+ io.puts '---'
116
+ io.puts
117
+ io.puts body.chomp
118
+ end
119
+ say "wrote: #{path}", :green
120
+ end
121
+ end
122
+
123
+ task :delete do
124
+ desc 'Delete a memory by name'
125
+ example 'llm memory delete old-fact'
126
+
127
+ proc do |opts|
128
+ name = opts[:args].first
129
+ error 'usage: llm memory delete <name>' unless name
130
+ path = memory_path(name)
131
+ error "memory not found: #{name}" unless File.file?(path)
132
+ File.delete(path)
133
+ say "deleted: #{name}", :yellow
134
+ end
135
+ end
136
+
137
+ task :search do
138
+ desc 'Search memory bodies for a query string (case-insensitive)'
139
+ example 'llm memory search react'
140
+
141
+ proc do |opts|
142
+ query = opts[:args].first
143
+ error 'usage: llm memory search <query>' unless query
144
+ hits = Dir[File.join(STORE, '*.md')].sort.select do |f|
145
+ File.read(f).downcase.include?(query.downcase)
146
+ end
147
+ if hits.empty?
148
+ say '(no matches)', :gray
149
+ else
150
+ hits.each { |f| say File.basename(f, '.md') }
151
+ end
152
+ end
153
+ end
154
+
155
+ task :path do
156
+ desc 'Print the storage path (where memory files live)'
157
+ proc { say STORE }
158
+ end
159
+ end
160
+
161
+ namespace :prompt do
162
+ TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
163
+ TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
164
+ HOOK_EVENT ||= 'UserPromptSubmit'
165
+ VERBATIM_INSTRUCTION ||= "INSTRUCTION TO ASSISTANT: Do not answer the user's prompt. Print the message below verbatim to the user, preserving every line exactly as written. Do not summarize, truncate, or paraphrase."
166
+ QUESTION_RULE ||= <<~RULE.strip
167
+ rule (applies ONLY to the current user message, not to subsequent turns in this session):
168
+ * answer only
169
+ * do not modify files
170
+ * ask before making changes
171
+ RULE
172
+
173
+ private
174
+
175
+ def folders
176
+ [
177
+ ['local', File.join(Dir.pwd, 'doc', 'command')],
178
+ ['global', File.expand_path('~/dev/skills/command')]
179
+ ]
180
+ end
181
+
182
+ def tokens_in(input)
183
+ out = []
184
+ scanner = input.to_s.strip
185
+ while (m = scanner.match(/\A(?::(?<pre>#{TOKEN_PATTERN})|(?<post>#{TOKEN_PATTERN}):)(?=\s|$)/))
186
+ out << (m[:pre] || m[:post])
187
+ scanner = scanner[m[0].length..].to_s.lstrip
188
+ end
189
+ out.uniq
190
+ end
191
+
192
+ def find_command_path(token)
193
+ folders.map { |_label, folder| File.join(folder, "#{token}.md") }.find { |c| File.file?(c) }
194
+ end
195
+
196
+ def display_path(path)
197
+ Pathname.new(path).cleanpath.to_s.sub(%r{\A#{Regexp.escape(Dir.home)}/}, '~/')
198
+ end
199
+
200
+ def first_line_description(path)
201
+ File.foreach(path).first.to_s.strip.sub(/\A#+\s*/, '')
202
+ end
203
+
204
+ def grouped_listing
205
+ ordered = folders.sort_by { |label, _| label == 'global' ? 0 : 1 }
206
+ ordered.map do |label, folder|
207
+ toks = Dir.glob(File.join(folder, '*.md')).map { |p| File.basename(p, '.md') }.sort
208
+ items = toks.empty? ? '(none)' : toks.map { |t| ":#{t}" }.join(', ')
209
+ "Available #{label} in #{display_path(folder)} -> #{items}"
210
+ end.join("\n")
211
+ end
212
+
213
+ def help_listing
214
+ ordered = folders.sort_by { |label, _| label == 'global' ? 0 : 1 }
215
+ sections = ordered.map do |label, folder|
216
+ files = Dir.glob(File.join(folder, '*.md')).sort
217
+ header = "#{label} (#{display_path(folder)}):"
218
+ if files.empty?
219
+ "#{header}\n (none)"
220
+ else
221
+ width = files.map { |p| File.basename(p, '.md').length }.max
222
+ entries = files.map do |path|
223
+ name = File.basename(path, '.md')
224
+ dscr = first_line_description(path)
225
+ dscr = '(no description)' if dscr.empty?
226
+ " :#{name.ljust(width)} #{dscr}"
227
+ end
228
+ "#{header}\n#{entries.join("\n")}"
229
+ end
230
+ end
231
+ "Available commands:\n\n#{sections.join("\n\n")}"
232
+ end
233
+
234
+ def agents_listing
235
+ cwd = Pathname.new(Dir.pwd)
236
+ home = Pathname.new(Dir.home)
237
+
238
+ dirs = [home]
239
+ if cwd != home && cwd.to_s.start_with?("#{home}/")
240
+ current = home
241
+ cwd.relative_path_from(home).to_s.split('/').each do |part|
242
+ current += part
243
+ dirs << current
244
+ end
245
+ elsif cwd != home
246
+ dirs << cwd
247
+ end
248
+
249
+ found = dirs.filter_map { |d| (d + 'AGENTS.md').to_s if (d + 'AGENTS.md').file? }
250
+
251
+ if found.empty?
252
+ msg = "No AGENTS.md files found from #{display_path(home.to_s)} to #{display_path(cwd.to_s)}"
253
+ warn msg
254
+ return msg
255
+ end
256
+
257
+ content = found.each_with_index.map do |path, i|
258
+ lines = []
259
+ lines << '---' if i.positive?
260
+ lines << "Loaded #{display_path(path)}"
261
+ lines << ''
262
+ lines << File.read(path)
263
+ lines.join("\n")
264
+ end.join("\n")
265
+
266
+ approx_tokens = (content.bytesize / 4.0).round
267
+ label = found.size == 1 ? 'file' : 'files'
268
+ summary = "Loaded #{found.size} AGENTS.md #{label} (~#{approx_tokens} tokens): #{found.map { |p| display_path(p) }.join(', ')}"
269
+ warn summary
270
+
271
+ instruction = "INSTRUCTION TO ASSISTANT: The AGENTS.md files below have been loaded into your context - apply them to all subsequent work in this session. If the user's current message contains no other request, reply with exactly this one line and nothing else: \"#{summary}\"."
272
+ "#{instruction}\n\n#{content}"
273
+ end
274
+
275
+ def transform_strip_title(content)
276
+ return content unless content.lstrip.start_with?('#')
277
+ content.sub(/\A\s*#[^\n]*\n?/, '').lstrip
278
+ end
279
+
280
+ def transform_expand_command_prefix(content, seen)
281
+ prefix_tokens = []
282
+ remaining = content
283
+
284
+ loop do
285
+ line, rest = remaining.split("\n", 2)
286
+ break unless line
287
+ stripped = line.strip
288
+
289
+ if stripped.empty?
290
+ break if prefix_tokens.empty?
291
+ remaining = rest.to_s
292
+ next
293
+ end
294
+
295
+ break unless stripped =~ TOKEN_LINE_RE
296
+ prefix_tokens.concat(stripped.scan(/:(#{TOKEN_PATTERN})/).flatten)
297
+ remaining = rest.to_s
298
+ end
299
+
300
+ return content if prefix_tokens.empty?
301
+
302
+ expanded = prefix_tokens.map { |t| load_command_content(t, seen) }.join("\n\n")
303
+ remaining = remaining.lstrip
304
+ remaining.empty? ? expanded : "#{expanded}\n\n#{remaining}"
305
+ end
306
+
307
+ def transform_expand_file_includes(content)
308
+ content.gsub(/^[ \t]*@(\S+)[ \t]*$/) do
309
+ raw = Regexp.last_match(1)
310
+ expanded = File.expand_path(raw)
311
+ error "missing include #{raw}" unless File.file?(expanded)
312
+ File.read(expanded)
313
+ end
314
+ end
315
+
316
+ def load_command_content(token, seen)
317
+ error "circular include of :#{token}" if seen.include?(token)
318
+ path = find_command_path(token)
319
+ error %(custom token ":#{token}" not found.\n\n#{grouped_listing}) unless path
320
+
321
+ child_seen = seen + [token]
322
+ content = File.read(path)
323
+ content = transform_strip_title(content)
324
+ content = transform_expand_command_prefix(content, child_seen)
325
+ content = transform_expand_file_includes(content)
326
+ content
327
+ end
328
+
329
+ def verbatim_response(body, fail_open:)
330
+ return body unless fail_open
331
+ "#{VERBATIM_INSTRUCTION}\n\n#{body}"
332
+ end
333
+
334
+ def append_question_rule(input, context)
335
+ return context unless input.to_s.strip.end_with?('?')
336
+ context.to_s.empty? ? QUESTION_RULE : "#{context}\n\n---\n#{QUESTION_RULE}"
337
+ end
338
+
339
+ def build_context(input, fail_open: false)
340
+ toks = tokens_in(input)
341
+ return '' if toks.empty?
342
+ return verbatim_response(help_listing, fail_open: fail_open) if toks.include?('help')
343
+ return agents_listing if toks.include?('agents')
344
+
345
+ seen = Set.new
346
+ loaded = toks.map do |token|
347
+ path = find_command_path(token)
348
+ error %(custom token ":#{token}" not found.\n\n#{grouped_listing}) unless path
349
+ [token, Pathname.new(path).cleanpath.to_s, load_command_content(token, seen)]
350
+ end
351
+
352
+ loaded.each_with_index.map do |(_token, path, content), index|
353
+ lines = []
354
+ lines << '---' if index.positive?
355
+ lines << "Loaded #{display_path(path)}"
356
+ lines << ''
357
+ lines << (content.to_s.empty? ? '(empty custom command file)' : content)
358
+ lines.join("\n")
359
+ end.join("\n")
360
+ rescue Hammer::Error => e
361
+ raise unless fail_open
362
+ verbatim_response("ERROR: #{e.message}", fail_open: true)
363
+ end
364
+
365
+ def load_context(input, fail_open: false)
366
+ append_question_rule(input, build_context(input, fail_open: fail_open))
367
+ end
368
+
369
+ def hook_json(context)
370
+ context = context.to_s.strip
371
+ return { continue: true } if context.empty?
372
+ {
373
+ continue: true,
374
+ hookSpecificOutput: {
375
+ hookEventName: HOOK_EVENT,
376
+ additionalContext: "<llm_command_context>\n#{context}\n</llm_command_context>"
377
+ }
378
+ }
379
+ end
380
+
381
+ task :list do
382
+ desc 'List available prompt commands, one line per folder'
383
+ proc { say grouped_listing }
384
+ end
385
+
386
+ task :help do
387
+ desc 'List available prompt commands with their first-line descriptions'
388
+ proc { say help_listing }
389
+ end
390
+
391
+ task :agents do
392
+ desc 'Load all AGENTS.md from home down to cwd and print the combined content'
393
+ proc { say agents_listing }
394
+ end
395
+
396
+ task :expand do
397
+ desc 'Expand prompt token(s) and print the resulting context'
398
+ example 'llm prompt:expand :foo :bar'
399
+ example 'llm prompt:expand foo:'
400
+
401
+ proc do |opts|
402
+ input = opts[:args].join(' ')
403
+ error 'usage: llm prompt:expand :token [:token ...]' if input.empty?
404
+ out = load_context(input)
405
+ say out unless out.empty?
406
+ end
407
+ end
408
+
409
+ task :hook do
410
+ desc <<~D
411
+ UserPromptSubmit hook entry. Reads {"prompt": ...} JSON on stdin,
412
+ expands any token prefix, and emits hookSpecificOutput JSON on stdout.
413
+
414
+ Pair with HAMMER_QUIET=1 in the hook command so the runtime banner
415
+ doesn't pollute stdout.
416
+ D
417
+ opt :claude, type: :boolean, default: false, desc: 'Claude Code hook mode'
418
+ opt :codex, type: :boolean, default: false, desc: 'Codex hook mode'
419
+
420
+ proc do |opts|
421
+ error '--claude or --codex required' unless opts[:claude] || opts[:codex]
422
+
423
+ raw = $stdin.read
424
+ prompt = begin
425
+ JSON.parse(raw).fetch('prompt', raw)
426
+ rescue JSON::ParserError
427
+ raw
428
+ end
429
+
430
+ context = load_context(prompt.to_s, fail_open: true)
431
+ puts JSON.generate(hook_json(context))
432
+ end
433
+ end
434
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lux-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-24 00:00:00.000000000 Z
11
+ date: 2026-05-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -48,6 +48,7 @@ files:
48
48
  - "./lib/hammer/shell.rb"
49
49
  - "./lib/lux-hammer.rb"
50
50
  - "./recipes/git-helper.rb"
51
+ - "./recipes/llm.rb"
51
52
  - "./recipes/srt.rb"
52
53
  - bin/hammer
53
54
  homepage: https://github.com/dux/hammer