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 +4 -4
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/document_authoring/config/document_authoring.yml +22 -0
- data/templates/skillsets/document_authoring/knowledge/document_authoring_guide/document_authoring_guide.md +64 -0
- data/templates/skillsets/document_authoring/lib/document_authoring/context_assembler.rb +91 -0
- data/templates/skillsets/document_authoring/lib/document_authoring/path_validator.rb +118 -0
- data/templates/skillsets/document_authoring/lib/document_authoring/section_writer.rb +94 -0
- data/templates/skillsets/document_authoring/lib/document_authoring.rb +12 -0
- data/templates/skillsets/document_authoring/skillset.json +19 -0
- data/templates/skillsets/document_authoring/test/test_document_authoring.rb +734 -0
- data/templates/skillsets/document_authoring/tools/document_status.rb +136 -0
- data/templates/skillsets/document_authoring/tools/write_section.rb +203 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02a83d13eab2a28deb385acb5e3e57f62204923c2efe4f2768fcf063bca5def2
|
|
4
|
+
data.tar.gz: fcc519fed062416470e34d9fa95f1760b92ba61d8fb76b1f568a056e244f8c85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd5586084ac9d3b6faa446bd7471b9c812dfe6e506d52c6f00471100e8554ce1a7734001d9a0fc8ce715795bf82a606b3592d87d02812d5aeafa0db7ecd64325
|
|
7
|
+
data.tar.gz: fb0cc839b19d882bc89c1bcb1b3f95a9737f3d5000eeaf12a0fc6ed2927426d6d7b6d6084d5871a87a6ab362f5b533475d7e94cd13d473b6fb8982b91d82d647
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
+
}
|