claude_memory 0.1.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/.claude/CLAUDE.md +3 -0
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/output-styles/memory-aware.md +21 -0
- data/.claude/rules/claude_memory.generated.md +21 -0
- data/.claude/settings.json +62 -0
- data/.claude/settings.local.json +21 -0
- data/.claude-plugin/marketplace.json +13 -0
- data/.claude-plugin/plugin.json +10 -0
- data/.mcp.json +11 -0
- data/CHANGELOG.md +36 -0
- data/CLAUDE.md +224 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/Rakefile +10 -0
- data/commands/analyze.md +29 -0
- data/commands/recall.md +17 -0
- data/commands/remember.md +26 -0
- data/docs/demo.md +126 -0
- data/docs/organizational_memory_playbook.md +291 -0
- data/docs/plan.md +411 -0
- data/docs/plugin.md +202 -0
- data/docs/updated_plan.md +453 -0
- data/exe/claude-memory +8 -0
- data/hooks/hooks.json +59 -0
- data/lib/claude_memory/cli.rb +869 -0
- data/lib/claude_memory/distill/distiller.rb +11 -0
- data/lib/claude_memory/distill/extraction.rb +29 -0
- data/lib/claude_memory/distill/json_schema.md +78 -0
- data/lib/claude_memory/distill/null_distiller.rb +123 -0
- data/lib/claude_memory/hook/handler.rb +49 -0
- data/lib/claude_memory/index/lexical_fts.rb +58 -0
- data/lib/claude_memory/ingest/ingester.rb +46 -0
- data/lib/claude_memory/ingest/transcript_reader.rb +21 -0
- data/lib/claude_memory/mcp/server.rb +127 -0
- data/lib/claude_memory/mcp/tools.rb +409 -0
- data/lib/claude_memory/publish.rb +201 -0
- data/lib/claude_memory/recall.rb +360 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +30 -0
- data/lib/claude_memory/resolve/resolver.rb +152 -0
- data/lib/claude_memory/store/sqlite_store.rb +340 -0
- data/lib/claude_memory/store/store_manager.rb +139 -0
- data/lib/claude_memory/sweep/sweeper.rb +80 -0
- data/lib/claude_memory/templates/hooks.example.json +74 -0
- data/lib/claude_memory/templates/output-styles/memory-aware.md +21 -0
- data/lib/claude_memory/version.rb +5 -0
- data/lib/claude_memory.rb +36 -0
- data/sig/claude_memory.rbs +4 -0
- data/skills/analyze/SKILL.md +126 -0
- data/skills/memory/SKILL.md +82 -0
- metadata +123 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
class CLI
|
|
7
|
+
COMMANDS = %w[help version db:init].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(args = ARGV, stdout: $stdout, stderr: $stderr, stdin: $stdin)
|
|
10
|
+
@args = args
|
|
11
|
+
@stdout = stdout
|
|
12
|
+
@stderr = stderr
|
|
13
|
+
@stdin = stdin
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
command = @args.first || "help"
|
|
18
|
+
|
|
19
|
+
case command
|
|
20
|
+
when "help", "-h", "--help"
|
|
21
|
+
print_help
|
|
22
|
+
0
|
|
23
|
+
when "version", "-v", "--version"
|
|
24
|
+
print_version
|
|
25
|
+
0
|
|
26
|
+
when "db:init"
|
|
27
|
+
db_init
|
|
28
|
+
0
|
|
29
|
+
when "init"
|
|
30
|
+
init_project
|
|
31
|
+
when "ingest"
|
|
32
|
+
ingest
|
|
33
|
+
when "search"
|
|
34
|
+
search
|
|
35
|
+
when "recall"
|
|
36
|
+
recall_cmd
|
|
37
|
+
when "explain"
|
|
38
|
+
explain_cmd
|
|
39
|
+
when "conflicts"
|
|
40
|
+
conflicts_cmd
|
|
41
|
+
when "changes"
|
|
42
|
+
changes_cmd
|
|
43
|
+
when "sweep"
|
|
44
|
+
sweep_cmd
|
|
45
|
+
when "serve-mcp"
|
|
46
|
+
serve_mcp
|
|
47
|
+
when "publish"
|
|
48
|
+
publish_cmd
|
|
49
|
+
when "hook"
|
|
50
|
+
hook_cmd
|
|
51
|
+
when "doctor"
|
|
52
|
+
doctor_cmd
|
|
53
|
+
when "promote"
|
|
54
|
+
promote_cmd
|
|
55
|
+
else
|
|
56
|
+
@stderr.puts "Unknown command: #{command}"
|
|
57
|
+
@stderr.puts "Run 'claude-memory help' for usage."
|
|
58
|
+
1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def print_help
|
|
65
|
+
@stdout.puts <<~HELP
|
|
66
|
+
claude-memory - Long-term memory for Claude Code
|
|
67
|
+
|
|
68
|
+
Usage: claude-memory <command> [options]
|
|
69
|
+
|
|
70
|
+
Commands:
|
|
71
|
+
changes Show recent fact changes
|
|
72
|
+
conflicts Show open conflicts
|
|
73
|
+
db:init Initialize the SQLite database
|
|
74
|
+
doctor Check system health
|
|
75
|
+
explain Explain a fact with receipts
|
|
76
|
+
help Show this help message
|
|
77
|
+
hook Run hook entrypoints (ingest|sweep|publish)
|
|
78
|
+
init Initialize ClaudeMemory in a project
|
|
79
|
+
ingest Ingest transcript delta
|
|
80
|
+
promote Promote a project fact to global memory
|
|
81
|
+
publish Publish snapshot to Claude Code memory
|
|
82
|
+
recall Recall facts matching a query
|
|
83
|
+
search Search indexed content
|
|
84
|
+
serve-mcp Start MCP server
|
|
85
|
+
sweep Run maintenance/pruning
|
|
86
|
+
version Show version number
|
|
87
|
+
|
|
88
|
+
Run 'claude-memory <command> --help' for more information on a command.
|
|
89
|
+
HELP
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def print_version
|
|
93
|
+
@stdout.puts "claude-memory #{ClaudeMemory::VERSION}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def db_init
|
|
97
|
+
opts = parse_db_init_options
|
|
98
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
99
|
+
|
|
100
|
+
if opts[:global]
|
|
101
|
+
manager.ensure_global!
|
|
102
|
+
@stdout.puts "Global database initialized at #{manager.global_db_path}"
|
|
103
|
+
@stdout.puts "Schema version: #{manager.global_store.schema_version}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if opts[:project]
|
|
107
|
+
manager.ensure_project!
|
|
108
|
+
@stdout.puts "Project database initialized at #{manager.project_db_path}"
|
|
109
|
+
@stdout.puts "Schema version: #{manager.project_store.schema_version}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
manager.close
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_db_init_options
|
|
116
|
+
opts = {global: false, project: false}
|
|
117
|
+
|
|
118
|
+
parser = OptionParser.new do |o|
|
|
119
|
+
o.banner = "Usage: claude-memory db:init [options]"
|
|
120
|
+
o.on("--global", "Initialize global database (~/.claude/memory.sqlite3)") { opts[:global] = true }
|
|
121
|
+
o.on("--project", "Initialize project database (.claude/memory.sqlite3)") { opts[:project] = true }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
parser.parse!(@args[1..])
|
|
125
|
+
|
|
126
|
+
opts[:global] = true if !opts[:global] && !opts[:project]
|
|
127
|
+
opts[:project] = true if !opts[:global] && !opts[:project]
|
|
128
|
+
|
|
129
|
+
opts
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ingest
|
|
133
|
+
opts = parse_ingest_options
|
|
134
|
+
return 1 unless opts
|
|
135
|
+
|
|
136
|
+
store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
|
|
137
|
+
ingester = ClaudeMemory::Ingest::Ingester.new(store)
|
|
138
|
+
|
|
139
|
+
result = ingester.ingest(
|
|
140
|
+
source: opts[:source],
|
|
141
|
+
session_id: opts[:session_id],
|
|
142
|
+
transcript_path: opts[:transcript_path]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
case result[:status]
|
|
146
|
+
when :ingested
|
|
147
|
+
@stdout.puts "Ingested #{result[:bytes_read]} bytes (content_id: #{result[:content_id]})"
|
|
148
|
+
when :no_change
|
|
149
|
+
@stdout.puts "No new content to ingest"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
store.close
|
|
153
|
+
0
|
|
154
|
+
rescue ClaudeMemory::Ingest::TranscriptReader::FileNotFoundError => e
|
|
155
|
+
@stderr.puts "Error: #{e.message}"
|
|
156
|
+
1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def parse_ingest_options
|
|
160
|
+
opts = {source: "claude_code", db: ClaudeMemory.project_db_path}
|
|
161
|
+
|
|
162
|
+
parser = OptionParser.new do |o|
|
|
163
|
+
o.banner = "Usage: claude-memory ingest [options]"
|
|
164
|
+
o.on("--source SOURCE", "Source identifier (default: claude_code)") { |v| opts[:source] = v }
|
|
165
|
+
o.on("--session-id ID", "Session identifier (required)") { |v| opts[:session_id] = v }
|
|
166
|
+
o.on("--transcript-path PATH", "Path to transcript file (required)") { |v| opts[:transcript_path] = v }
|
|
167
|
+
o.on("--db PATH", "Database path") { |v| opts[:db] = v }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
parser.parse!(@args[1..])
|
|
171
|
+
|
|
172
|
+
unless opts[:session_id] && opts[:transcript_path]
|
|
173
|
+
@stderr.puts parser.help
|
|
174
|
+
@stderr.puts "\nError: --session-id and --transcript-path are required"
|
|
175
|
+
return nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
opts
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def search
|
|
182
|
+
query = @args[1]
|
|
183
|
+
unless query
|
|
184
|
+
@stderr.puts "Usage: claude-memory search <query> [--db PATH] [--limit N]"
|
|
185
|
+
return 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
opts = {limit: 10, scope: "all"}
|
|
189
|
+
OptionParser.new do |o|
|
|
190
|
+
o.on("--limit N", Integer, "Max results") { |v| opts[:limit] = v }
|
|
191
|
+
o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
|
|
192
|
+
end.parse!(@args[2..])
|
|
193
|
+
|
|
194
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
195
|
+
store = manager.store_for_scope((opts[:scope] == "global") ? "global" : "project")
|
|
196
|
+
fts = ClaudeMemory::Index::LexicalFTS.new(store)
|
|
197
|
+
|
|
198
|
+
ids = fts.search(query, limit: opts[:limit])
|
|
199
|
+
if ids.empty?
|
|
200
|
+
@stdout.puts "No results found."
|
|
201
|
+
else
|
|
202
|
+
@stdout.puts "Found #{ids.size} result(s):"
|
|
203
|
+
ids.each do |id|
|
|
204
|
+
text = store.content_items.where(id: id).get(:raw_text)
|
|
205
|
+
preview = text&.slice(0, 100)&.gsub(/\s+/, " ")
|
|
206
|
+
@stdout.puts " [#{id}] #{preview}..."
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
manager.close
|
|
211
|
+
0
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def recall_cmd
|
|
215
|
+
query = @args[1]
|
|
216
|
+
unless query
|
|
217
|
+
@stderr.puts "Usage: claude-memory recall <query> [--limit N] [--scope project|global|all]"
|
|
218
|
+
return 1
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
opts = {limit: 10, scope: "all"}
|
|
222
|
+
OptionParser.new do |o|
|
|
223
|
+
o.on("--limit N", Integer, "Max results") { |v| opts[:limit] = v }
|
|
224
|
+
o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
|
|
225
|
+
end.parse!(@args[2..])
|
|
226
|
+
|
|
227
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
228
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
229
|
+
|
|
230
|
+
results = recall.query(query, limit: opts[:limit], scope: opts[:scope])
|
|
231
|
+
if results.empty?
|
|
232
|
+
@stdout.puts "No facts found."
|
|
233
|
+
else
|
|
234
|
+
@stdout.puts "Found #{results.size} fact(s):\n\n"
|
|
235
|
+
results.each do |result|
|
|
236
|
+
print_fact(result[:fact], source: result[:source])
|
|
237
|
+
print_receipts(result[:receipts])
|
|
238
|
+
@stdout.puts
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
manager.close
|
|
243
|
+
0
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def explain_cmd
|
|
247
|
+
fact_id = @args[1]&.to_i
|
|
248
|
+
unless fact_id && fact_id > 0
|
|
249
|
+
@stderr.puts "Usage: claude-memory explain <fact_id> [--scope project|global]"
|
|
250
|
+
return 1
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
opts = {scope: "project"}
|
|
254
|
+
OptionParser.new do |o|
|
|
255
|
+
o.on("--scope SCOPE", "Scope: project or global") { |v| opts[:scope] = v }
|
|
256
|
+
end.parse!(@args[2..])
|
|
257
|
+
|
|
258
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
259
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
260
|
+
|
|
261
|
+
explanation = recall.explain(fact_id, scope: opts[:scope])
|
|
262
|
+
if explanation.nil?
|
|
263
|
+
@stderr.puts "Fact #{fact_id} not found in #{opts[:scope]} database."
|
|
264
|
+
manager.close
|
|
265
|
+
return 1
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
@stdout.puts "Fact ##{fact_id} (#{opts[:scope]}):"
|
|
269
|
+
print_fact(explanation[:fact])
|
|
270
|
+
print_receipts(explanation[:receipts])
|
|
271
|
+
|
|
272
|
+
if explanation[:supersedes].any?
|
|
273
|
+
@stdout.puts " Supersedes: #{explanation[:supersedes].join(", ")}"
|
|
274
|
+
end
|
|
275
|
+
if explanation[:superseded_by].any?
|
|
276
|
+
@stdout.puts " Superseded by: #{explanation[:superseded_by].join(", ")}"
|
|
277
|
+
end
|
|
278
|
+
if explanation[:conflicts].any?
|
|
279
|
+
@stdout.puts " Conflicts: #{explanation[:conflicts].map { |c| c[:id] }.join(", ")}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
manager.close
|
|
283
|
+
0
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def conflicts_cmd
|
|
287
|
+
opts = {scope: "all"}
|
|
288
|
+
OptionParser.new do |o|
|
|
289
|
+
o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
|
|
290
|
+
end.parse!(@args[1..])
|
|
291
|
+
|
|
292
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
293
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
294
|
+
conflicts = recall.conflicts(scope: opts[:scope])
|
|
295
|
+
|
|
296
|
+
if conflicts.empty?
|
|
297
|
+
@stdout.puts "No open conflicts."
|
|
298
|
+
else
|
|
299
|
+
@stdout.puts "Open conflicts (#{conflicts.size}):\n\n"
|
|
300
|
+
conflicts.each do |c|
|
|
301
|
+
source_label = c[:source] ? " [#{c[:source]}]" : ""
|
|
302
|
+
@stdout.puts " Conflict ##{c[:id]}: Fact #{c[:fact_a_id]} vs Fact #{c[:fact_b_id]}#{source_label}"
|
|
303
|
+
@stdout.puts " Status: #{c[:status]}, Detected: #{c[:detected_at]}"
|
|
304
|
+
@stdout.puts " Notes: #{c[:notes]}" if c[:notes]
|
|
305
|
+
@stdout.puts
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
manager.close
|
|
310
|
+
0
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def changes_cmd
|
|
314
|
+
opts = {since: nil, limit: 20, scope: "all"}
|
|
315
|
+
OptionParser.new do |o|
|
|
316
|
+
o.on("--since ISO", "Since timestamp") { |v| opts[:since] = v }
|
|
317
|
+
o.on("--limit N", Integer, "Max results") { |v| opts[:limit] = v }
|
|
318
|
+
o.on("--scope SCOPE", "Scope: project, global, or all") { |v| opts[:scope] = v }
|
|
319
|
+
end.parse!(@args[1..])
|
|
320
|
+
|
|
321
|
+
opts[:since] ||= (Time.now - 86400 * 7).utc.iso8601
|
|
322
|
+
|
|
323
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
324
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
325
|
+
|
|
326
|
+
changes = recall.changes(since: opts[:since], limit: opts[:limit], scope: opts[:scope])
|
|
327
|
+
if changes.empty?
|
|
328
|
+
@stdout.puts "No changes since #{opts[:since]}."
|
|
329
|
+
else
|
|
330
|
+
@stdout.puts "Changes since #{opts[:since]} (#{changes.size}):\n\n"
|
|
331
|
+
changes.each do |change|
|
|
332
|
+
source_label = change[:source] ? " [#{change[:source]}]" : ""
|
|
333
|
+
@stdout.puts " [#{change[:id]}] #{change[:predicate]}: #{change[:object_literal]} (#{change[:status]})#{source_label}"
|
|
334
|
+
@stdout.puts " Created: #{change[:created_at]}"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
manager.close
|
|
339
|
+
0
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def print_fact(fact, source: nil)
|
|
343
|
+
source_label = source ? " [#{source}]" : ""
|
|
344
|
+
@stdout.puts " #{fact[:subject_name]}.#{fact[:predicate]} = #{fact[:object_literal]}#{source_label}"
|
|
345
|
+
@stdout.puts " Status: #{fact[:status]}, Confidence: #{fact[:confidence]}"
|
|
346
|
+
@stdout.puts " Valid: #{fact[:valid_from]} - #{fact[:valid_to] || "present"}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def print_receipts(receipts)
|
|
350
|
+
return if receipts.empty?
|
|
351
|
+
|
|
352
|
+
@stdout.puts " Receipts:"
|
|
353
|
+
receipts.each do |r|
|
|
354
|
+
quote_preview = r[:quote]&.slice(0, 80)&.gsub(/\s+/, " ")
|
|
355
|
+
@stdout.puts " - [#{r[:strength]}] \"#{quote_preview}...\""
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def sweep_cmd
|
|
360
|
+
opts = {budget: 5, scope: "project"}
|
|
361
|
+
OptionParser.new do |o|
|
|
362
|
+
o.on("--budget SECONDS", Integer, "Time budget in seconds") { |v| opts[:budget] = v }
|
|
363
|
+
o.on("--scope SCOPE", "Scope: project or global") { |v| opts[:scope] = v }
|
|
364
|
+
end.parse!(@args[1..])
|
|
365
|
+
|
|
366
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
367
|
+
store = manager.store_for_scope(opts[:scope])
|
|
368
|
+
sweeper = ClaudeMemory::Sweep::Sweeper.new(store)
|
|
369
|
+
|
|
370
|
+
@stdout.puts "Running sweep on #{opts[:scope]} database with #{opts[:budget]}s budget..."
|
|
371
|
+
stats = sweeper.run!(budget_seconds: opts[:budget])
|
|
372
|
+
|
|
373
|
+
@stdout.puts "Sweep complete:"
|
|
374
|
+
@stdout.puts " Proposed facts expired: #{stats[:proposed_facts_expired]}"
|
|
375
|
+
@stdout.puts " Disputed facts expired: #{stats[:disputed_facts_expired]}"
|
|
376
|
+
@stdout.puts " Orphaned provenance deleted: #{stats[:orphaned_provenance_deleted]}"
|
|
377
|
+
@stdout.puts " Old content pruned: #{stats[:old_content_pruned]}"
|
|
378
|
+
@stdout.puts " Elapsed: #{stats[:elapsed_seconds].round(2)}s"
|
|
379
|
+
@stdout.puts " Budget honored: #{stats[:budget_honored]}"
|
|
380
|
+
|
|
381
|
+
manager.close
|
|
382
|
+
0
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def serve_mcp
|
|
386
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
387
|
+
server = ClaudeMemory::MCP::Server.new(manager)
|
|
388
|
+
server.run
|
|
389
|
+
manager.close
|
|
390
|
+
0
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def publish_cmd
|
|
394
|
+
opts = {mode: :shared, granularity: :repo, since: nil, scope: "project"}
|
|
395
|
+
OptionParser.new do |o|
|
|
396
|
+
o.on("--mode MODE", "Mode: shared, local, or home") { |v| opts[:mode] = v.to_sym }
|
|
397
|
+
o.on("--granularity LEVEL", "Granularity: repo, paths, or nested") { |v| opts[:granularity] = v.to_sym }
|
|
398
|
+
o.on("--since ISO", "Include changes since timestamp") { |v| opts[:since] = v }
|
|
399
|
+
o.on("--scope SCOPE", "Scope: project or global") { |v| opts[:scope] = v }
|
|
400
|
+
end.parse!(@args[1..])
|
|
401
|
+
|
|
402
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
403
|
+
store = manager.store_for_scope(opts[:scope])
|
|
404
|
+
publish = ClaudeMemory::Publish.new(store)
|
|
405
|
+
|
|
406
|
+
result = publish.publish!(mode: opts[:mode], granularity: opts[:granularity], since: opts[:since])
|
|
407
|
+
|
|
408
|
+
case result[:status]
|
|
409
|
+
when :updated
|
|
410
|
+
@stdout.puts "Published #{opts[:scope]} snapshot to #{result[:path]}"
|
|
411
|
+
when :unchanged
|
|
412
|
+
@stdout.puts "No changes - #{result[:path]} is up to date"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
manager.close
|
|
416
|
+
0
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def promote_cmd
|
|
420
|
+
fact_id = @args[1]&.to_i
|
|
421
|
+
unless fact_id && fact_id > 0
|
|
422
|
+
@stderr.puts "Usage: claude-memory promote <fact_id>"
|
|
423
|
+
@stderr.puts "\nPromotes a project fact to the global database."
|
|
424
|
+
return 1
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
428
|
+
global_fact_id = manager.promote_fact(fact_id)
|
|
429
|
+
|
|
430
|
+
if global_fact_id
|
|
431
|
+
@stdout.puts "Promoted fact ##{fact_id} to global database as fact ##{global_fact_id}"
|
|
432
|
+
else
|
|
433
|
+
@stderr.puts "Fact ##{fact_id} not found in project database."
|
|
434
|
+
manager.close
|
|
435
|
+
return 1
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
manager.close
|
|
439
|
+
0
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def hook_cmd
|
|
443
|
+
subcommand = @args[1]
|
|
444
|
+
|
|
445
|
+
unless subcommand
|
|
446
|
+
@stderr.puts "Usage: claude-memory hook <ingest|sweep|publish> [options]"
|
|
447
|
+
@stderr.puts "\nReads hook payload JSON from stdin."
|
|
448
|
+
return 1
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
unless %w[ingest sweep publish].include?(subcommand)
|
|
452
|
+
@stderr.puts "Unknown hook command: #{subcommand}"
|
|
453
|
+
@stderr.puts "Available: ingest, sweep, publish"
|
|
454
|
+
return 1
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
opts = {db: ClaudeMemory.project_db_path}
|
|
458
|
+
OptionParser.new do |o|
|
|
459
|
+
o.on("--db PATH", "Database path") { |v| opts[:db] = v }
|
|
460
|
+
end.parse!(@args[2..])
|
|
461
|
+
|
|
462
|
+
payload = parse_hook_payload
|
|
463
|
+
return 1 unless payload
|
|
464
|
+
|
|
465
|
+
store = ClaudeMemory::Store::SQLiteStore.new(opts[:db])
|
|
466
|
+
handler = ClaudeMemory::Hook::Handler.new(store)
|
|
467
|
+
|
|
468
|
+
case subcommand
|
|
469
|
+
when "ingest"
|
|
470
|
+
hook_ingest(handler, payload)
|
|
471
|
+
when "sweep"
|
|
472
|
+
hook_sweep(handler, payload)
|
|
473
|
+
when "publish"
|
|
474
|
+
hook_publish(handler, payload)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
store.close
|
|
478
|
+
0
|
|
479
|
+
rescue ClaudeMemory::Hook::Handler::PayloadError => e
|
|
480
|
+
@stderr.puts "Payload error: #{e.message}"
|
|
481
|
+
1
|
|
482
|
+
rescue ClaudeMemory::Ingest::TranscriptReader::FileNotFoundError => e
|
|
483
|
+
@stderr.puts "Error: #{e.message}"
|
|
484
|
+
1
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def parse_hook_payload
|
|
488
|
+
input = @stdin.read
|
|
489
|
+
JSON.parse(input)
|
|
490
|
+
rescue JSON::ParserError => e
|
|
491
|
+
@stderr.puts "Invalid JSON payload: #{e.message}"
|
|
492
|
+
nil
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def hook_ingest(handler, payload)
|
|
496
|
+
result = handler.ingest(payload)
|
|
497
|
+
|
|
498
|
+
case result[:status]
|
|
499
|
+
when :ingested
|
|
500
|
+
@stdout.puts "Ingested #{result[:bytes_read]} bytes (content_id: #{result[:content_id]})"
|
|
501
|
+
when :no_change
|
|
502
|
+
@stdout.puts "No new content to ingest"
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def hook_sweep(handler, payload)
|
|
507
|
+
result = handler.sweep(payload)
|
|
508
|
+
stats = result[:stats]
|
|
509
|
+
|
|
510
|
+
@stdout.puts "Sweep complete:"
|
|
511
|
+
@stdout.puts " Elapsed: #{stats[:elapsed_seconds].round(2)}s"
|
|
512
|
+
@stdout.puts " Budget honored: #{stats[:budget_honored]}"
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def hook_publish(handler, payload)
|
|
516
|
+
result = handler.publish(payload)
|
|
517
|
+
|
|
518
|
+
case result[:status]
|
|
519
|
+
when :updated
|
|
520
|
+
@stdout.puts "Published snapshot to #{result[:path]}"
|
|
521
|
+
when :unchanged
|
|
522
|
+
@stdout.puts "No changes - #{result[:path]} is up to date"
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def init_project
|
|
527
|
+
opts = {global: false}
|
|
528
|
+
OptionParser.new do |o|
|
|
529
|
+
o.on("--global", "Install to global ~/.claude/ settings") { opts[:global] = true }
|
|
530
|
+
end.parse!(@args[1..])
|
|
531
|
+
|
|
532
|
+
if opts[:global]
|
|
533
|
+
init_global
|
|
534
|
+
else
|
|
535
|
+
init_local
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def init_local
|
|
540
|
+
@stdout.puts "Initializing ClaudeMemory (project-local)...\n\n"
|
|
541
|
+
|
|
542
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
543
|
+
manager.ensure_global!
|
|
544
|
+
@stdout.puts "✓ Global database: #{manager.global_db_path}"
|
|
545
|
+
manager.ensure_project!
|
|
546
|
+
@stdout.puts "✓ Project database: #{manager.project_db_path}"
|
|
547
|
+
manager.close
|
|
548
|
+
|
|
549
|
+
FileUtils.mkdir_p(".claude/rules")
|
|
550
|
+
@stdout.puts "✓ Created .claude/rules directory"
|
|
551
|
+
|
|
552
|
+
configure_project_hooks
|
|
553
|
+
configure_project_mcp
|
|
554
|
+
install_output_style
|
|
555
|
+
|
|
556
|
+
@stdout.puts "\n=== Setup Complete ===\n"
|
|
557
|
+
@stdout.puts "ClaudeMemory is now configured for this project."
|
|
558
|
+
@stdout.puts "\nDatabases:"
|
|
559
|
+
@stdout.puts " Global: ~/.claude/memory.sqlite3 (user-wide knowledge)"
|
|
560
|
+
@stdout.puts " Project: .claude/memory.sqlite3 (project-specific)"
|
|
561
|
+
@stdout.puts "\nNext steps:"
|
|
562
|
+
@stdout.puts " 1. Restart Claude Code to load the new configuration"
|
|
563
|
+
@stdout.puts " 2. Use Claude Code normally - transcripts will be ingested automatically"
|
|
564
|
+
@stdout.puts " 3. Run 'claude-memory promote <fact_id>' to move facts to global"
|
|
565
|
+
@stdout.puts " 4. Run 'claude-memory doctor' to verify setup"
|
|
566
|
+
|
|
567
|
+
0
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def init_global
|
|
571
|
+
@stdout.puts "Initializing ClaudeMemory (global only)...\n\n"
|
|
572
|
+
|
|
573
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
574
|
+
manager.ensure_global!
|
|
575
|
+
@stdout.puts "✓ Created global database: #{manager.global_db_path}"
|
|
576
|
+
manager.close
|
|
577
|
+
|
|
578
|
+
configure_global_hooks
|
|
579
|
+
configure_global_mcp
|
|
580
|
+
configure_global_memory
|
|
581
|
+
|
|
582
|
+
@stdout.puts "\n=== Global Setup Complete ===\n"
|
|
583
|
+
@stdout.puts "ClaudeMemory is now configured globally."
|
|
584
|
+
@stdout.puts "\nNote: Run 'claude-memory init' in each project for project-specific memory."
|
|
585
|
+
|
|
586
|
+
0
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def configure_global_hooks
|
|
590
|
+
settings_path = File.join(Dir.home, ".claude", "settings.json")
|
|
591
|
+
db_path = ClaudeMemory.global_db_path
|
|
592
|
+
|
|
593
|
+
ingest_cmd = "claude-memory hook ingest --db #{db_path}"
|
|
594
|
+
sweep_cmd = "claude-memory hook sweep --db #{db_path}"
|
|
595
|
+
|
|
596
|
+
hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
|
|
597
|
+
|
|
598
|
+
existing = load_json_file(settings_path)
|
|
599
|
+
existing["hooks"] ||= {}
|
|
600
|
+
existing["hooks"].merge!(hooks_config["hooks"])
|
|
601
|
+
|
|
602
|
+
File.write(settings_path, JSON.pretty_generate(existing))
|
|
603
|
+
@stdout.puts "✓ Configured hooks in #{settings_path}"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def configure_global_mcp
|
|
607
|
+
mcp_path = File.join(Dir.home, ".claude.json")
|
|
608
|
+
|
|
609
|
+
existing = load_json_file(mcp_path)
|
|
610
|
+
existing["mcpServers"] ||= {}
|
|
611
|
+
existing["mcpServers"]["claude-memory"] = {
|
|
612
|
+
"type" => "stdio",
|
|
613
|
+
"command" => "claude-memory",
|
|
614
|
+
"args" => ["serve-mcp"]
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
File.write(mcp_path, JSON.pretty_generate(existing))
|
|
618
|
+
@stdout.puts "✓ Configured MCP server in #{mcp_path}"
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def configure_global_memory
|
|
622
|
+
global_claude_dir = File.join(Dir.home, ".claude")
|
|
623
|
+
claude_md_path = File.join(global_claude_dir, "CLAUDE.md")
|
|
624
|
+
|
|
625
|
+
memory_instruction = <<~MD
|
|
626
|
+
# ClaudeMemory
|
|
627
|
+
|
|
628
|
+
ClaudeMemory is installed globally. Use these MCP tools:
|
|
629
|
+
- `memory.recall` - Search for relevant facts
|
|
630
|
+
- `memory.explain` - Get detailed fact provenance
|
|
631
|
+
- `memory.conflicts` - Show open contradictions
|
|
632
|
+
- `memory.status` - Check system health
|
|
633
|
+
MD
|
|
634
|
+
|
|
635
|
+
if File.exist?(claude_md_path)
|
|
636
|
+
content = File.read(claude_md_path)
|
|
637
|
+
if content.include?("ClaudeMemory")
|
|
638
|
+
@stdout.puts "✓ #{claude_md_path} already has ClaudeMemory instructions"
|
|
639
|
+
else
|
|
640
|
+
File.write(claude_md_path, content + "\n" + memory_instruction)
|
|
641
|
+
@stdout.puts "✓ Added ClaudeMemory instructions to #{claude_md_path}"
|
|
642
|
+
end
|
|
643
|
+
else
|
|
644
|
+
File.write(claude_md_path, memory_instruction)
|
|
645
|
+
@stdout.puts "✓ Created #{claude_md_path}"
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def configure_project_hooks
|
|
650
|
+
hooks_path = ".claude/settings.json"
|
|
651
|
+
db_path = File.expand_path(ClaudeMemory.project_db_path)
|
|
652
|
+
|
|
653
|
+
ingest_cmd = "claude-memory hook ingest --db #{db_path}"
|
|
654
|
+
sweep_cmd = "claude-memory hook sweep --db #{db_path}"
|
|
655
|
+
|
|
656
|
+
hooks_config = build_hooks_config(ingest_cmd, sweep_cmd)
|
|
657
|
+
|
|
658
|
+
existing = load_json_file(hooks_path)
|
|
659
|
+
existing["hooks"] ||= {}
|
|
660
|
+
existing["hooks"].merge!(hooks_config["hooks"])
|
|
661
|
+
|
|
662
|
+
FileUtils.mkdir_p(".claude")
|
|
663
|
+
File.write(hooks_path, JSON.pretty_generate(existing))
|
|
664
|
+
@stdout.puts "✓ Configured hooks in #{hooks_path}"
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def configure_project_mcp
|
|
668
|
+
mcp_path = ".mcp.json"
|
|
669
|
+
|
|
670
|
+
existing = load_json_file(mcp_path)
|
|
671
|
+
existing["mcpServers"] ||= {}
|
|
672
|
+
existing["mcpServers"]["claude-memory"] = {
|
|
673
|
+
"type" => "stdio",
|
|
674
|
+
"command" => "claude-memory",
|
|
675
|
+
"args" => ["serve-mcp"]
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
File.write(mcp_path, JSON.pretty_generate(existing))
|
|
679
|
+
@stdout.puts "✓ Configured MCP server in #{mcp_path}"
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def install_output_style
|
|
683
|
+
templates_dir = File.expand_path("templates", __dir__)
|
|
684
|
+
style_source = File.join(templates_dir, "output-styles", "memory-aware.md")
|
|
685
|
+
style_dest = ".claude/output-styles/memory-aware.md"
|
|
686
|
+
|
|
687
|
+
FileUtils.mkdir_p(File.dirname(style_dest))
|
|
688
|
+
FileUtils.cp(style_source, style_dest)
|
|
689
|
+
@stdout.puts "✓ Installed output style at #{style_dest}"
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def doctor_cmd
|
|
693
|
+
issues = []
|
|
694
|
+
warnings = []
|
|
695
|
+
|
|
696
|
+
@stdout.puts "Claude Memory Doctor\n"
|
|
697
|
+
@stdout.puts "=" * 40
|
|
698
|
+
|
|
699
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
700
|
+
|
|
701
|
+
@stdout.puts "\n## Global Database"
|
|
702
|
+
check_database(manager.global_db_path, "global", issues, warnings)
|
|
703
|
+
|
|
704
|
+
@stdout.puts "\n## Project Database"
|
|
705
|
+
check_database(manager.project_db_path, "project", issues, warnings)
|
|
706
|
+
|
|
707
|
+
manager.close
|
|
708
|
+
|
|
709
|
+
if File.exist?(".claude/rules/claude_memory.generated.md")
|
|
710
|
+
@stdout.puts "✓ Published snapshot exists"
|
|
711
|
+
else
|
|
712
|
+
warnings << "No published snapshot found. Run 'claude-memory publish'"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
if File.exist?(".claude/CLAUDE.md")
|
|
716
|
+
content = File.read(".claude/CLAUDE.md")
|
|
717
|
+
if content.include?("claude_memory.generated.md")
|
|
718
|
+
@stdout.puts "✓ CLAUDE.md imports snapshot"
|
|
719
|
+
else
|
|
720
|
+
warnings << "CLAUDE.md does not import snapshot"
|
|
721
|
+
end
|
|
722
|
+
else
|
|
723
|
+
warnings << "No .claude/CLAUDE.md found"
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
check_hooks_config(warnings)
|
|
727
|
+
|
|
728
|
+
@stdout.puts
|
|
729
|
+
|
|
730
|
+
if warnings.any?
|
|
731
|
+
@stdout.puts "Warnings:"
|
|
732
|
+
warnings.each { |w| @stdout.puts " ⚠ #{w}" }
|
|
733
|
+
@stdout.puts
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
if issues.any?
|
|
737
|
+
@stdout.puts "Issues:"
|
|
738
|
+
issues.each { |i| @stderr.puts " ✗ #{i}" }
|
|
739
|
+
@stdout.puts
|
|
740
|
+
@stdout.puts "Run 'claude-memory init' to set up."
|
|
741
|
+
return 1
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
@stdout.puts "All checks passed!"
|
|
745
|
+
0
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def check_database(db_path, label, issues, warnings)
|
|
749
|
+
if File.exist?(db_path)
|
|
750
|
+
@stdout.puts "✓ #{label.capitalize} database exists: #{db_path}"
|
|
751
|
+
begin
|
|
752
|
+
store = ClaudeMemory::Store::SQLiteStore.new(db_path)
|
|
753
|
+
@stdout.puts " Schema version: #{store.schema_version}"
|
|
754
|
+
|
|
755
|
+
fact_count = store.db.execute("SELECT COUNT(*) FROM facts").first.first
|
|
756
|
+
@stdout.puts " Facts: #{fact_count}"
|
|
757
|
+
|
|
758
|
+
content_count = store.db.execute("SELECT COUNT(*) FROM content_items").first.first
|
|
759
|
+
@stdout.puts " Content items: #{content_count}"
|
|
760
|
+
|
|
761
|
+
conflict_count = store.db.execute("SELECT COUNT(*) FROM conflicts WHERE status = 'open'").first.first
|
|
762
|
+
if conflict_count > 0
|
|
763
|
+
warnings << "#{label}: #{conflict_count} open conflict(s) need resolution"
|
|
764
|
+
end
|
|
765
|
+
@stdout.puts " Open conflicts: #{conflict_count}"
|
|
766
|
+
|
|
767
|
+
last_ingest = store.db.execute("SELECT MAX(ingested_at) FROM content_items").first.first
|
|
768
|
+
if last_ingest
|
|
769
|
+
@stdout.puts " Last ingest: #{last_ingest}"
|
|
770
|
+
elsif label == "project"
|
|
771
|
+
warnings << "#{label}: No content has been ingested yet"
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
store.close
|
|
775
|
+
rescue => e
|
|
776
|
+
issues << "#{label} database error: #{e.message}"
|
|
777
|
+
end
|
|
778
|
+
elsif label == "global"
|
|
779
|
+
issues << "Global database not found: #{db_path}"
|
|
780
|
+
else
|
|
781
|
+
warnings << "Project database not found: #{db_path} (run 'claude-memory init')"
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def check_hooks_config(warnings)
|
|
786
|
+
settings_path = ".claude/settings.json"
|
|
787
|
+
local_settings_path = ".claude/settings.local.json"
|
|
788
|
+
|
|
789
|
+
hooks_found = false
|
|
790
|
+
|
|
791
|
+
[settings_path, local_settings_path].each do |path|
|
|
792
|
+
next unless File.exist?(path)
|
|
793
|
+
|
|
794
|
+
begin
|
|
795
|
+
config = JSON.parse(File.read(path))
|
|
796
|
+
if config["hooks"]&.any?
|
|
797
|
+
hooks_found = true
|
|
798
|
+
@stdout.puts "✓ Hooks configured in #{path}"
|
|
799
|
+
|
|
800
|
+
expected_hooks = %w[Stop SessionStart PreCompact SessionEnd]
|
|
801
|
+
missing = expected_hooks - config["hooks"].keys
|
|
802
|
+
if missing.any?
|
|
803
|
+
warnings << "Missing recommended hooks in #{path}: #{missing.join(", ")}"
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
rescue JSON::ParserError
|
|
807
|
+
warnings << "Invalid JSON in #{path}"
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
unless hooks_found
|
|
812
|
+
warnings << "No hooks configured. Run 'claude-memory init' or configure manually."
|
|
813
|
+
@stdout.puts "\n Manual fallback available:"
|
|
814
|
+
@stdout.puts " claude-memory ingest --session-id <id> --transcript-path <path>"
|
|
815
|
+
@stdout.puts " claude-memory sweep --budget 5"
|
|
816
|
+
@stdout.puts " claude-memory publish"
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def load_json_file(path)
|
|
821
|
+
return {} unless File.exist?(path)
|
|
822
|
+
|
|
823
|
+
JSON.parse(File.read(path))
|
|
824
|
+
rescue JSON::ParserError
|
|
825
|
+
{}
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def build_hooks_config(ingest_cmd, sweep_cmd)
|
|
829
|
+
{
|
|
830
|
+
"hooks" => {
|
|
831
|
+
"Stop" => [
|
|
832
|
+
{
|
|
833
|
+
"matcher" => "",
|
|
834
|
+
"hooks" => [
|
|
835
|
+
{"type" => "command", "command" => ingest_cmd, "timeout" => 10}
|
|
836
|
+
]
|
|
837
|
+
}
|
|
838
|
+
],
|
|
839
|
+
"SessionStart" => [
|
|
840
|
+
{
|
|
841
|
+
"matcher" => "",
|
|
842
|
+
"hooks" => [
|
|
843
|
+
{"type" => "command", "command" => ingest_cmd, "timeout" => 10}
|
|
844
|
+
]
|
|
845
|
+
}
|
|
846
|
+
],
|
|
847
|
+
"PreCompact" => [
|
|
848
|
+
{
|
|
849
|
+
"matcher" => "",
|
|
850
|
+
"hooks" => [
|
|
851
|
+
{"type" => "command", "command" => ingest_cmd, "timeout" => 30},
|
|
852
|
+
{"type" => "command", "command" => sweep_cmd, "timeout" => 30}
|
|
853
|
+
]
|
|
854
|
+
}
|
|
855
|
+
],
|
|
856
|
+
"SessionEnd" => [
|
|
857
|
+
{
|
|
858
|
+
"matcher" => "",
|
|
859
|
+
"hooks" => [
|
|
860
|
+
{"type" => "command", "command" => ingest_cmd, "timeout" => 30},
|
|
861
|
+
{"type" => "command", "command" => sweep_cmd, "timeout" => 30}
|
|
862
|
+
]
|
|
863
|
+
}
|
|
864
|
+
]
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
end
|