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,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
|