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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +3 -0
  3. data/.claude/memory.sqlite3 +0 -0
  4. data/.claude/output-styles/memory-aware.md +21 -0
  5. data/.claude/rules/claude_memory.generated.md +21 -0
  6. data/.claude/settings.json +62 -0
  7. data/.claude/settings.local.json +21 -0
  8. data/.claude-plugin/marketplace.json +13 -0
  9. data/.claude-plugin/plugin.json +10 -0
  10. data/.mcp.json +11 -0
  11. data/CHANGELOG.md +36 -0
  12. data/CLAUDE.md +224 -0
  13. data/CODE_OF_CONDUCT.md +10 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +212 -0
  16. data/Rakefile +10 -0
  17. data/commands/analyze.md +29 -0
  18. data/commands/recall.md +17 -0
  19. data/commands/remember.md +26 -0
  20. data/docs/demo.md +126 -0
  21. data/docs/organizational_memory_playbook.md +291 -0
  22. data/docs/plan.md +411 -0
  23. data/docs/plugin.md +202 -0
  24. data/docs/updated_plan.md +453 -0
  25. data/exe/claude-memory +8 -0
  26. data/hooks/hooks.json +59 -0
  27. data/lib/claude_memory/cli.rb +869 -0
  28. data/lib/claude_memory/distill/distiller.rb +11 -0
  29. data/lib/claude_memory/distill/extraction.rb +29 -0
  30. data/lib/claude_memory/distill/json_schema.md +78 -0
  31. data/lib/claude_memory/distill/null_distiller.rb +123 -0
  32. data/lib/claude_memory/hook/handler.rb +49 -0
  33. data/lib/claude_memory/index/lexical_fts.rb +58 -0
  34. data/lib/claude_memory/ingest/ingester.rb +46 -0
  35. data/lib/claude_memory/ingest/transcript_reader.rb +21 -0
  36. data/lib/claude_memory/mcp/server.rb +127 -0
  37. data/lib/claude_memory/mcp/tools.rb +409 -0
  38. data/lib/claude_memory/publish.rb +201 -0
  39. data/lib/claude_memory/recall.rb +360 -0
  40. data/lib/claude_memory/resolve/predicate_policy.rb +30 -0
  41. data/lib/claude_memory/resolve/resolver.rb +152 -0
  42. data/lib/claude_memory/store/sqlite_store.rb +340 -0
  43. data/lib/claude_memory/store/store_manager.rb +139 -0
  44. data/lib/claude_memory/sweep/sweeper.rb +80 -0
  45. data/lib/claude_memory/templates/hooks.example.json +74 -0
  46. data/lib/claude_memory/templates/output-styles/memory-aware.md +21 -0
  47. data/lib/claude_memory/version.rb +5 -0
  48. data/lib/claude_memory.rb +36 -0
  49. data/sig/claude_memory.rbs +4 -0
  50. data/skills/analyze/SKILL.md +126 -0
  51. data/skills/memory/SKILL.md +82 -0
  52. metadata +123 -0
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Distill
5
+ class Distiller
6
+ def distill(text, content_item_id: nil)
7
+ raise NotImplementedError, "Subclasses must implement #distill"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Distill
5
+ class Extraction
6
+ attr_reader :entities, :facts, :decisions, :signals
7
+
8
+ def initialize(entities: [], facts: [], decisions: [], signals: [])
9
+ @entities = entities
10
+ @facts = facts
11
+ @decisions = decisions
12
+ @signals = signals
13
+ end
14
+
15
+ def empty?
16
+ entities.empty? && facts.empty? && decisions.empty? && signals.empty?
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ entities: entities,
22
+ facts: facts,
23
+ decisions: decisions,
24
+ signals: signals
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,78 @@
1
+ # Extraction Schema v1
2
+
3
+ This document defines the schema for extracted knowledge from transcripts.
4
+
5
+ ## Extraction Object
6
+
7
+ ```json
8
+ {
9
+ "entities": [...],
10
+ "facts": [...],
11
+ "decisions": [...],
12
+ "signals": [...]
13
+ }
14
+ ```
15
+
16
+ ## Entity
17
+
18
+ ```json
19
+ {
20
+ "type": "string", // e.g., "database", "framework", "language", "platform", "repo", "module", "person", "service"
21
+ "name": "string", // canonical name
22
+ "aliases": ["string"], // optional: alternative names
23
+ "confidence": 0.0-1.0 // optional: extraction confidence
24
+ }
25
+ ```
26
+
27
+ ## Fact
28
+
29
+ ```json
30
+ {
31
+ "subject": "string", // entity name or "repo" for project-level
32
+ "predicate": "string", // e.g., "uses_database", "convention", "auth_method"
33
+ "object": "string", // entity name or literal value
34
+ "polarity": "positive|negative", // default: "positive"
35
+ "confidence": 0.0-1.0, // extraction confidence
36
+ "quote": "string", // source text excerpt
37
+ "strength": "stated|inferred", // how strongly evidenced
38
+ "time_hint": "string", // optional: ISO timestamp hint
39
+ "decision_ref": "integer" // optional: index into decisions array
40
+ }
41
+ ```
42
+
43
+ ## Decision
44
+
45
+ ```json
46
+ {
47
+ "title": "string", // short summary (max 100 chars)
48
+ "summary": "string", // full description
49
+ "status_hint": "string", // "accepted", "proposed", "rejected"
50
+ "emits_fact_indexes": [0, 1] // optional: indices of facts this decision creates
51
+ }
52
+ ```
53
+
54
+ ## Signal
55
+
56
+ ```json
57
+ {
58
+ "kind": "string", // "supersession", "conflict", "time_boundary"
59
+ "value": "any" // signal-specific value
60
+ }
61
+ ```
62
+
63
+ ### Signal Kinds
64
+
65
+ - **supersession**: `{kind: "supersession", value: true}` - indicates old knowledge may be replaced
66
+ - **conflict**: `{kind: "conflict", value: true}` - indicates contradictory information detected
67
+ - **time_boundary**: `{kind: "time_boundary", value: "2024-01-15"}` - temporal boundary marker
68
+
69
+ ## Predicate Types (MVP)
70
+
71
+ | Predicate | Cardinality | Exclusive |
72
+ |-----------|-------------|-----------|
73
+ | convention | multi | no |
74
+ | decision | multi (by scope) | no |
75
+ | auth_method | single | yes |
76
+ | uses_database | single | yes |
77
+ | uses_framework | single | yes |
78
+ | deployment_platform | single | yes |
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Distill
5
+ class NullDistiller < Distiller
6
+ DECISION_PATTERNS = [
7
+ /\b(?:we\s+)?decided\s+to\s+(.+)/i,
8
+ /\b(?:we\s+)?agreed\s+(?:to\s+|on\s+)(.+)/i,
9
+ /\blet'?s\s+(?:go\s+with|use)\s+(.+)/i,
10
+ /\bgoing\s+(?:forward|ahead)\s+with\s+(.+)/i
11
+ ].freeze
12
+
13
+ CONVENTION_PATTERNS = [
14
+ /\balways\s+(.+)/i,
15
+ /\bnever\s+(.+)/i,
16
+ /\bconvention[:\s]+(.+)/i,
17
+ /\bstandard[:\s]+(.+)/i,
18
+ /\bwe\s+use\s+(.+)/i
19
+ ].freeze
20
+
21
+ ENTITY_PATTERNS = {
22
+ "database" => /\b(postgresql|postgres|mysql|sqlite|mongodb|redis)\b/i,
23
+ "framework" => /\b(rails|sinatra|django|express|next\.?js|react|vue)\b/i,
24
+ "language" => /\b(ruby|python|javascript|typescript|go|rust)\b/i,
25
+ "platform" => /\b(aws|gcp|azure|heroku|vercel|netlify|docker|kubernetes)\b/i
26
+ }.freeze
27
+
28
+ GLOBAL_SCOPE_PATTERNS = [
29
+ /\bi\s+always\b/i,
30
+ /\bin\s+all\s+(?:my\s+)?projects\b/i,
31
+ /\beverywhere\b/i,
32
+ /\bacross\s+all\s+(?:my\s+)?(?:projects|repos|codebases)\b/i,
33
+ /\bmy\s+(?:personal\s+)?(?:preference|convention|standard)\b/i,
34
+ /\bglobally\b/i,
35
+ /\buniversally\b/i
36
+ ].freeze
37
+
38
+ def distill(text, content_item_id: nil)
39
+ entities = extract_entities(text)
40
+ facts = extract_facts(text, entities)
41
+ decisions = extract_decisions(text)
42
+ signals = extract_signals(text)
43
+
44
+ Extraction.new(
45
+ entities: entities,
46
+ facts: facts,
47
+ decisions: decisions,
48
+ signals: signals
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def extract_entities(text)
55
+ found = []
56
+ ENTITY_PATTERNS.each do |type, pattern|
57
+ text.scan(pattern).flatten.uniq.each do |name|
58
+ found << {type: type, name: name.downcase, confidence: 0.7}
59
+ end
60
+ end
61
+ found.uniq { |e| [e[:type], e[:name]] }
62
+ end
63
+
64
+ def extract_facts(text, entities)
65
+ facts = []
66
+ scope_hint = global_scope_signal?(text) ? "global" : "project"
67
+
68
+ entities.each do |entity|
69
+ case entity[:type]
70
+ when "database"
71
+ facts << build_fact("uses_database", entity[:name], text, scope_hint)
72
+ when "framework"
73
+ facts << build_fact("uses_framework", entity[:name], text, scope_hint)
74
+ when "platform"
75
+ facts << build_fact("deployment_platform", entity[:name], text, scope_hint)
76
+ end
77
+ end
78
+
79
+ facts
80
+ end
81
+
82
+ def extract_decisions(text)
83
+ decisions = []
84
+ DECISION_PATTERNS.each do |pattern|
85
+ text.scan(pattern).flatten.each do |match|
86
+ decisions << {
87
+ title: match.strip.slice(0, 100),
88
+ summary: match.strip,
89
+ status_hint: "accepted"
90
+ }
91
+ end
92
+ end
93
+ decisions.first(5)
94
+ end
95
+
96
+ def extract_signals(text)
97
+ signals = []
98
+ signals << {kind: "supersession", value: true} if text.match?(/\b(no longer|stopped using|switched from|replaced|deprecated)\b/i)
99
+ signals << {kind: "conflict", value: true} if text.match?(/\b(disagree|conflict|contradiction|but.*said|however.*different)\b/i)
100
+ signals << {kind: "global_scope", value: true} if global_scope_signal?(text)
101
+ signals
102
+ end
103
+
104
+ def global_scope_signal?(text)
105
+ GLOBAL_SCOPE_PATTERNS.any? { |pattern| text.match?(pattern) }
106
+ end
107
+
108
+ def build_fact(predicate, object, text, scope_hint = "project")
109
+ quote = text.slice(0, 200)
110
+ {
111
+ subject: "repo",
112
+ predicate: predicate,
113
+ object: object,
114
+ polarity: "positive",
115
+ confidence: 0.7,
116
+ quote: quote,
117
+ strength: "inferred",
118
+ scope_hint: scope_hint
119
+ }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Hook
5
+ class Handler
6
+ class PayloadError < Error; end
7
+
8
+ DEFAULT_SWEEP_BUDGET = 5
9
+
10
+ def initialize(store, env: ENV)
11
+ @store = store
12
+ @env = env
13
+ end
14
+
15
+ def ingest(payload)
16
+ session_id = payload["session_id"] || @env["CLAUDE_SESSION_ID"]
17
+ transcript_path = payload["transcript_path"] || @env["CLAUDE_TRANSCRIPT_PATH"]
18
+ project_path = payload["project_path"] || @env["CLAUDE_PROJECT_DIR"] || Dir.pwd
19
+
20
+ raise PayloadError, "Missing required field: session_id" if session_id.nil? || session_id.empty?
21
+ raise PayloadError, "Missing required field: transcript_path" if transcript_path.nil? || transcript_path.empty?
22
+
23
+ ingester = Ingest::Ingester.new(@store, env: @env)
24
+ ingester.ingest(
25
+ source: "claude_code",
26
+ session_id: session_id,
27
+ transcript_path: transcript_path,
28
+ project_path: project_path
29
+ )
30
+ end
31
+
32
+ def sweep(payload)
33
+ budget = payload.fetch("budget", DEFAULT_SWEEP_BUDGET).to_i
34
+ sweeper = Sweep::Sweeper.new(@store)
35
+ stats = sweeper.run!(budget_seconds: budget)
36
+
37
+ {stats: stats}
38
+ end
39
+
40
+ def publish(payload)
41
+ mode = payload.fetch("mode", "shared").to_sym
42
+ since = payload["since"]
43
+
44
+ publisher = Publish.new(@store)
45
+ publisher.publish!(mode: mode, since: since)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Index
5
+ class LexicalFTS
6
+ def initialize(store)
7
+ @store = store
8
+ @db = store.db
9
+ ensure_fts_table!
10
+ end
11
+
12
+ def index_content_item(content_item_id, text)
13
+ existing = @db[:content_fts].where(content_item_id: content_item_id).get(:content_item_id)
14
+ return if existing
15
+
16
+ @db[:content_fts].insert(content_item_id: content_item_id, text: text)
17
+ end
18
+
19
+ def search(query, limit: 20)
20
+ return [] if query.nil? || query.strip.empty?
21
+
22
+ if query.strip == "*"
23
+ return @db[:content_items]
24
+ .order(Sequel.desc(:id))
25
+ .limit(limit)
26
+ .select_map(:id)
27
+ end
28
+
29
+ escaped_query = escape_fts_query(query)
30
+ @db[:content_fts]
31
+ .where(Sequel.lit("text MATCH ?", escaped_query))
32
+ .order(:rank)
33
+ .limit(limit)
34
+ .select_map(:content_item_id)
35
+ end
36
+
37
+ def escape_fts_query(query)
38
+ words = query.split(/\s+/).map do |word|
39
+ next word if word == "*"
40
+ escaped = word.gsub('"', '""')
41
+ %("#{escaped}")
42
+ end.compact
43
+
44
+ return words.first if words.size == 1
45
+ words.join(" OR ")
46
+ end
47
+
48
+ private
49
+
50
+ def ensure_fts_table!
51
+ @db.run(<<~SQL)
52
+ CREATE VIRTUAL TABLE IF NOT EXISTS content_fts
53
+ USING fts5(content_item_id UNINDEXED, text, tokenize='porter unicode61')
54
+ SQL
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ClaudeMemory
6
+ module Ingest
7
+ class Ingester
8
+ def initialize(store, fts: nil, env: ENV)
9
+ @store = store
10
+ @fts = fts || Index::LexicalFTS.new(store)
11
+ @env = env
12
+ end
13
+
14
+ def ingest(source:, session_id:, transcript_path:, project_path: nil)
15
+ current_offset = @store.get_delta_cursor(session_id, transcript_path) || 0
16
+ delta, new_offset = TranscriptReader.read_delta(transcript_path, current_offset)
17
+
18
+ return {status: :no_change, bytes_read: 0} if delta.nil?
19
+
20
+ resolved_project = project_path || detect_project_path
21
+
22
+ text_hash = Digest::SHA256.hexdigest(delta)
23
+ content_id = @store.upsert_content_item(
24
+ source: source,
25
+ session_id: session_id,
26
+ transcript_path: transcript_path,
27
+ project_path: resolved_project,
28
+ text_hash: text_hash,
29
+ byte_len: delta.bytesize,
30
+ raw_text: delta
31
+ )
32
+
33
+ @fts.index_content_item(content_id, delta)
34
+ @store.update_delta_cursor(session_id, transcript_path, new_offset)
35
+
36
+ {status: :ingested, content_id: content_id, bytes_read: delta.bytesize, project_path: resolved_project}
37
+ end
38
+
39
+ private
40
+
41
+ def detect_project_path
42
+ @env["CLAUDE_PROJECT_DIR"] || Dir.pwd
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Ingest
5
+ class TranscriptReader
6
+ class FileNotFoundError < ClaudeMemory::Error; end
7
+
8
+ def self.read_delta(path, from_offset)
9
+ raise FileNotFoundError, "File not found: #{path}" unless File.exist?(path)
10
+
11
+ file_size = File.size(path)
12
+ effective_offset = (from_offset > file_size) ? 0 : from_offset
13
+
14
+ return [nil, effective_offset] if file_size == effective_offset
15
+
16
+ content = File.read(path, nil, effective_offset)
17
+ [content, file_size]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeMemory
6
+ module MCP
7
+ class Server
8
+ PROTOCOL_VERSION = "2024-11-05"
9
+
10
+ def initialize(store_or_manager, input: $stdin, output: $stdout)
11
+ @store_or_manager = store_or_manager
12
+ @tools = Tools.new(store_or_manager)
13
+ @input = input
14
+ @output = output
15
+ @running = false
16
+ end
17
+
18
+ def run
19
+ @running = true
20
+ while @running
21
+ line = @input.gets
22
+ break unless line
23
+
24
+ handle_message(line.strip)
25
+ end
26
+ end
27
+
28
+ def stop
29
+ @running = false
30
+ end
31
+
32
+ private
33
+
34
+ def handle_message(line)
35
+ return if line.empty?
36
+
37
+ request = nil
38
+ begin
39
+ request = JSON.parse(line)
40
+ response = process_request(request)
41
+ send_response(response) if response
42
+ rescue JSON::ParserError => e
43
+ send_error(-32700, "Parse error: #{e.message}", 0)
44
+ rescue => e
45
+ request_id = request&.fetch("id", nil) || 0
46
+ send_error(-32603, "Internal error: #{e.message}", request_id)
47
+ end
48
+ end
49
+
50
+ def process_request(request)
51
+ id = request["id"]
52
+ method = request["method"]
53
+
54
+ case method
55
+ when "initialize"
56
+ handle_initialize(id, request["params"])
57
+ when "tools/list"
58
+ handle_tools_list(id)
59
+ when "tools/call"
60
+ handle_tools_call(id, request["params"])
61
+ when "shutdown"
62
+ @running = false
63
+ {jsonrpc: "2.0", id: id, result: nil}
64
+ else
65
+ {jsonrpc: "2.0", id: id, error: {code: -32601, message: "Method not found: #{method}"}}
66
+ end
67
+ end
68
+
69
+ def handle_initialize(id, _params)
70
+ {
71
+ jsonrpc: "2.0",
72
+ id: id,
73
+ result: {
74
+ protocolVersion: PROTOCOL_VERSION,
75
+ capabilities: {
76
+ tools: {}
77
+ },
78
+ serverInfo: {
79
+ name: "claude-memory",
80
+ version: ClaudeMemory::VERSION
81
+ }
82
+ }
83
+ }
84
+ end
85
+
86
+ def handle_tools_list(id)
87
+ {
88
+ jsonrpc: "2.0",
89
+ id: id,
90
+ result: {
91
+ tools: @tools.definitions
92
+ }
93
+ }
94
+ end
95
+
96
+ def handle_tools_call(id, params)
97
+ name = params["name"]
98
+ arguments = params["arguments"] || {}
99
+
100
+ result = @tools.call(name, arguments)
101
+
102
+ {
103
+ jsonrpc: "2.0",
104
+ id: id,
105
+ result: {
106
+ content: [
107
+ {type: "text", text: JSON.generate(result)}
108
+ ]
109
+ }
110
+ }
111
+ end
112
+
113
+ def send_response(response)
114
+ @output.puts(JSON.generate(response))
115
+ @output.flush
116
+ end
117
+
118
+ def send_error(code, message, id)
119
+ send_response({
120
+ jsonrpc: "2.0",
121
+ id: id,
122
+ error: {code: code, message: message}
123
+ })
124
+ end
125
+ end
126
+ end
127
+ end