kairos-chain 3.6.0 → 3.6.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e63245a9c8dd3d8e79b83ad1eba7697cbf2075832b1afbae7080c4b1dea0e0de
4
- data.tar.gz: 56ec7236649c644bd73fccbe35989640ac169831e8300531821c46ff01805475
3
+ metadata.gz: 02a83d13eab2a28deb385acb5e3e57f62204923c2efe4f2768fcf063bca5def2
4
+ data.tar.gz: fcc519fed062416470e34d9fa95f1760b92ba61d8fb76b1f568a056e244f8c85
5
5
  SHA512:
6
- metadata.gz: cb2ddc02ed24e10dcd76e6cf6f8149588b135011b8e777c52324961fc06ecb44a6810c1b97cdedda66bbcc8bcca484400a7f6a245dfbe7c1b831b7b2b0b04003
7
- data.tar.gz: 455843011cef593d7ff7693d66f22be19dad14f70fe5b26cccc131cae903362089e35367e4410ba91138a9822b9b8adb6823f7ca98420bb6d9811e8f5233f106
6
+ metadata.gz: fd5586084ac9d3b6faa446bd7471b9c812dfe6e506d52c6f00471100e8554ce1a7734001d9a0fc8ce715795bf82a606b3592d87d02812d5aeafa0db7ecd64325
7
+ data.tar.gz: fb0cc839b19d882bc89c1bcb1b3f95a9737f3d5000eeaf12a0fc6ed2927426d6d7b6d6084d5871a87a6ab362f5b533475d7e94cd13d473b6fb8982b91d82d647
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.6.0"
2
+ VERSION = "3.6.1"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -0,0 +1,22 @@
1
+ # Document Authoring SkillSet configuration
2
+ llm_model: null # uses llm_client default
3
+
4
+ # Output settings
5
+ output_base_dir: null # null = workspace root (@safety.safe_root)
6
+
7
+ # Per-section limits
8
+ max_words_default: 500
9
+
10
+ # Context assembly
11
+ max_context_chars: 4000 # per source, truncated at paragraph boundary
12
+ max_total_context_chars: 16000 # total context budget
13
+ max_context_sources: 10 # max number of context URIs
14
+
15
+ # File safety
16
+ allowed_output_extensions:
17
+ - ".md"
18
+ - ".txt"
19
+ max_output_file_size_bytes: 1048576 # 1MB — refuse to overwrite larger files
20
+
21
+ # document_status limits
22
+ max_status_files: 50 # max files to scan
@@ -0,0 +1,64 @@
1
+ ---
2
+ tags: [document, authoring, grant, writing, agent, ooda]
3
+ version: "1.0"
4
+ ---
5
+
6
+ # Document Authoring Guide
7
+
8
+ ## Overview
9
+
10
+ The `document_authoring` SkillSet provides LLM-driven document section generation
11
+ with L1/L2 context injection. It integrates with the Agent SkillSet's OODA loop
12
+ via autoexec's `internal_execute` dispatcher — no Agent or autoexec changes required.
13
+
14
+ ## Tools
15
+
16
+ ### write_section
17
+
18
+ Generates a document section using an LLM, with optional context from L1 knowledge
19
+ and L2 session contexts.
20
+
21
+ ```json
22
+ {
23
+ "section_name": "research_significance",
24
+ "instructions": "Explain why genomic data ownership matters for open science",
25
+ "context_sources": ["knowledge://genomicschain_design"],
26
+ "output_file": "grant_draft/02_significance.md",
27
+ "max_words": 500,
28
+ "language": "en"
29
+ }
30
+ ```
31
+
32
+ ### document_status
33
+
34
+ Lists existing draft files with word counts. Non-recursive directory scan.
35
+
36
+ ```json
37
+ {
38
+ "output_dir": "grant_draft/"
39
+ }
40
+ ```
41
+
42
+ ## Agent Integration
43
+
44
+ When used with the Agent SkillSet, the workflow is:
45
+
46
+ 1. Create L1 knowledge with the document goal (e.g., grant requirements)
47
+ 2. Start Agent session: `agent_start(goal_name: "grant_application_uzh")`
48
+ 3. Agent ORIENT identifies required sections from the goal
49
+ 4. Agent DECIDE generates task steps with `tool_name: "write_section"`
50
+ 5. Human approves the plan at [proposed] checkpoint
51
+ 6. Agent ACT executes write_section for each section via autoexec
52
+ 7. Agent REFLECT evaluates completeness
53
+
54
+ ## Context Sources
55
+
56
+ Use the platform URI scheme:
57
+
58
+ - `knowledge://genomicschain_design` — L1 knowledge
59
+ - `context://session_id/context_name` — L2 session context
60
+
61
+ ## Output
62
+
63
+ Generated text is written directly to the specified file (no JSON wrapper).
64
+ Files are created under the workspace root with symlink-safe path validation.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KairosMcp
4
+ module SkillSets
5
+ module DocumentAuthoring
6
+ # Retrieves L1/L2 context via resource_read and assembles into prompt text.
7
+ # Uses the platform URI scheme (knowledge://, context://).
8
+ class ContextAssembler
9
+ # @param caller_tool [BaseTool] tool instance with invoke_tool access
10
+ # @param max_chars_per_source [Integer] max characters per source
11
+ # @param max_total_chars [Integer] total context budget
12
+ # @param max_sources [Integer] max number of sources
13
+ def initialize(caller_tool, max_chars_per_source: 4000,
14
+ max_total_chars: 16_000, max_sources: 10)
15
+ @caller = caller_tool
16
+ @max_chars_per_source = max_chars_per_source
17
+ @max_total_chars = max_total_chars
18
+ @max_sources = max_sources
19
+ end
20
+
21
+ # Assemble context from resource URIs.
22
+ # @param sources [Array<String>] resource URIs (knowledge://, context://)
23
+ # @param context [Object, nil] InvocationContext for policy inheritance
24
+ # @return [Hash] { text: String, loaded: Integer, failed: Integer, warnings: Array<String> }
25
+ def assemble(sources, context: nil)
26
+ return { text: '', loaded: 0, failed: 0, warnings: [] } if sources.nil? || sources.empty?
27
+
28
+ warnings = []
29
+ texts = []
30
+ loaded = 0
31
+ failed = 0
32
+ total_chars = 0
33
+
34
+ if sources.size > @max_sources
35
+ warnings << "Truncated to #{@max_sources} sources (#{sources.size} provided)"
36
+ end
37
+
38
+ sources.first(@max_sources).each do |uri|
39
+ unless uri.match?(%r{\A(knowledge|context)://})
40
+ warnings << "Unknown URI scheme, skipped: #{uri}"
41
+ failed += 1
42
+ next
43
+ end
44
+
45
+ begin
46
+ result = @caller.invoke_tool('resource_read', { 'uri' => uri }, context: context)
47
+ text = extract_text(result)
48
+
49
+ if text.nil? || text.strip.empty?
50
+ warnings << "Empty content from: #{uri}"
51
+ failed += 1
52
+ next
53
+ end
54
+
55
+ truncated = truncate_at_paragraph(text, @max_chars_per_source)
56
+ remaining = @max_total_chars - total_chars
57
+ truncated = truncated[0...remaining] if truncated.length > remaining
58
+
59
+ texts << "### Source: #{uri}\n#{truncated}"
60
+ total_chars += truncated.length
61
+ loaded += 1
62
+ rescue StandardError => e
63
+ warnings << "Failed to load #{uri}: #{e.message}"
64
+ failed += 1
65
+ end
66
+
67
+ break if total_chars >= @max_total_chars
68
+ end
69
+
70
+ { text: texts.join("\n\n"), loaded: loaded, failed: failed, warnings: warnings }
71
+ end
72
+
73
+ private
74
+
75
+ def extract_text(result)
76
+ return '' unless result.is_a?(Array)
77
+
78
+ result.map { |b| b[:text] || b['text'] }.compact.join("\n")
79
+ end
80
+
81
+ def truncate_at_paragraph(text, max_chars)
82
+ return text if text.length <= max_chars
83
+
84
+ # Find last paragraph break (double newline) before max_chars
85
+ cut = text.rindex("\n\n", max_chars)
86
+ cut && cut > 0 ? text[0..cut] : text[0...max_chars]
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+
6
+ module KairosMcp
7
+ module SkillSets
8
+ module DocumentAuthoring
9
+ # Symlink-safe path validation for file writes.
10
+ # Uses File.realpath to resolve symlinks at every ancestor.
11
+ class PathValidator
12
+ ALLOWED_EXTENSIONS = %w[.md .txt].freeze
13
+
14
+ # Validate a relative output file path (symlink-safe).
15
+ # @param relative_path [String] user-provided relative path
16
+ # @param base_dir [String] workspace root (from @safety.safe_root)
17
+ # @param allowed_extensions [Array<String>] permitted file extensions
18
+ # @param max_file_size [Integer, nil] max existing file size to overwrite
19
+ # @return [String] validated absolute path
20
+ # @raise [ArgumentError] on invalid path
21
+ def self.validate!(relative_path, base_dir,
22
+ allowed_extensions: ALLOWED_EXTENSIONS,
23
+ max_file_size: nil)
24
+ raise ArgumentError, "Empty path" if relative_path.nil? || relative_path.strip.empty?
25
+ raise ArgumentError, "Absolute paths not allowed: #{relative_path}" if relative_path.start_with?('/')
26
+
27
+ # Extension whitelist
28
+ ext = File.extname(relative_path).downcase
29
+ unless allowed_extensions.include?(ext)
30
+ raise ArgumentError, "Extension not allowed: #{ext}. Allowed: #{allowed_extensions.join(', ')}"
31
+ end
32
+
33
+ # Resolve base_dir to its real path (handles symlinked workspace roots)
34
+ base_real = File.realpath(base_dir)
35
+
36
+ # Expand relative_path against the real base (not the possibly-symlinked base_dir)
37
+ expanded = File.expand_path(relative_path, base_real)
38
+
39
+ # Containment check on expanded path
40
+ unless expanded.start_with?(base_real + '/')
41
+ raise ArgumentError, "Path escapes workspace: #{relative_path}"
42
+ end
43
+
44
+ # Incremental mkdir with per-component symlink check
45
+ parent = File.dirname(expanded)
46
+ safe_mkdir_p(parent, base_real)
47
+
48
+ # Check if target itself is a symlink
49
+ if File.symlink?(expanded)
50
+ raise ArgumentError, "Target is a symlink: #{relative_path}"
51
+ end
52
+
53
+ # File size guard
54
+ if max_file_size && File.exist?(expanded) && File.size(expanded) > max_file_size
55
+ raise ArgumentError, "Existing file too large (#{File.size(expanded)} bytes > #{max_file_size})"
56
+ end
57
+
58
+ expanded
59
+ end
60
+
61
+ # Validate a directory path for document_status (read-only).
62
+ # @return [String] validated absolute directory path
63
+ # @raise [ArgumentError] on invalid path
64
+ def self.validate_dir!(relative_path, base_dir)
65
+ raise ArgumentError, "Empty directory path" if relative_path.nil? || relative_path.strip.empty?
66
+ raise ArgumentError, "Absolute paths not allowed: #{relative_path}" if relative_path.start_with?('/')
67
+
68
+ base_real = File.realpath(base_dir)
69
+ expanded = File.expand_path(relative_path, base_real)
70
+
71
+ unless expanded.start_with?(base_real + '/') || expanded == base_real
72
+ raise ArgumentError, "Path escapes workspace: #{relative_path}"
73
+ end
74
+
75
+ # If directory exists, resolve via realpath and re-check containment
76
+ if File.exist?(expanded)
77
+ real = File.realpath(expanded)
78
+ unless real.start_with?(base_real + '/') || real == base_real
79
+ raise ArgumentError, "Symlink escape detected: #{relative_path} resolves to #{real}"
80
+ end
81
+ return real
82
+ end
83
+
84
+ expanded
85
+ end
86
+
87
+ # Incrementally create directories, checking each component for symlinks.
88
+ # @param target_dir [String] absolute target directory
89
+ # @param base_real [String] realpath of workspace root
90
+ def self.safe_mkdir_p(target_dir, base_real)
91
+ # Decompose the path relative to base_real
92
+ rel = Pathname.new(target_dir).relative_path_from(Pathname.new(base_real))
93
+ parts = rel.each_filename.to_a
94
+
95
+ current = base_real
96
+ parts.each do |component|
97
+ current = File.join(current, component)
98
+
99
+ if File.symlink?(current)
100
+ raise ArgumentError, "Symlink in path: #{current}"
101
+ end
102
+
103
+ if File.exist?(current)
104
+ real = File.realpath(current)
105
+ unless real.start_with?(base_real + '/') || real == base_real
106
+ raise ArgumentError, "Path component escapes workspace: #{current} -> #{real}"
107
+ end
108
+ else
109
+ Dir.mkdir(current)
110
+ end
111
+ end
112
+ end
113
+
114
+ private_class_method :safe_mkdir_p
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module KairosMcp
6
+ module SkillSets
7
+ module DocumentAuthoring
8
+ # Core write logic: builds LLM prompt, calls llm_call, writes output file.
9
+ class SectionWriter
10
+ def initialize(caller_tool, config)
11
+ @caller = caller_tool
12
+ @config = config
13
+ end
14
+
15
+ # Write a document section via LLM.
16
+ # @return [Hash] result with 'status'/'error', 'word_count', etc.
17
+ def write(section_name:, instructions:, context_text:, output_file:,
18
+ max_words: 500, language: 'en', append_mode: false,
19
+ invocation_context: nil)
20
+ messages = [{
21
+ 'role' => 'user',
22
+ 'content' => build_user_prompt(section_name, instructions, context_text, max_words, language)
23
+ }]
24
+
25
+ llm_args = {
26
+ 'messages' => messages,
27
+ 'system' => system_prompt
28
+ }
29
+
30
+ # Forward InvocationContext via dispatch-level context: keyword only
31
+ # (not duplicated in arguments — llm_call uses dispatch context)
32
+ result = @caller.invoke_tool('llm_call', llm_args, context: invocation_context)
33
+
34
+ # Parse pinned response contract:
35
+ # { "status": "ok", "response": { "content": "..." }, "snapshot": {...} }
36
+ # { "status": "error", "error": { "type": "...", "message": "..." } }
37
+ raw = result.map { |b| b[:text] || b['text'] }.compact.join
38
+ parsed = JSON.parse(raw)
39
+
40
+ if parsed['status'] == 'error'
41
+ error = parsed['error'] || {}
42
+ error_msg = if error.is_a?(Hash)
43
+ "#{error['type']}: #{error['message']}"
44
+ else
45
+ error.to_s
46
+ end
47
+ return { 'error' => "LLM call failed: #{error_msg}" }
48
+ end
49
+
50
+ generated_text = parsed.dig('response', 'content')
51
+ if generated_text.nil? || generated_text.strip.empty?
52
+ return { 'error' => 'LLM returned empty content' }
53
+ end
54
+
55
+ # Write to file
56
+ if append_mode
57
+ File.open(output_file, 'a') { |f| f.write("\n\n#{generated_text}") }
58
+ else
59
+ File.write(output_file, generated_text)
60
+ end
61
+
62
+ {
63
+ 'status' => 'ok',
64
+ 'section_name' => section_name,
65
+ 'output_file' => output_file,
66
+ 'word_count' => generated_text.split.size
67
+ }
68
+ rescue JSON::ParserError => e
69
+ { 'error' => "Failed to parse LLM response: #{e.message}" }
70
+ end
71
+
72
+ private
73
+
74
+ def system_prompt
75
+ "You are a professional document writer. Write the requested section " \
76
+ "following the instructions precisely. Use the provided context for accuracy. " \
77
+ "Output ONLY the section content. You may use markdown formatting within the section."
78
+ end
79
+
80
+ def build_user_prompt(section_name, instructions, context_text, max_words, language)
81
+ parts = [
82
+ "## Section: #{section_name}",
83
+ "## Instructions\n#{instructions}",
84
+ "## Word limit: approximately #{max_words} words",
85
+ "## Language: #{language}"
86
+ ]
87
+ parts << "## Reference Context\n#{context_text}" if context_text && !context_text.empty?
88
+ parts << "\nWrite the section now."
89
+ parts.join("\n\n")
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'document_authoring/path_validator'
4
+ require_relative 'document_authoring/context_assembler'
5
+ require_relative 'document_authoring/section_writer'
6
+
7
+ module KairosMcp
8
+ module SkillSets
9
+ module DocumentAuthoring
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "document_authoring",
3
+ "version": "0.1.0",
4
+ "description": "LLM-driven document section generation with L1/L2 context injection. Integrates with Agent OODA via autoexec internal_execute.",
5
+ "author": "Masaomi Hatakeyama",
6
+ "layer": "L1",
7
+ "depends_on": ["llm_client"],
8
+ "provides": [
9
+ "document_authoring",
10
+ "section_writing"
11
+ ],
12
+ "tool_classes": [
13
+ "KairosMcp::SkillSets::DocumentAuthoring::Tools::WriteSection",
14
+ "KairosMcp::SkillSets::DocumentAuthoring::Tools::DocumentStatus"
15
+ ],
16
+ "config_files": ["config/document_authoring.yml"],
17
+ "knowledge_dirs": ["knowledge/document_authoring_guide"],
18
+ "min_core_version": "2.8.0"
19
+ }