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'
BACKUP_DIR_PATTERN =

Backup directories created by upgrade flow (‘.bak.<timestamp>`). Loader must skip these — they may contain old/broken frontmatter.

/(?:^|\.)bak(?:\.|$)/.freeze

Instance Attribute Summary collapse

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



42
43
44
45
46
47
48
49
50
51
52
# File 'lib/kairos_mcp/knowledge_provider.rb', line 42

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 Attribute Details

#knowledge_dirObject (readonly)

Main knowledge directory (constitutively-recorded L1). Exposed so callers can distinguish main-dir knowledge from read-only external SkillSet knowledge, e.g. to scope INV-A correspondence checks to recorded artifacts.



29
30
31
# File 'lib/kairos_mcp/knowledge_provider.rb', line 29

def knowledge_dir
  @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



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

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



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
407
408
409
410
411
412
413
414
# File 'lib/kairos_mcp/knowledge_provider.rb', line 363

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



519
520
521
522
# File 'lib/kairos_mcp/knowledge_provider.rb', line 519

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



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/kairos_mcp/knowledge_provider.rb', line 141

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



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/kairos_mcp/knowledge_provider.rb', line 216

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:



121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/kairos_mcp/knowledge_provider.rb', line 121

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



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/kairos_mcp/knowledge_provider.rb', line 496

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



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
107
108
109
110
111
112
113
114
# File 'lib/kairos_mcp/knowledge_provider.rb', line 77

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



472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
# File 'lib/kairos_mcp/knowledge_provider.rb', line 472

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



262
263
264
265
266
267
# File 'lib/kairos_mcp/knowledge_provider.rb', line 262

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



273
274
275
276
277
278
# File 'lib/kairos_mcp/knowledge_provider.rb', line 273

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



251
252
253
254
255
256
# File 'lib/kairos_mcp/knowledge_provider.rb', line 251

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



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
344
345
346
347
348
349
350
351
# File 'lib/kairos_mcp/knowledge_provider.rb', line 311

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



286
287
288
289
290
291
292
293
294
# File 'lib/kairos_mcp/knowledge_provider.rb', line 286

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



70
71
72
# File 'lib/kairos_mcp/knowledge_provider.rb', line 70

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



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
460
461
462
463
464
465
466
467
# File 'lib/kairos_mcp/knowledge_provider.rb', line 421

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



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
202
203
204
205
206
207
208
209
# File 'lib/kairos_mcp/knowledge_provider.rb', line 175

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



299
300
301
302
303
304
305
306
# File 'lib/kairos_mcp/knowledge_provider.rb', line 299

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