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,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ClaudeMemory
|
|
7
|
+
module Store
|
|
8
|
+
class SQLiteStore
|
|
9
|
+
SCHEMA_VERSION = 2
|
|
10
|
+
|
|
11
|
+
attr_reader :db
|
|
12
|
+
|
|
13
|
+
def initialize(db_path)
|
|
14
|
+
@db_path = db_path
|
|
15
|
+
@db = Sequel.sqlite(db_path)
|
|
16
|
+
ensure_schema!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def close
|
|
20
|
+
@db.disconnect
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def schema_version
|
|
24
|
+
@db[:meta].where(key: "schema_version").get(:value)&.to_i
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def content_items
|
|
28
|
+
@db[:content_items]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delta_cursors
|
|
32
|
+
@db[:delta_cursors]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def entities
|
|
36
|
+
@db[:entities]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def entity_aliases
|
|
40
|
+
@db[:entity_aliases]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def facts
|
|
44
|
+
@db[:facts]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def provenance
|
|
48
|
+
@db[:provenance]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fact_links
|
|
52
|
+
@db[:fact_links]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def conflicts
|
|
56
|
+
@db[:conflicts]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def ensure_schema!
|
|
62
|
+
create_tables!
|
|
63
|
+
run_migrations!
|
|
64
|
+
set_meta("schema_version", SCHEMA_VERSION.to_s)
|
|
65
|
+
set_meta("created_at", Time.now.utc.iso8601) unless get_meta("created_at")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run_migrations!
|
|
69
|
+
current = get_meta("schema_version")&.to_i || 0
|
|
70
|
+
|
|
71
|
+
migrate_to_v2! if current < 2
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def migrate_to_v2!
|
|
75
|
+
columns = @db.schema(:content_items).map(&:first)
|
|
76
|
+
unless columns.include?(:project_path)
|
|
77
|
+
@db.alter_table(:content_items) do
|
|
78
|
+
add_column :project_path, String
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
columns = @db.schema(:facts).map(&:first)
|
|
83
|
+
unless columns.include?(:scope)
|
|
84
|
+
@db.alter_table(:facts) do
|
|
85
|
+
add_column :scope, String, default: "project"
|
|
86
|
+
add_column :project_path, String
|
|
87
|
+
add_index :scope, name: :idx_facts_scope
|
|
88
|
+
add_index :project_path, name: :idx_facts_project
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_tables!
|
|
94
|
+
@db.create_table?(:meta) do
|
|
95
|
+
String :key, primary_key: true
|
|
96
|
+
String :value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@db.create_table?(:content_items) do
|
|
100
|
+
primary_key :id
|
|
101
|
+
String :source, null: false
|
|
102
|
+
String :session_id
|
|
103
|
+
String :transcript_path
|
|
104
|
+
String :project_path
|
|
105
|
+
String :occurred_at
|
|
106
|
+
String :ingested_at, null: false
|
|
107
|
+
String :text_hash, null: false
|
|
108
|
+
Integer :byte_len, null: false
|
|
109
|
+
String :raw_text, text: true
|
|
110
|
+
String :metadata_json, text: true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@db.create_table?(:delta_cursors) do
|
|
114
|
+
primary_key :id
|
|
115
|
+
String :session_id, null: false
|
|
116
|
+
String :transcript_path, null: false
|
|
117
|
+
Integer :last_byte_offset, null: false, default: 0
|
|
118
|
+
String :updated_at, null: false
|
|
119
|
+
unique [:session_id, :transcript_path]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@db.create_table?(:entities) do
|
|
123
|
+
primary_key :id
|
|
124
|
+
String :type, null: false
|
|
125
|
+
String :canonical_name, null: false
|
|
126
|
+
String :slug, null: false, unique: true
|
|
127
|
+
String :created_at, null: false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@db.create_table?(:entity_aliases) do
|
|
131
|
+
primary_key :id
|
|
132
|
+
foreign_key :entity_id, :entities, null: false
|
|
133
|
+
String :source
|
|
134
|
+
String :alias, null: false
|
|
135
|
+
Float :confidence, default: 1.0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@db.create_table?(:facts) do
|
|
139
|
+
primary_key :id
|
|
140
|
+
foreign_key :subject_entity_id, :entities
|
|
141
|
+
String :predicate, null: false
|
|
142
|
+
foreign_key :object_entity_id, :entities
|
|
143
|
+
String :object_literal
|
|
144
|
+
String :datatype
|
|
145
|
+
String :polarity, default: "positive"
|
|
146
|
+
String :valid_from
|
|
147
|
+
String :valid_to
|
|
148
|
+
String :status, default: "active"
|
|
149
|
+
Float :confidence, default: 1.0
|
|
150
|
+
String :created_from
|
|
151
|
+
String :created_at, null: false
|
|
152
|
+
String :scope, default: "project"
|
|
153
|
+
String :project_path
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@db.create_table?(:provenance) do
|
|
157
|
+
primary_key :id
|
|
158
|
+
foreign_key :fact_id, :facts, null: false
|
|
159
|
+
foreign_key :content_item_id, :content_items
|
|
160
|
+
String :quote, text: true
|
|
161
|
+
foreign_key :attribution_entity_id, :entities
|
|
162
|
+
String :strength, default: "stated"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@db.create_table?(:fact_links) do
|
|
166
|
+
primary_key :id
|
|
167
|
+
foreign_key :from_fact_id, :facts, null: false
|
|
168
|
+
foreign_key :to_fact_id, :facts, null: false
|
|
169
|
+
String :link_type, null: false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
@db.create_table?(:conflicts) do
|
|
173
|
+
primary_key :id
|
|
174
|
+
foreign_key :fact_a_id, :facts, null: false
|
|
175
|
+
foreign_key :fact_b_id, :facts, null: false
|
|
176
|
+
String :status, default: "open"
|
|
177
|
+
String :detected_at, null: false
|
|
178
|
+
String :notes, text: true
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
create_index_if_not_exists(:facts, :predicate, :idx_facts_predicate)
|
|
182
|
+
create_index_if_not_exists(:facts, :subject_entity_id, :idx_facts_subject)
|
|
183
|
+
create_index_if_not_exists(:facts, :status, :idx_facts_status)
|
|
184
|
+
create_index_if_not_exists(:facts, :scope, :idx_facts_scope)
|
|
185
|
+
create_index_if_not_exists(:facts, :project_path, :idx_facts_project)
|
|
186
|
+
create_index_if_not_exists(:provenance, :fact_id, :idx_provenance_fact)
|
|
187
|
+
create_index_if_not_exists(:entity_aliases, :entity_id, :idx_entity_aliases_entity)
|
|
188
|
+
create_index_if_not_exists(:content_items, :session_id, :idx_content_items_session)
|
|
189
|
+
create_index_if_not_exists(:content_items, :project_path, :idx_content_items_project)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def create_index_if_not_exists(table, column, name)
|
|
193
|
+
@db.run("CREATE INDEX IF NOT EXISTS #{name} ON #{table}(#{column})")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def set_meta(key, value)
|
|
197
|
+
@db[:meta].insert_conflict(target: :key, update: {value: value}).insert(key: key, value: value)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def get_meta(key)
|
|
201
|
+
@db[:meta].where(key: key).get(:value)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
public
|
|
205
|
+
|
|
206
|
+
def upsert_content_item(source:, text_hash:, byte_len:, session_id: nil, transcript_path: nil,
|
|
207
|
+
project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil)
|
|
208
|
+
existing = content_items.where(text_hash: text_hash, session_id: session_id).get(:id)
|
|
209
|
+
return existing if existing
|
|
210
|
+
|
|
211
|
+
now = Time.now.utc.iso8601
|
|
212
|
+
content_items.insert(
|
|
213
|
+
source: source,
|
|
214
|
+
session_id: session_id,
|
|
215
|
+
transcript_path: transcript_path,
|
|
216
|
+
project_path: project_path,
|
|
217
|
+
occurred_at: occurred_at || now,
|
|
218
|
+
ingested_at: now,
|
|
219
|
+
text_hash: text_hash,
|
|
220
|
+
byte_len: byte_len,
|
|
221
|
+
raw_text: raw_text,
|
|
222
|
+
metadata_json: metadata&.to_json
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def get_delta_cursor(session_id, transcript_path)
|
|
227
|
+
delta_cursors.where(session_id: session_id, transcript_path: transcript_path).get(:last_byte_offset)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def update_delta_cursor(session_id, transcript_path, offset)
|
|
231
|
+
now = Time.now.utc.iso8601
|
|
232
|
+
delta_cursors
|
|
233
|
+
.insert_conflict(
|
|
234
|
+
target: [:session_id, :transcript_path],
|
|
235
|
+
update: {last_byte_offset: offset, updated_at: now}
|
|
236
|
+
)
|
|
237
|
+
.insert(
|
|
238
|
+
session_id: session_id,
|
|
239
|
+
transcript_path: transcript_path,
|
|
240
|
+
last_byte_offset: offset,
|
|
241
|
+
updated_at: now
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def find_or_create_entity(type:, name:)
|
|
246
|
+
slug = slugify(type, name)
|
|
247
|
+
existing = entities.where(slug: slug).get(:id)
|
|
248
|
+
return existing if existing
|
|
249
|
+
|
|
250
|
+
now = Time.now.utc.iso8601
|
|
251
|
+
entities.insert(type: type, canonical_name: name, slug: slug, created_at: now)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def insert_fact(subject_entity_id:, predicate:, object_entity_id: nil, object_literal: nil,
|
|
255
|
+
datatype: nil, polarity: "positive", valid_from: nil, status: "active",
|
|
256
|
+
confidence: 1.0, created_from: nil, scope: "project", project_path: nil)
|
|
257
|
+
now = Time.now.utc.iso8601
|
|
258
|
+
facts.insert(
|
|
259
|
+
subject_entity_id: subject_entity_id,
|
|
260
|
+
predicate: predicate,
|
|
261
|
+
object_entity_id: object_entity_id,
|
|
262
|
+
object_literal: object_literal,
|
|
263
|
+
datatype: datatype,
|
|
264
|
+
polarity: polarity,
|
|
265
|
+
valid_from: valid_from || now,
|
|
266
|
+
status: status,
|
|
267
|
+
confidence: confidence,
|
|
268
|
+
created_from: created_from,
|
|
269
|
+
created_at: now,
|
|
270
|
+
scope: scope,
|
|
271
|
+
project_path: project_path
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def update_fact(fact_id, status: nil, valid_to: nil, scope: nil, project_path: nil)
|
|
276
|
+
updates = {}
|
|
277
|
+
updates[:status] = status if status
|
|
278
|
+
updates[:valid_to] = valid_to if valid_to
|
|
279
|
+
|
|
280
|
+
if scope
|
|
281
|
+
updates[:scope] = scope
|
|
282
|
+
updates[:project_path] = (scope == "global") ? nil : project_path
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
return false if updates.empty?
|
|
286
|
+
|
|
287
|
+
facts.where(id: fact_id).update(updates)
|
|
288
|
+
true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def facts_for_slot(subject_entity_id, predicate, status: "active")
|
|
292
|
+
facts
|
|
293
|
+
.where(subject_entity_id: subject_entity_id, predicate: predicate, status: status)
|
|
294
|
+
.select(:id, :subject_entity_id, :predicate, :object_entity_id, :object_literal,
|
|
295
|
+
:datatype, :polarity, :valid_from, :valid_to, :status, :confidence,
|
|
296
|
+
:created_from, :created_at)
|
|
297
|
+
.all
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def insert_provenance(fact_id:, content_item_id: nil, quote: nil, attribution_entity_id: nil, strength: "stated")
|
|
301
|
+
provenance.insert(
|
|
302
|
+
fact_id: fact_id,
|
|
303
|
+
content_item_id: content_item_id,
|
|
304
|
+
quote: quote,
|
|
305
|
+
attribution_entity_id: attribution_entity_id,
|
|
306
|
+
strength: strength
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def provenance_for_fact(fact_id)
|
|
311
|
+
provenance.where(fact_id: fact_id).all
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def insert_conflict(fact_a_id:, fact_b_id:, status: "open", notes: nil)
|
|
315
|
+
now = Time.now.utc.iso8601
|
|
316
|
+
conflicts.insert(
|
|
317
|
+
fact_a_id: fact_a_id,
|
|
318
|
+
fact_b_id: fact_b_id,
|
|
319
|
+
status: status,
|
|
320
|
+
detected_at: now,
|
|
321
|
+
notes: notes
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def open_conflicts
|
|
326
|
+
conflicts.where(status: "open").all
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def insert_fact_link(from_fact_id:, to_fact_id:, link_type:)
|
|
330
|
+
fact_links.insert(from_fact_id: from_fact_id, to_fact_id: to_fact_id, link_type: link_type)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
private
|
|
334
|
+
|
|
335
|
+
def slugify(type, name)
|
|
336
|
+
"#{type}:#{name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")}"
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Store
|
|
7
|
+
class StoreManager
|
|
8
|
+
attr_reader :global_store, :project_store, :project_path
|
|
9
|
+
|
|
10
|
+
def initialize(global_db_path: nil, project_db_path: nil, project_path: nil, env: ENV)
|
|
11
|
+
@project_path = project_path || env["CLAUDE_PROJECT_DIR"] || Dir.pwd
|
|
12
|
+
@global_db_path = global_db_path || self.class.default_global_db_path(env)
|
|
13
|
+
@project_db_path = project_db_path || self.class.default_project_db_path(@project_path)
|
|
14
|
+
|
|
15
|
+
@global_store = nil
|
|
16
|
+
@project_store = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.default_global_db_path(env = ENV)
|
|
20
|
+
home = env["HOME"] || File.expand_path("~")
|
|
21
|
+
File.join(home, ".claude", "memory.sqlite3")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.default_project_db_path(project_path = Dir.pwd)
|
|
25
|
+
File.join(project_path, ".claude", "memory.sqlite3")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ensure_global!
|
|
29
|
+
return @global_store if @global_store
|
|
30
|
+
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(@global_db_path))
|
|
32
|
+
@global_store = SQLiteStore.new(@global_db_path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ensure_project!
|
|
36
|
+
return @project_store if @project_store
|
|
37
|
+
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(@project_db_path))
|
|
39
|
+
@project_store = SQLiteStore.new(@project_db_path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ensure_both!
|
|
43
|
+
ensure_global!
|
|
44
|
+
ensure_project!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :global_db_path
|
|
48
|
+
|
|
49
|
+
attr_reader :project_db_path
|
|
50
|
+
|
|
51
|
+
def global_exists?
|
|
52
|
+
File.exist?(@global_db_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def project_exists?
|
|
56
|
+
File.exist?(@project_db_path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def close
|
|
60
|
+
@global_store&.close
|
|
61
|
+
@project_store&.close
|
|
62
|
+
@global_store = nil
|
|
63
|
+
@project_store = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def store_for_scope(scope)
|
|
67
|
+
case scope
|
|
68
|
+
when "global"
|
|
69
|
+
ensure_global!
|
|
70
|
+
@global_store
|
|
71
|
+
when "project"
|
|
72
|
+
ensure_project!
|
|
73
|
+
@project_store
|
|
74
|
+
else
|
|
75
|
+
raise ArgumentError, "Invalid scope: #{scope}. Use 'global' or 'project'"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def promote_fact(fact_id)
|
|
80
|
+
ensure_both!
|
|
81
|
+
|
|
82
|
+
fact = @project_store.facts.where(id: fact_id).first
|
|
83
|
+
return nil unless fact
|
|
84
|
+
|
|
85
|
+
subject = @project_store.entities.where(id: fact[:subject_entity_id]).first
|
|
86
|
+
return nil unless subject
|
|
87
|
+
|
|
88
|
+
global_subject_id = @global_store.find_or_create_entity(
|
|
89
|
+
type: subject[:type],
|
|
90
|
+
name: subject[:canonical_name]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
global_object_id = nil
|
|
94
|
+
if fact[:object_entity_id]
|
|
95
|
+
object = @project_store.entities.where(id: fact[:object_entity_id]).first
|
|
96
|
+
if object
|
|
97
|
+
global_object_id = @global_store.find_or_create_entity(
|
|
98
|
+
type: object[:type],
|
|
99
|
+
name: object[:canonical_name]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
global_fact_id = @global_store.insert_fact(
|
|
105
|
+
subject_entity_id: global_subject_id,
|
|
106
|
+
predicate: fact[:predicate],
|
|
107
|
+
object_entity_id: global_object_id,
|
|
108
|
+
object_literal: fact[:object_literal],
|
|
109
|
+
datatype: fact[:datatype],
|
|
110
|
+
polarity: fact[:polarity],
|
|
111
|
+
valid_from: fact[:valid_from],
|
|
112
|
+
status: fact[:status],
|
|
113
|
+
confidence: fact[:confidence],
|
|
114
|
+
created_from: "promoted:#{@project_path}:#{fact_id}",
|
|
115
|
+
scope: "global",
|
|
116
|
+
project_path: nil
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
copy_provenance(fact_id, global_fact_id)
|
|
120
|
+
|
|
121
|
+
global_fact_id
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def copy_provenance(source_fact_id, target_fact_id)
|
|
127
|
+
@project_store.provenance.where(fact_id: source_fact_id).each do |prov|
|
|
128
|
+
@global_store.insert_provenance(
|
|
129
|
+
fact_id: target_fact_id,
|
|
130
|
+
content_item_id: nil,
|
|
131
|
+
quote: prov[:quote],
|
|
132
|
+
attribution_entity_id: nil,
|
|
133
|
+
strength: prov[:strength]
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Sweep
|
|
5
|
+
class Sweeper
|
|
6
|
+
DEFAULT_CONFIG = {
|
|
7
|
+
proposed_fact_ttl_days: 14,
|
|
8
|
+
disputed_fact_ttl_days: 30,
|
|
9
|
+
content_retention_days: 30,
|
|
10
|
+
default_budget_seconds: 5
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(store, config: {})
|
|
14
|
+
@store = store
|
|
15
|
+
@config = DEFAULT_CONFIG.merge(config)
|
|
16
|
+
@start_time = nil
|
|
17
|
+
@stats = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run!(budget_seconds: nil)
|
|
21
|
+
budget = budget_seconds || @config[:default_budget_seconds]
|
|
22
|
+
@start_time = Time.now
|
|
23
|
+
@stats = {
|
|
24
|
+
proposed_facts_expired: 0,
|
|
25
|
+
disputed_facts_expired: 0,
|
|
26
|
+
orphaned_provenance_deleted: 0,
|
|
27
|
+
old_content_pruned: 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
expire_proposed_facts if within_budget?
|
|
31
|
+
expire_disputed_facts if within_budget?
|
|
32
|
+
prune_orphaned_provenance if within_budget?
|
|
33
|
+
prune_old_content if within_budget?
|
|
34
|
+
|
|
35
|
+
@stats[:elapsed_seconds] = Time.now - @start_time
|
|
36
|
+
@stats[:budget_honored] = @stats[:elapsed_seconds] <= budget
|
|
37
|
+
@stats
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def within_budget?
|
|
43
|
+
budget = @config[:default_budget_seconds]
|
|
44
|
+
(Time.now - @start_time) < budget
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def expire_proposed_facts
|
|
48
|
+
cutoff = (Time.now - @config[:proposed_fact_ttl_days] * 86400).utc.iso8601
|
|
49
|
+
@stats[:proposed_facts_expired] = @store.facts
|
|
50
|
+
.where(status: "proposed")
|
|
51
|
+
.where { created_at < cutoff }
|
|
52
|
+
.update(status: "expired")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def expire_disputed_facts
|
|
56
|
+
cutoff = (Time.now - @config[:disputed_fact_ttl_days] * 86400).utc.iso8601
|
|
57
|
+
@stats[:disputed_facts_expired] = @store.facts
|
|
58
|
+
.where(status: "disputed")
|
|
59
|
+
.where { created_at < cutoff }
|
|
60
|
+
.update(status: "expired")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def prune_orphaned_provenance
|
|
64
|
+
fact_ids = @store.facts.select(:id)
|
|
65
|
+
@stats[:orphaned_provenance_deleted] = @store.provenance
|
|
66
|
+
.exclude(fact_id: fact_ids)
|
|
67
|
+
.delete
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def prune_old_content
|
|
71
|
+
cutoff = (Time.now - @config[:content_retention_days] * 86400).utc.iso8601
|
|
72
|
+
referenced_ids = @store.provenance.exclude(content_item_id: nil).select(:content_item_id)
|
|
73
|
+
@stats[:old_content_pruned] = @store.content_items
|
|
74
|
+
.where { ingested_at < cutoff }
|
|
75
|
+
.exclude(id: referenced_ids)
|
|
76
|
+
.delete
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"Stop": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "claude-memory hook ingest",
|
|
10
|
+
"timeout": 10
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"SessionStart": [
|
|
16
|
+
{
|
|
17
|
+
"matcher": "",
|
|
18
|
+
"hooks": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "claude-memory hook ingest",
|
|
22
|
+
"timeout": 10
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"PreCompact": [
|
|
28
|
+
{
|
|
29
|
+
"matcher": "",
|
|
30
|
+
"hooks": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "claude-memory hook ingest",
|
|
34
|
+
"timeout": 30
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"type": "command",
|
|
38
|
+
"command": "claude-memory hook sweep",
|
|
39
|
+
"timeout": 30
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"SessionEnd": [
|
|
45
|
+
{
|
|
46
|
+
"matcher": "",
|
|
47
|
+
"hooks": [
|
|
48
|
+
{
|
|
49
|
+
"type": "command",
|
|
50
|
+
"command": "claude-memory hook ingest",
|
|
51
|
+
"timeout": 30
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "command",
|
|
55
|
+
"command": "claude-memory hook sweep",
|
|
56
|
+
"timeout": 30
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"Notification": [
|
|
62
|
+
{
|
|
63
|
+
"matcher": "idle_prompt",
|
|
64
|
+
"hooks": [
|
|
65
|
+
{
|
|
66
|
+
"type": "command",
|
|
67
|
+
"command": "claude-memory hook sweep",
|
|
68
|
+
"timeout": 10
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
keep-coding-instructions: true
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Memory-Aware Output Style
|
|
6
|
+
|
|
7
|
+
When making decisions or establishing conventions:
|
|
8
|
+
- State decisions clearly with "We decided to..." or "We agreed to..."
|
|
9
|
+
- Be explicit about technology choices: "We use PostgreSQL for..."
|
|
10
|
+
- Clarify when replacing previous decisions: "We no longer use X, switching to Y"
|
|
11
|
+
- Note conventions with "Convention:" or "Standard:"
|
|
12
|
+
|
|
13
|
+
When recalling past context:
|
|
14
|
+
- Use the memory.recall MCP tool to find relevant past decisions
|
|
15
|
+
- Cite specific facts when referencing previous work
|
|
16
|
+
- If unsure, use memory.explain to get provenance for a fact
|
|
17
|
+
|
|
18
|
+
When conflicts arise:
|
|
19
|
+
- Acknowledge contradictions explicitly
|
|
20
|
+
- Use memory.conflicts to see open disputes
|
|
21
|
+
- Help resolve conflicts by providing clear supersession signals
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
LEGACY_DB_PATH = ".claude_memory.sqlite3"
|
|
7
|
+
PROJECT_DB_PATH = ".claude/memory.sqlite3"
|
|
8
|
+
|
|
9
|
+
def self.global_db_path(env = ENV)
|
|
10
|
+
home = env["HOME"] || File.expand_path("~")
|
|
11
|
+
File.join(home, ".claude", "memory.sqlite3")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.project_db_path(project_path = Dir.pwd)
|
|
15
|
+
File.join(project_path, ".claude", "memory.sqlite3")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative "claude_memory/version"
|
|
20
|
+
require_relative "claude_memory/cli"
|
|
21
|
+
require_relative "claude_memory/store/sqlite_store"
|
|
22
|
+
require_relative "claude_memory/store/store_manager"
|
|
23
|
+
require_relative "claude_memory/ingest/transcript_reader"
|
|
24
|
+
require_relative "claude_memory/ingest/ingester"
|
|
25
|
+
require_relative "claude_memory/index/lexical_fts"
|
|
26
|
+
require_relative "claude_memory/distill/extraction"
|
|
27
|
+
require_relative "claude_memory/distill/distiller"
|
|
28
|
+
require_relative "claude_memory/distill/null_distiller"
|
|
29
|
+
require_relative "claude_memory/resolve/predicate_policy"
|
|
30
|
+
require_relative "claude_memory/resolve/resolver"
|
|
31
|
+
require_relative "claude_memory/recall"
|
|
32
|
+
require_relative "claude_memory/sweep/sweeper"
|
|
33
|
+
require_relative "claude_memory/mcp/tools"
|
|
34
|
+
require_relative "claude_memory/mcp/server"
|
|
35
|
+
require_relative "claude_memory/publish"
|
|
36
|
+
require_relative "claude_memory/hook/handler"
|