Class: KairosMcp::KnowledgeProvider

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/knowledge_provider.rb

Overview

KnowledgeProvider: Manages L1 (knowledge layer) skills in Anthropic format

L1 characteristics:

  • Project-specific universal knowledge

  • Hash-only blockchain recording

  • Lightweight modification constraints

  • Folder-based archiving (.archived/ directory)

Storage:

  • Content (*.md files): Always stored in files for human readability

  • Metadata: Stored in files (default) or SQLite (when sqlite backend enabled)

  • Blockchain: Uses the configured storage backend

Constant Summary collapse

ARCHIVED_DIR =
'.archived'
ARCHIVE_META_FILE =
'.archive_meta.yml'

Instance Method Summary collapse

Constructor Details

#initialize(knowledge_dir = nil, vector_search_enabled: true, storage_backend: nil, user_context: nil) ⇒ KnowledgeProvider

Initialize the KnowledgeProvider

Parameters:

  • knowledge_dir (String) (defaults to: nil)

    Path to knowledge directory

  • vector_search_enabled (Boolean) (defaults to: true)

    Enable vector search

  • storage_backend (Storage::Backend, nil) (defaults to: nil)

    Storage backend to use



34
35
36
37
38
39
40
41
42
43
44
# File 'lib/kairos_mcp/knowledge_provider.rb', line 34

def initialize(knowledge_dir = nil, vector_search_enabled: true, storage_backend: nil, user_context: nil)
  knowledge_dir ||= KairosMcp.knowledge_dir(user_context: user_context)
  @knowledge_dir = knowledge_dir
  @user_context = user_context
  @vector_search_enabled = vector_search_enabled
  @storage_backend = storage_backend
  @vector_search = nil
  @index_built = false
  @external_dirs = []
  FileUtils.mkdir_p(@knowledge_dir)
end

Instance Method Details

#add_external_dir(dir, source:, layer: :L1, index: true) ⇒ Object

Register an external knowledge directory (e.g. from a SkillSet) Knowledge is read-only from external dirs; no merge into the main dir.

Parameters:

  • dir (String)

    Absolute path to the knowledge directory

  • source (String)

    Identifier for the source (e.g. “skillset:mmp”)

  • layer (Symbol) (defaults to: :L1)

    Layer governance (:L0, :L1, :L2)

  • index (Boolean) (defaults to: true)

    Whether to include in vector search index



53
54
55
56
57
58
# File 'lib/kairos_mcp/knowledge_provider.rb', line 53

def add_external_dir(dir, source:, layer: :L1, index: true)
  return unless File.directory?(dir)

  @external_dirs << { dir: dir, source: source, layer: layer, index: index }
  @index_built = false if index # Invalidate index when new indexed dir added
end

#archive(name, reason:, superseded_by: nil) ⇒ Hash

Archive a knowledge skill (move to .archived/ directory)

Parameters:

  • name (String)

    Skill name

  • reason (String)

    Reason for archiving

  • superseded_by (String, nil) (defaults to: nil)

    Name of the knowledge that supersedes this one

Returns:

  • (Hash)

    Result with success status



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/kairos_mcp/knowledge_provider.rb', line 355

def archive(name, reason:, superseded_by: nil)
  skill = get(name)
  unless skill
    return { success: false, error: "Knowledge '#{name}' not found" }
  end

  # Check if already archived
  if archived?(name)
    return { success: false, error: "Knowledge '#{name}' is already archived" }
  end

  # Create archive directory
  archived_dir = File.join(@knowledge_dir, ARCHIVED_DIR)
  FileUtils.mkdir_p(archived_dir)

  # Calculate hash before moving
  content = File.read(skill.md_file_path)
  content_hash = Digest::SHA256.hexdigest(content)

  # Move to archive
  dest_path = File.join(archived_dir, name)
  FileUtils.mv(skill.base_path, dest_path)

  # Create archive metadata file
  meta = {
    'archived_at' => Time.now.iso8601,
    'archived_reason' => reason,
    'superseded_by' => superseded_by,
    'original_path' => skill.base_path,
    'content_hash' => content_hash
  }
  File.write(File.join(dest_path, ARCHIVE_META_FILE), meta.to_yaml)

  # Record to blockchain
  record_hash_reference(
    name: name,
    action: 'archive',
    prev_hash: content_hash,
    next_hash: nil,
    reason: reason
  )

  # Remove from vector search index
  remove_from_vector_index(name)

  # Track pending change for state commit (archive = demotion)
  track_pending_change(layer: 'L1', action: 'archive', skill_id: name, reason: reason)

  { success: true, archived: name, path: dest_path, hash: content_hash }
rescue StandardError => e
  { success: false, error: "Archive failed: #{e.message}" }
end

#archived?(name) ⇒ Boolean

Check if a knowledge skill is archived

Parameters:

  • name (String)

    Skill name

Returns:

  • (Boolean)

    True if archived



511
512
513
514
# File 'lib/kairos_mcp/knowledge_provider.rb', line 511

def archived?(name)
  archived_path = File.join(@knowledge_dir, ARCHIVED_DIR, name)
  File.directory?(archived_path)
end

#create(name, content, reason: nil, create_subdirs: false) ⇒ Hash

Create a new knowledge skill

Parameters:

  • name (String)

    Skill name

  • content (String)

    Full content including YAML frontmatter

  • reason (String) (defaults to: nil)

    Reason for creation

  • create_subdirs (Boolean) (defaults to: false)

    Whether to create scripts/assets/references

Returns:

  • (Hash)

    Result with success status and skill info



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/kairos_mcp/knowledge_provider.rb', line 133

def create(name, content, reason: nil, create_subdirs: false)
  skill_dir = File.join(@knowledge_dir, name)
  
  if File.exist?(skill_dir)
    return { success: false, error: "Knowledge '#{name}' already exists" }
  end

  skill = AnthropicSkillParser.create(@knowledge_dir, name, content, create_subdirs: create_subdirs)
  
  # Record hash reference to blockchain
  content_hash = Digest::SHA256.hexdigest(content)
  record_hash_reference(
    name: name,
    action: 'create',
    prev_hash: nil,
    next_hash: content_hash,
    reason: reason || "Create knowledge: #{name}"
  )

  # Update vector search index
  update_vector_index(name, content, skill)

  # Track pending change for state commit
  track_pending_change(layer: 'L1', action: 'create', skill_id: name, reason: reason)

  { success: true, skill: skill.to_h, hash: content_hash, next_hash: content_hash }
end

#delete(name, reason: nil) ⇒ Hash

Delete a knowledge skill

Parameters:

  • name (String)

    Skill name

  • reason (String) (defaults to: nil)

    Reason for deletion

Returns:

  • (Hash)

    Result with success status



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/kairos_mcp/knowledge_provider.rb', line 208

def delete(name, reason: nil)
  skill = get(name)
  unless skill
    return { success: false, error: "Knowledge '#{name}' not found" }
  end

  # Calculate hash before deletion
  prev_content = File.read(skill.md_file_path)
  prev_hash = Digest::SHA256.hexdigest(prev_content)

  # Delete the directory
  FileUtils.rm_rf(skill.base_path)

  # Record hash reference to blockchain
  record_hash_reference(
    name: name,
    action: 'delete',
    prev_hash: prev_hash,
    next_hash: nil,
    reason: reason || "Delete knowledge: #{name}"
  )

  # Remove from vector search index
  remove_from_vector_index(name)

  # Track pending change for state commit
  track_pending_change(layer: 'L1', action: 'delete', skill_id: name, reason: reason)

  { success: true, deleted: name, prev_hash: prev_hash }
end

#get(name) ⇒ AnthropicSkillParser::SkillEntry?

Get a specific knowledge skill by name Searches main knowledge dir first, then external SkillSet dirs

Parameters:

  • name (String)

    Skill name

Returns:



113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/kairos_mcp/knowledge_provider.rb', line 113

def get(name)
  skill_dir = File.join(@knowledge_dir, name)
  return AnthropicSkillParser.parse(skill_dir) if File.directory?(skill_dir)

  # Search external directories
  @external_dirs.each do |ext|
    ext_skill_dir = File.join(ext[:dir], name)
    return AnthropicSkillParser.parse(ext_skill_dir) if File.directory?(ext_skill_dir)
  end

  nil
end

#get_archived(name) ⇒ Hash?

Get a specific archived knowledge skill

Parameters:

  • name (String)

    Skill name

Returns:

  • (Hash, nil)

    Archived skill info or nil



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# File 'lib/kairos_mcp/knowledge_provider.rb', line 488

def get_archived(name)
  archived_path = File.join(@knowledge_dir, ARCHIVED_DIR, name)
  return nil unless File.directory?(archived_path)

  skill = AnthropicSkillParser.parse(archived_path)
  return nil unless skill

  meta_file = File.join(archived_path, ARCHIVE_META_FILE)
  meta = File.exist?(meta_file) ? YAML.safe_load(File.read(meta_file)) : {}

  {
    skill: skill.to_h,
    archived_at: meta['archived_at'],
    archived_reason: meta['archived_reason'],
    superseded_by: meta['superseded_by'],
    content_hash: meta['content_hash']
  }
end

#listArray<Hash>

List all knowledge skills (including those from external SkillSet dirs)

Returns:

  • (Array<Hash>)

    List of knowledge skill summaries



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/kairos_mcp/knowledge_provider.rb', line 69

def list
  results = skill_dirs.map do |dir|
    skill = AnthropicSkillParser.parse(dir)
    next unless skill

    {
      name: skill.name,
      description: skill.description,
      version: skill.version,
      tags: skill.tags,
      has_scripts: skill.has_scripts?,
      has_assets: skill.has_assets?,
      has_references: skill.has_references?
    }
  end.compact

  # Include knowledge from external directories (SkillSets)
  @external_dirs.each do |ext|
    external_skill_dirs(ext[:dir]).each do |dir|
      skill = AnthropicSkillParser.parse(dir)
      next unless skill

      results << {
        name: skill.name,
        description: skill.description,
        version: skill.version,
        tags: skill.tags,
        has_scripts: skill.has_scripts?,
        has_assets: skill.has_assets?,
        has_references: skill.has_references?,
        source: ext[:source],
        layer: ext[:layer]
      }
    end
  end

  results
end

#list_archivedArray<Hash>

List all archived knowledge skills

Returns:

  • (Array<Hash>)

    List of archived knowledge summaries



464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/kairos_mcp/knowledge_provider.rb', line 464

def list_archived
  archived_dir = File.join(@knowledge_dir, ARCHIVED_DIR)
  return [] unless File.directory?(archived_dir)

  Dir[File.join(archived_dir, '*')].select { |f| File.directory?(f) }.map do |dir|
    skill = AnthropicSkillParser.parse(dir)
    meta_file = File.join(dir, ARCHIVE_META_FILE)
    meta = File.exist?(meta_file) ? YAML.safe_load(File.read(meta_file)) : {}

    {
      name: skill&.name || File.basename(dir),
      description: skill&.description,
      archived_at: meta['archived_at'],
      archived_reason: meta['archived_reason'],
      superseded_by: meta['superseded_by'],
      content_hash: meta['content_hash']
    }
  end
end

#list_assets(name) ⇒ Array<Hash>

List assets in a knowledge skill

Parameters:

  • name (String)

    Skill name

Returns:

  • (Array<Hash>)

    List of asset info



254
255
256
257
258
259
# File 'lib/kairos_mcp/knowledge_provider.rb', line 254

def list_assets(name)
  skill = get(name)
  return [] unless skill

  AnthropicSkillParser.list_assets(skill)
end

#list_references(name) ⇒ Array<Hash>

List references in a knowledge skill

Parameters:

  • name (String)

    Skill name

Returns:

  • (Array<Hash>)

    List of reference info



265
266
267
268
269
270
# File 'lib/kairos_mcp/knowledge_provider.rb', line 265

def list_references(name)
  skill = get(name)
  return [] unless skill

  AnthropicSkillParser.list_references(skill)
end

#list_scripts(name) ⇒ Array<Hash>

List scripts in a knowledge skill

Parameters:

  • name (String)

    Skill name

Returns:

  • (Array<Hash>)

    List of script info



243
244
245
246
247
248
# File 'lib/kairos_mcp/knowledge_provider.rb', line 243

def list_scripts(name)
  skill = get(name)
  return [] unless skill

  AnthropicSkillParser.list_scripts(skill)
end

#rebuild_indexBoolean

Rebuild the vector search index (includes indexed external dirs)

Returns:

  • (Boolean)

    Success status



303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/kairos_mcp/knowledge_provider.rb', line 303

def rebuild_index
  documents = skill_dirs.filter_map do |dir|
    skill = AnthropicSkillParser.parse(dir)
    next unless skill

    content = File.read(skill.md_file_path) rescue ''
    {
      id: skill.name,
      text: build_searchable_text(skill, content),
      metadata: {
        description: skill.description,
        tags: skill.tags,
        version: skill.version
      }
    }
  end

  # Include external dirs that have indexing enabled
  @external_dirs.select { |ext| ext[:index] }.each do |ext|
    external_skill_dirs(ext[:dir]).each do |dir|
      skill = AnthropicSkillParser.parse(dir)
      next unless skill

      content = File.read(skill.md_file_path) rescue ''
      documents << {
        id: "#{ext[:source]}:#{skill.name}",
        text: build_searchable_text(skill, content),
        metadata: {
          description: skill.description,
          tags: skill.tags,
          version: skill.version,
          source: ext[:source]
        }
      }
    end
  end

  result = vector_search.rebuild(documents)
  @index_built = result
  result
end

#search(query, max_results = 5, semantic: nil) ⇒ Array<Hash>

Search knowledge skills by query

Parameters:

  • query (String)

    Search query

  • max_results (Integer) (defaults to: 5)

    Maximum number of results

  • semantic (Boolean) (defaults to: nil)

    Force semantic search if available

Returns:

  • (Array<Hash>)

    Matching skills



278
279
280
281
282
283
284
285
286
# File 'lib/kairos_mcp/knowledge_provider.rb', line 278

def search(query, max_results = 5, semantic: nil)
  use_semantic = semantic.nil? ? @vector_search_enabled : semantic
  
  if use_semantic && vector_search.semantic?
    semantic_search(query, max_results)
  else
    regex_search(query, max_results)
  end
end

#storage_typeSymbol

Get the storage backend type

Returns:

  • (Symbol)

    :file or :sqlite



62
63
64
# File 'lib/kairos_mcp/knowledge_provider.rb', line 62

def storage_type
  storage_backend.backend_type
end

#unarchive(name, reason:) ⇒ Hash

Unarchive a knowledge skill (restore from .archived/ directory)

Parameters:

  • name (String)

    Skill name

  • reason (String)

    Reason for unarchiving

Returns:

  • (Hash)

    Result with success status



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
# File 'lib/kairos_mcp/knowledge_provider.rb', line 413

def unarchive(name, reason:)
  archived_path = File.join(@knowledge_dir, ARCHIVED_DIR, name)

  unless File.directory?(archived_path)
    return { success: false, error: "Archived knowledge '#{name}' not found" }
  end

  # Check if active knowledge with same name exists
  active_path = File.join(@knowledge_dir, name)
  if File.directory?(active_path)
    return { success: false, error: "Active knowledge '#{name}' already exists. Rename or delete it first." }
  end

  # Read archive metadata
  meta_file = File.join(archived_path, ARCHIVE_META_FILE)
  meta = File.exist?(meta_file) ? YAML.safe_load(File.read(meta_file)) : {}

  # Move back to active
  FileUtils.mv(archived_path, active_path)

  # Remove archive metadata file
  FileUtils.rm_f(File.join(active_path, ARCHIVE_META_FILE))

  # Parse the restored skill
  skill = AnthropicSkillParser.parse(active_path)
  content = File.read(skill.md_file_path)
  content_hash = Digest::SHA256.hexdigest(content)

  # Record to blockchain
  record_hash_reference(
    name: name,
    action: 'unarchive',
    prev_hash: meta['content_hash'],
    next_hash: content_hash,
    reason: reason
  )

  # Update vector search index
  update_vector_index(name, content, skill)

  # Track pending change for state commit
  track_pending_change(layer: 'L1', action: 'unarchive', skill_id: name, reason: reason)

  { success: true, unarchived: name, path: active_path, hash: content_hash }
rescue StandardError => e
  { success: false, error: "Unarchive failed: #{e.message}" }
end

#update(name, new_content, reason: nil) ⇒ Hash

Update an existing knowledge skill

Parameters:

  • name (String)

    Skill name

  • new_content (String)

    New content including YAML frontmatter

  • reason (String) (defaults to: nil)

    Reason for update

Returns:

  • (Hash)

    Result with success status



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/kairos_mcp/knowledge_provider.rb', line 167

def update(name, new_content, reason: nil)
  skill = get(name)
  unless skill
    return { success: false, error: "Knowledge '#{name}' not found" }
  end

  # Calculate hashes
  prev_content = File.read(skill.md_file_path)
  prev_hash = Digest::SHA256.hexdigest(prev_content)
  next_hash = Digest::SHA256.hexdigest(new_content)

  if prev_hash == next_hash
    return { success: false, error: "No changes detected" }
  end

  # Update the file
  updated_skill = AnthropicSkillParser.update(skill.base_path, new_content)

  # Record hash reference to blockchain
  record_hash_reference(
    name: name,
    action: 'update',
    prev_hash: prev_hash,
    next_hash: next_hash,
    reason: reason || "Update knowledge: #{name}"
  )

  # Update vector search index
  update_vector_index(name, new_content, updated_skill)

  # Track pending change for state commit
  track_pending_change(layer: 'L1', action: 'update', skill_id: name, reason: reason)

  { success: true, skill: updated_skill.to_h, prev_hash: prev_hash, next_hash: next_hash }
end

#vector_search_statusHash

Get vector search status

Returns:

  • (Hash)

    Status information



291
292
293
294
295
296
297
298
# File 'lib/kairos_mcp/knowledge_provider.rb', line 291

def vector_search_status
  {
    enabled: @vector_search_enabled,
    semantic_available: VectorSearch.available?,
    index_built: @index_built,
    document_count: vector_search.count
  }
end