Class: KairosMcp::ContextManager

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

Overview

ContextManager: Manages L2 (context layer) skills in Anthropic format

L2 characteristics:

  • Temporary context and hypotheses
  • No blockchain recording (free modification)
  • Session-based organization

Instance Method Summary collapse

Constructor Details

#initialize(context_dir = nil, user_context: nil) ⇒ ContextManager

Returns a new instance of ContextManager.



19
20
21
22
23
24
# File 'lib/kairos_mcp/context_manager.rb', line 19

def initialize(context_dir = nil, user_context: nil)
  context_dir ||= KairosMcp.context_dir(user_context: user_context)
  @context_dir = context_dir
  @user_context = user_context
  FileUtils.mkdir_p(@context_dir)
end

Instance Method Details

#create_subdir(session_id, name, subdir) ⇒ Hash

Create a subdirectory (scripts, assets, or references)

Parameters:

  • session_id (String)

    Session ID

  • name (String)

    Context name

  • subdir (String)

    Subdirectory name ('scripts', 'assets', or 'references')

Returns:

  • (Hash)

    Result with success status and path



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/kairos_mcp/context_manager.rb', line 191

def create_subdir(session_id, name, subdir)
  valid_subdirs = %w[scripts assets references]
  unless valid_subdirs.include?(subdir)
    return { success: false, error: "Invalid subdir. Must be one of: #{valid_subdirs.join(', ')}" }
  end

  context_dir = File.join(@context_dir, session_id, name)
  unless segments?(session_id, name) && contained?(context_dir) && File.directory?(context_dir)
    return { success: false, error: "Context '#{name}' not found in session '#{session_id}'" }
  end

  subdir_path = File.join(context_dir, subdir)
  unless contained?(subdir_path)
    return { success: false, error: "Invalid subdir '#{subdir}': resolves outside the context directory" }
  end

  # mkdir_p raises Errno::EEXIST when the name is taken by a dangling
  # symlink, and this method's contract is a result hash, not an exception.
  FileUtils.mkdir_p(subdir_path)
  { success: true, path: subdir_path }
rescue SystemCallError => e
  { success: false, error: "Could not create subdir '#{subdir}': #{e.message}" }
end

#delete_context(session_id, name) ⇒ Hash

Delete a context

Parameters:

  • session_id (String)

    Session ID

  • name (String)

    Context name

Returns:

  • (Hash)

    Result with success status



156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/kairos_mcp/context_manager.rb', line 156

def delete_context(session_id, name)
  context_dir = File.join(@context_dir, session_id, name)

  # rm_rf below: containment is checked first so a traversing name cannot
  # delete a tree outside the context store.
  unless segments?(session_id, name) && contained?(context_dir) && File.directory?(context_dir)
    return { success: false, error: "Context '#{name}' not found in session '#{session_id}'" }
  end

  FileUtils.rm_rf(context_dir)
  { success: true, deleted: name }
end

#delete_session(session_id) ⇒ Hash

Delete an entire session

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Hash)

    Result with success status



173
174
175
176
177
178
179
180
181
182
183
# File 'lib/kairos_mcp/context_manager.rb', line 173

def delete_session(session_id)
  session_dir = File.join(@context_dir, session_id)

  unless segments?(session_id) && contained?(session_dir) && File.directory?(session_dir)
    return { success: false, error: "Session '#{session_id}' not found" }
  end

  contexts_count = context_dirs(session_dir).size
  FileUtils.rm_rf(session_dir)
  { success: true, deleted: session_id, contexts_deleted: contexts_count }
end

#generate_session_id(prefix: 'session') ⇒ String

Generate a unique session ID

Parameters:

  • prefix (String) (defaults to: 'session')

    Optional prefix for the session ID

Returns:

  • (String)

    Generated session ID



219
220
221
222
223
# File 'lib/kairos_mcp/context_manager.rb', line 219

def generate_session_id(prefix: 'session')
  timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
  random = SecureRandom.hex(4)
  "#{prefix}_#{timestamp}_#{random}"
end

#get_context(session_id, name) ⇒ AnthropicSkillParser::SkillEntry?

Get a specific context

Parameters:

  • session_id (String)

    Session ID

  • name (String)

    Context name

Returns:



72
73
74
75
76
77
78
79
80
# File 'lib/kairos_mcp/context_manager.rb', line 72

def get_context(session_id, name)
  return nil unless segments?(session_id, name)

  context_dir = File.join(@context_dir, session_id, name)
  return nil unless contained?(context_dir)
  return nil unless File.directory?(context_dir)

  AnthropicSkillParser.parse(context_dir)
end

#list_assets(session_id, name) ⇒ Array<Hash>

List assets in a context

Parameters:

  • session_id (String)

    Session ID

  • name (String)

    Context name

Returns:

  • (Array<Hash>)

    List of asset info



242
243
244
245
246
247
# File 'lib/kairos_mcp/context_manager.rb', line 242

def list_assets(session_id, name)
  context = get_context(session_id, name)
  return [] unless context

  AnthropicSkillParser.list_assets(context)
end

#list_contexts_in_session(session_id) ⇒ Array<Hash>

List all contexts in a session

Parameters:

  • session_id (String)

    Session ID

Returns:

  • (Array<Hash>)

    List of context summaries



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/kairos_mcp/context_manager.rb', line 46

def list_contexts_in_session(session_id)
  return [] unless segments?(session_id)

  session_dir = File.join(@context_dir, session_id)
  return [] unless contained?(session_dir)
  return [] unless File.directory?(session_dir)

  context_dirs(session_dir).map do |dir|
    skill = AnthropicSkillParser.parse(dir)
    next unless skill

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

#list_scripts(session_id, name) ⇒ Array<Hash>

List scripts in a context

Parameters:

  • session_id (String)

    Session ID

  • name (String)

    Context name

Returns:

  • (Array<Hash>)

    List of script info



230
231
232
233
234
235
# File 'lib/kairos_mcp/context_manager.rb', line 230

def list_scripts(session_id, name)
  context = get_context(session_id, name)
  return [] unless context

  AnthropicSkillParser.list_scripts(context)
end

#list_sessionsArray<Hash>

List all active sessions

Returns:

  • (Array<Hash>)

    List of session info



29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/kairos_mcp/context_manager.rb', line 29

def list_sessions
  session_dirs.map do |dir|
    session_id = File.basename(dir)
    contexts = list_contexts_in_session(session_id)
    {
      session_id: session_id,
      context_count: contexts.size,
      created_at: File.ctime(dir),
      modified_at: File.mtime(dir)
    }
  end.sort_by { |s| s[:modified_at] }.reverse
end

#save_context(session_id, name, content, create_subdirs: false) ⇒ Hash

Save a context (create or update)

Parameters:

  • session_id (String)

    Session ID

  • name (String)

    Context name

  • content (String)

    Full content including YAML frontmatter

  • create_subdirs (Boolean) (defaults to: false)

    Whether to create scripts/assets/references

Returns:

  • (Hash)

    Result with success status



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
115
116
117
118
# File 'lib/kairos_mcp/context_manager.rb', line 89

def save_context(session_id, name, content, create_subdirs: false)
  validate_relations_in_content!(content)

  session_dir = File.join(@context_dir, session_id)
  context_dir = File.join(session_dir, name)

  # Checked before mkdir_p, so neither value can create a directory outside
  # the store. Both guards are needed: containment bounds the composed leaf,
  # and the segment check bounds session_dir and the second join the parser
  # performs on `name` — paths this method never composes itself.
  unless segments?(session_id, name) && contained?(context_dir)
    return { success: false, error: "Invalid session_id/name: '#{session_id}/#{name}' resolves outside the context directory" }
  end

  FileUtils.mkdir_p(session_dir)

  if File.directory?(context_dir)
    # Update existing
    skill = AnthropicSkillParser.update(context_dir, content)
    { success: true, action: 'updated', context: skill.to_h }
  else
    # Create new
    skill = AnthropicSkillParser.create(session_dir, name, content, create_subdirs: create_subdirs)
    { success: true, action: 'created', context: skill.to_h }
  end
rescue ContextGraph::Error => e
  { success: false, error: "#{e.class.name.split('::').last}: #{e.message}" }
rescue StandardError => e
  { success: false, error: e.message }
end

#validate_relations_in_content!(content) ⇒ Object

Parse the incoming content's frontmatter, and if it carries relations (Context Graph Phase 1), enforce the v2.1 §1.1 schema rules and path-containment guard. Pure validation — does not mutate content.

Raises:

  • ContextGraph::* on any violation



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/kairos_mcp/context_manager.rb', line 125

def validate_relations_in_content!(content)
  return unless content.is_a?(String)

  m = content.match(/\A---\r?\n(.+?)\r?\n---\r?\n/m)
  return unless m

  begin
    front = YAML.safe_load(m[1], permitted_classes: [Symbol, Date, Time]) || {}
  rescue StandardError => e
    raise ContextGraph::InvalidFrontmatterError, "frontmatter parse failed: #{e.message}"
  end

  relations = front['relations'] || front[:relations]
  return if relations.nil?

  ContextGraph.validate_relations!(relations)

  # For each target, run the path-containment guard. PathEscape and
  # SymlinkRejected are hard fails on the write path. ENOENT (dangling)
  # is allowed (forward references are part of L2-evidential ontology).
  relations.each do |item|
    target = item['target'] || item[:target]
    ContextGraph.resolve_target(target, @context_dir)
  end
end