tensaku 0.0.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +20 -0
- data/README.md +196 -0
- data/app/assets/tensaku.js +85 -0
- data/lib/generators/tensaku/install_generator.rb +103 -0
- data/lib/tensaku/caches.rb +31 -0
- data/lib/tensaku/endpoints.rb +115 -0
- data/lib/tensaku/file_write.rb +33 -0
- data/lib/tensaku/guard.rb +77 -0
- data/lib/tensaku/i18n.rb +79 -0
- data/lib/tensaku/mcp.rb +135 -0
- data/lib/tensaku/middleware.rb +62 -0
- data/lib/tensaku/notes.rb +204 -0
- data/lib/tensaku/railtie.rb +36 -0
- data/lib/tensaku/source/html_text.rb +121 -0
- data/lib/tensaku/source/locale.rb +158 -0
- data/lib/tensaku/source/markdown.rb +78 -0
- data/lib/tensaku/source/template.rb +252 -0
- data/lib/tensaku/version.rb +3 -0
- data/lib/tensaku.rb +91 -0
- data/skills/tensaku/SKILL.md +61 -0
- metadata +126 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module Tensaku
|
|
5
|
+
# The JSON endpoints the overlay talks to, plus the overlay script and the MCP endpoint. Everything
|
|
6
|
+
# except the script needs a loopback address, and everything the overlay calls needs the boot token.
|
|
7
|
+
class Endpoints
|
|
8
|
+
ASSET = File.expand_path("../../app/assets/tensaku.js", __dir__)
|
|
9
|
+
|
|
10
|
+
def self.asset_digest
|
|
11
|
+
Digest::SHA256.file(ASSET).hexdigest[0, 12]
|
|
12
|
+
rescue Errno::ENOENT
|
|
13
|
+
"missing"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# What the overlay last selected, for the MCP `current_selection` tool. One person, one browser.
|
|
17
|
+
def self.selection
|
|
18
|
+
@selection
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.selection=(value)
|
|
22
|
+
@selection = value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(env)
|
|
26
|
+
request = Rack::Request.new(env)
|
|
27
|
+
path = request.path_info.delete_prefix(Tensaku.config.mount_path)
|
|
28
|
+
|
|
29
|
+
return script(request) if path == "/tensaku.js" && request.get?
|
|
30
|
+
|
|
31
|
+
if path == "/mcp"
|
|
32
|
+
Guard.check_local_client!(request)
|
|
33
|
+
return Mcp.new.call(request)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Guard.check_request!(request)
|
|
37
|
+
route(request, path)
|
|
38
|
+
rescue Forbidden => error
|
|
39
|
+
json({ error: error.message }, 403)
|
|
40
|
+
rescue StaleSource => error
|
|
41
|
+
json({ error: error.message, stale: true }, 409)
|
|
42
|
+
rescue Notes::NotFound => error
|
|
43
|
+
json({ error: error.message }, 404)
|
|
44
|
+
rescue Error, JSON::ParserError => error
|
|
45
|
+
json({ error: error.message }, 422)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def route(request, path)
|
|
51
|
+
params = request.get? ? request.GET : body(request)
|
|
52
|
+
|
|
53
|
+
case [ request.request_method, path ]
|
|
54
|
+
in [ "GET", "/source" ]
|
|
55
|
+
template, line, column = Source::Template.from_stamp(params["stamp"])
|
|
56
|
+
json(template.describe(line, column))
|
|
57
|
+
in [ "POST", "/text" ]
|
|
58
|
+
template, line, column = Source::Template.from_stamp(params["stamp"])
|
|
59
|
+
edits = Array(params["edits"]).map { it.to_h.symbolize_keys }
|
|
60
|
+
written(template.edit_text(line, column, edits), :template)
|
|
61
|
+
in [ "POST", "/attribute" ]
|
|
62
|
+
template, line, column = Source::Template.from_stamp(params["stamp"])
|
|
63
|
+
written(template.edit_attribute(line, column, params["name"].to_s, params.to_h.symbolize_keys), :template)
|
|
64
|
+
in [ "GET", "/translation" ]
|
|
65
|
+
json(key: params["key"], locales: Source::Locale.lookup_all(params["key"].to_s))
|
|
66
|
+
in [ "POST", "/translation" ]
|
|
67
|
+
result = Source::Locale.edit(params["locale"].to_s, params["key"].to_s, original: params["original"], text: params["text"])
|
|
68
|
+
written(result, :locale)
|
|
69
|
+
in [ "GET", "/markdown" ]
|
|
70
|
+
json(Source::Markdown.new(params["range"]).describe(tag: params["tag"]))
|
|
71
|
+
in [ "POST", "/markdown" ]
|
|
72
|
+
written(Source::Markdown.new(params["range"]).edit(original: params["original"], text: params["text"]), :markdown)
|
|
73
|
+
in [ "GET", "/notes" ]
|
|
74
|
+
Notes.prune!
|
|
75
|
+
json(notes: Notes.list(url: params["url"].presence, status: params["status"].presence), open: Notes.list(status: %w[open question]).size)
|
|
76
|
+
in [ "GET", "/notes.md" ]
|
|
77
|
+
notes = params["id"].present? ? [ Notes.find(params["id"]) ] : Notes.list(url: params["url"].presence, status: params["status"].presence || %w[open question])
|
|
78
|
+
[ 200, { "content-type" => "text/markdown; charset=utf-8" }, [ Notes.to_markdown(notes) ] ]
|
|
79
|
+
in [ "POST", "/notes" ]
|
|
80
|
+
json(Notes.create(params), 201)
|
|
81
|
+
in [ "PATCH", %r{\A/notes/([a-z0-9]+)\z} ]
|
|
82
|
+
id = path.split("/").last
|
|
83
|
+
json(Notes.update(id, status: params["status"], reply: params["reply"], comment: params["comment"]))
|
|
84
|
+
in [ "DELETE", %r{\A/notes/([a-z0-9]+)\z} ]
|
|
85
|
+
Notes.delete(path.split("/").last)
|
|
86
|
+
json({ deleted: true })
|
|
87
|
+
in [ "POST", "/selection" ]
|
|
88
|
+
self.class.selection = params.to_h.slice("url", "targets", "text").merge("at" => Time.now.utc.iso8601)
|
|
89
|
+
json({ ok: true })
|
|
90
|
+
else
|
|
91
|
+
json({ error: "Not found" }, 404)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def written(result, kind)
|
|
96
|
+
Caches.clear!(kind) if result[:changed]
|
|
97
|
+
json(result.merge(warning: Caches.fragment_cache_warning).compact)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def script(request)
|
|
101
|
+
raise Forbidden, "Tensaku only answers requests from this machine" unless Guard.loopback?(request)
|
|
102
|
+
|
|
103
|
+
[ 200, { "content-type" => "text/javascript; charset=utf-8", "cache-control" => "no-cache" }, [ File.read(ASSET) ] ]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def body(request)
|
|
107
|
+
raw = request.body&.read.to_s
|
|
108
|
+
raw.empty? ? {} : JSON.parse(raw)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def json(data, status = 200)
|
|
112
|
+
[ status, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [ JSON.generate(data) ] ]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Tensaku
|
|
2
|
+
module FileWrite
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
# Writes through a temp file in the same directory and renames it over the original, keeping the
|
|
6
|
+
# file mode, so a crash can't leave half a file behind.
|
|
7
|
+
def atomic(path, content)
|
|
8
|
+
path = Pathname(path)
|
|
9
|
+
temp = path.dirname.join(".#{path.basename}.tensaku-#{SecureRandom.hex(4)}")
|
|
10
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL, path.stat.mode) do |file|
|
|
11
|
+
file.binmode
|
|
12
|
+
file.write(content)
|
|
13
|
+
file.fsync
|
|
14
|
+
end
|
|
15
|
+
File.rename(temp, path)
|
|
16
|
+
ensure
|
|
17
|
+
File.unlink(temp) if temp && File.exist?(temp)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Replaces byte ranges in a string. Ranges must not overlap; they're applied back to front so the
|
|
21
|
+
# earlier offsets stay valid.
|
|
22
|
+
def splice(source, replacements)
|
|
23
|
+
result = source.b
|
|
24
|
+
replacements.sort_by { -it[:start] }.each_cons(2) do |later, earlier|
|
|
25
|
+
raise ArgumentError, "overlapping replacements" if earlier[:end] > later[:start]
|
|
26
|
+
end
|
|
27
|
+
replacements.sort_by { -it[:start] }.each do |replacement|
|
|
28
|
+
result[replacement[:start]...replacement[:end]] = replacement[:text].b
|
|
29
|
+
end
|
|
30
|
+
result.force_encoding(source.encoding)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
|
|
3
|
+
module Tensaku
|
|
4
|
+
# Every endpoint goes through here: loopback addresses only, the boot token for anything that reads
|
|
5
|
+
# source or writes, and writes limited to the allow-list.
|
|
6
|
+
module Guard
|
|
7
|
+
LOOPBACK = [ IPAddr.new("127.0.0.0/8"), IPAddr.new("::1/128") ].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def loopback?(request)
|
|
12
|
+
address = request.get_header("REMOTE_ADDR").to_s
|
|
13
|
+
LOOPBACK.any? { it.include?(IPAddr.new(address)) }
|
|
14
|
+
rescue IPAddr::InvalidAddressError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_request!(request)
|
|
19
|
+
raise Forbidden, "Tensaku only answers requests from this machine" unless loopback?(request)
|
|
20
|
+
|
|
21
|
+
token = request.get_header("HTTP_X_TENSAKU_TOKEN").to_s
|
|
22
|
+
raise Forbidden, "Missing or wrong Tensaku token. Reload the page." unless secure_compare(token, Tensaku.token)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# MCP clients can't read the page token, so the MCP endpoint relies on loopback plus a check that
|
|
26
|
+
# the request doesn't come from a web page (no foreign Origin, a local Host).
|
|
27
|
+
def check_local_client!(request)
|
|
28
|
+
raise Forbidden, "Tensaku only answers requests from this machine" unless loopback?(request)
|
|
29
|
+
|
|
30
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
31
|
+
raise Forbidden, "Cross-origin requests are refused" if origin && !local_url?(origin)
|
|
32
|
+
|
|
33
|
+
host = request.host.to_s
|
|
34
|
+
raise Forbidden, "Unexpected Host header" unless local_host?(host)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolves a path from the page (relative to Rails.root) to a real file inside the allow-list.
|
|
38
|
+
def writable_file!(relative, extensions:)
|
|
39
|
+
raise Forbidden, "No file given" if relative.to_s.empty?
|
|
40
|
+
|
|
41
|
+
path = Tensaku.root.join(relative.to_s)
|
|
42
|
+
raise Forbidden, "#{relative} does not exist" unless path.file?
|
|
43
|
+
|
|
44
|
+
real = path.realpath
|
|
45
|
+
inside = writable_dirs.any? { real.to_s.start_with?("#{it}/") }
|
|
46
|
+
raise Forbidden, "#{relative} is outside Tensaku's writable paths (#{Tensaku.config.writable_paths.join(", ")})" unless inside
|
|
47
|
+
|
|
48
|
+
unless extensions.any? { real.basename.to_s.end_with?(it) }
|
|
49
|
+
raise Forbidden, "#{relative} is not a #{extensions.join(" or ")} file"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
real
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The configured paths, plus Kiln's content directory when Kiln's content layer is loaded.
|
|
56
|
+
def writable_dirs
|
|
57
|
+
dirs = Tensaku.config.writable_paths.map { Tensaku.root.join(it) }
|
|
58
|
+
dirs << Pathname(Kiln::Content.root) if defined?(::Kiln::Content) && ::Kiln::Content.respond_to?(:root)
|
|
59
|
+
dirs.select(&:directory?).map(&:realpath)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def secure_compare(a, b)
|
|
63
|
+
a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def local_url?(url)
|
|
67
|
+
local_host?(URI.parse(url).host.to_s)
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def local_host?(host)
|
|
73
|
+
host = host.delete_prefix("[").delete_suffix("]")
|
|
74
|
+
host == "localhost" || host.end_with?(".localhost") || (IPAddr.new(host).loopback? rescue false)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/tensaku/i18n.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Tensaku
|
|
2
|
+
# In development, translated strings carry an invisible marker with their locale and key, so the
|
|
3
|
+
# overlay knows which key produced a text or an attribute (the i18n_proofreading technique). The
|
|
4
|
+
# overlay strips the markers when the page loads.
|
|
5
|
+
#
|
|
6
|
+
# A marker is U+2063 (invisible separator), then "locale:key" as UTF-8 bytes written in base 4 with
|
|
7
|
+
# four zero-width characters, then U+2063 again. Nothing shows if the overlay never runs.
|
|
8
|
+
#
|
|
9
|
+
# Markers are only added while a controller handles an HTML request, never to `number.*`, `date.*`,
|
|
10
|
+
# `time.*` and similar keys, never to a value that came from a `default:` instead of the key, and
|
|
11
|
+
# never to pluralized lookups. A Ruby comparison against a translated string sees the marker, which
|
|
12
|
+
# is the known cost of this technique (development only).
|
|
13
|
+
module I18nMarkers
|
|
14
|
+
FENCE = ""
|
|
15
|
+
DIGITS = [ "", "", "", "" ].freeze
|
|
16
|
+
PATTERN = /#{FENCE}[#{DIGITS.join}]+#{FENCE}/o
|
|
17
|
+
SKIP = /\A(?:number|date|time|datetime|support|i18n|helpers\.submit|errors\.format)(?:\.|\z)/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def active?
|
|
22
|
+
Thread.current[:tensaku_i18n_markers] == true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_markers
|
|
26
|
+
previous = Thread.current[:tensaku_i18n_markers]
|
|
27
|
+
Thread.current[:tensaku_i18n_markers] = true
|
|
28
|
+
yield
|
|
29
|
+
ensure
|
|
30
|
+
Thread.current[:tensaku_i18n_markers] = previous
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def encode(locale, key)
|
|
34
|
+
digits = "#{locale}:#{key}".bytes.flat_map { |byte| 3.downto(0).map { DIGITS[(byte >> (it * 2)) & 3] } }
|
|
35
|
+
"#{FENCE}#{digits.join}#{FENCE}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decode(marker)
|
|
39
|
+
digits = marker.delete(FENCE).chars.map { DIGITS.index(it) }
|
|
40
|
+
digits.each_slice(4).map { |a, b, c, d| (a << 6) | (b << 4) | (c << 2) | d }.pack("C*").force_encoding(Encoding::UTF_8)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def strip(text)
|
|
44
|
+
text.include?(FENCE) ? text.gsub(PATTERN, "") : text
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Added to the app's I18n backend with `extend`, so it wraps whichever backend the app uses.
|
|
48
|
+
module Backend
|
|
49
|
+
def translate(locale, key, options = ::I18n::EMPTY_HASH)
|
|
50
|
+
result = super
|
|
51
|
+
return result unless I18nMarkers.active? && result.is_a?(String) && !key.nil?
|
|
52
|
+
|
|
53
|
+
full = ::I18n.normalize_keys(nil, key, options[:scope], options[:separator]).join(".")
|
|
54
|
+
return result if full.match?(SKIP)
|
|
55
|
+
return result unless lookup(locale, key, options[:scope], options).is_a?(String)
|
|
56
|
+
|
|
57
|
+
marked = result + I18nMarkers.encode(locale, full)
|
|
58
|
+
result.html_safe? ? marked.html_safe : marked
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Wraps controller actions for HTML requests.
|
|
63
|
+
module Controller
|
|
64
|
+
extend ActiveSupport::Concern
|
|
65
|
+
|
|
66
|
+
included do
|
|
67
|
+
prepend_around_action :tensaku_i18n_markers
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def tensaku_i18n_markers(&)
|
|
73
|
+
return yield unless request.format&.html?
|
|
74
|
+
|
|
75
|
+
I18nMarkers.with_markers(&)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/tensaku/mcp.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Tensaku
|
|
4
|
+
# A small MCP server (Streamable HTTP transport, JSON responses, no sessions) on the dev server, so
|
|
5
|
+
# an agent can read and resolve notes. Configure it in .mcp.json as an http server at
|
|
6
|
+
# http://localhost:3000/__tensaku/mcp.
|
|
7
|
+
class Mcp
|
|
8
|
+
PROTOCOL_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
|
|
9
|
+
MAX_WATCH = 300
|
|
10
|
+
|
|
11
|
+
TOOLS = [
|
|
12
|
+
{
|
|
13
|
+
name: "list_notes",
|
|
14
|
+
description: "List Tensaku notes left on pages of this Rails app. Each note has a comment and targets with the exact file and line range that rendered the element. Defaults to notes that need work (open and question).",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
status: { type: "string", enum: %w[open question resolved active all], description: "active (default) means open or question." },
|
|
19
|
+
url: { type: "string", description: "Only notes left on this path, for example /about." }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "get_note",
|
|
25
|
+
description: "Get one Tensaku note by id.",
|
|
26
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: [ "id" ] }
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "resolve_note",
|
|
30
|
+
description: "Mark a note resolved after making the change. The reply is shown on the page next to a green pin, so say briefly what changed and where (for example: changed the heading in app/views/pages/_hero.html.erb:4).",
|
|
31
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, reply: { type: "string" } }, required: %w[id reply] }
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "ask_question",
|
|
35
|
+
description: "Ask the person who wrote a note for clarification instead of guessing. The question shows on the page; the note's status becomes question until they answer by editing the note.",
|
|
36
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, question: { type: "string" } }, required: %w[id question] }
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "watch_notes",
|
|
40
|
+
description: "Wait for new or changed open notes, so you can work through notes as they are added. Returns as soon as there are any, or empty after the timeout. Pass the checked_at value of the previous call as since so nothing is missed.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
since: { type: "string", description: "ISO 8601 time. Defaults to now." },
|
|
45
|
+
timeout_seconds: { type: "integer", minimum: 1, maximum: MAX_WATCH, description: "Defaults to 60." }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "current_selection",
|
|
51
|
+
description: "What the person last selected in the Tensaku overlay (elements with file and line), for questions like 'what is this?'.",
|
|
52
|
+
inputSchema: { type: "object", properties: {} }
|
|
53
|
+
}
|
|
54
|
+
].freeze
|
|
55
|
+
|
|
56
|
+
def call(request)
|
|
57
|
+
return [ 405, { "allow" => "POST" }, [ "" ] ] unless request.post?
|
|
58
|
+
|
|
59
|
+
message = JSON.parse(request.body.read.to_s)
|
|
60
|
+
responses = Array.wrap(message).filter_map { respond(it) }
|
|
61
|
+
return [ 202, {}, [ "" ] ] if responses.empty?
|
|
62
|
+
|
|
63
|
+
payload = message.is_a?(Array) ? responses : responses.first
|
|
64
|
+
[ 200, { "content-type" => "application/json" }, [ JSON.generate(payload) ] ]
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
[ 400, { "content-type" => "application/json" }, [ JSON.generate(error_payload(nil, -32700, "Parse error")) ] ]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def respond(message)
|
|
72
|
+
id = message["id"]
|
|
73
|
+
return nil if id.nil? # a notification
|
|
74
|
+
|
|
75
|
+
result = case message["method"]
|
|
76
|
+
when "initialize" then initialize_result(message.dig("params", "protocolVersion"))
|
|
77
|
+
when "ping" then {}
|
|
78
|
+
when "tools/list" then { tools: TOOLS }
|
|
79
|
+
when "tools/call" then call_tool(message.dig("params", "name"), message.dig("params", "arguments") || {})
|
|
80
|
+
else return error_payload(id, -32601, "Method not found: #{message["method"]}")
|
|
81
|
+
end
|
|
82
|
+
{ jsonrpc: "2.0", id:, result: }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize_result(requested)
|
|
86
|
+
{
|
|
87
|
+
protocolVersion: PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSIONS.first,
|
|
88
|
+
capabilities: { tools: { listChanged: false } },
|
|
89
|
+
serverInfo: { name: "tensaku", version: Tensaku::VERSION },
|
|
90
|
+
instructions: "Tensaku notes are comments a person left on pages of this Rails app, each with the file and line that rendered the element. Act on open notes, then resolve each with a short reply, or ask a question when a note is unclear."
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def call_tool(name, arguments)
|
|
95
|
+
case name
|
|
96
|
+
when "list_notes"
|
|
97
|
+
status = arguments["status"].presence || "active"
|
|
98
|
+
statuses = { "active" => %w[open question], "all" => nil }.fetch(status, status)
|
|
99
|
+
notes = Notes.list(url: arguments["url"].presence, status: statuses)
|
|
100
|
+
tool_result(Notes.to_markdown(notes), notes:, checked_at: Time.now.utc.iso8601)
|
|
101
|
+
when "get_note"
|
|
102
|
+
note = Notes.find(arguments["id"])
|
|
103
|
+
tool_result(Notes.note_markdown(note), note:)
|
|
104
|
+
when "resolve_note"
|
|
105
|
+
note = Notes.update(arguments["id"], status: "resolved", reply: arguments["reply"])
|
|
106
|
+
tool_result("Resolved note #{note["id"]}.", note:)
|
|
107
|
+
when "ask_question"
|
|
108
|
+
note = Notes.update(arguments["id"], status: "question", reply: arguments["question"])
|
|
109
|
+
tool_result("Asked on note #{note["id"]}. It shows on the page until the person answers.", note:)
|
|
110
|
+
when "watch_notes"
|
|
111
|
+
since = arguments["since"].present? ? Time.iso8601(arguments["since"]) : Time.now.utc
|
|
112
|
+
timeout = arguments["timeout_seconds"].to_i.clamp(1, MAX_WATCH)
|
|
113
|
+
timeout = 60 if arguments["timeout_seconds"].nil?
|
|
114
|
+
notes = Notes.watch(since:, timeout:)
|
|
115
|
+
text = notes.empty? ? "No new notes in #{timeout}s. Call watch_notes again to keep waiting." : Notes.to_markdown(notes)
|
|
116
|
+
tool_result(text, notes:, checked_at: Time.now.utc.iso8601)
|
|
117
|
+
when "current_selection"
|
|
118
|
+
selection = Endpoints.selection
|
|
119
|
+
tool_result(selection ? JSON.pretty_generate(selection) : "Nothing selected in the Tensaku overlay yet.", selection:)
|
|
120
|
+
else
|
|
121
|
+
tool_result("Unknown tool #{name}", error: true)
|
|
122
|
+
end
|
|
123
|
+
rescue Notes::NotFound, Error, ArgumentError => error
|
|
124
|
+
tool_result(error.message, error: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def tool_result(text, error: false, **structured)
|
|
128
|
+
{ content: [ { type: "text", text: } ], structuredContent: structured.presence, isError: error }.compact
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def error_payload(id, code, message)
|
|
132
|
+
{ jsonrpc: "2.0", id:, error: { code:, message: } }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Tensaku
|
|
2
|
+
# Development only. Serves the overlay and the endpoints under Tensaku.config.mount_path, and adds
|
|
3
|
+
# the overlay to HTML pages: a meta tag with the request method and the boot token in <head>, and the
|
|
4
|
+
# script before </body>.
|
|
5
|
+
class Middleware
|
|
6
|
+
HTML = %r{\Atext/html}
|
|
7
|
+
STRIP_TYPES = %r{\A(?:text/|application/(?:json|xml|javascript|ld\+json))}
|
|
8
|
+
SCRIPT_OR_STYLE = %r{(<(script|style)\b[^>]*>)(.*?)(</\2>)}im
|
|
9
|
+
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
return Endpoints.new.call(env) if env["PATH_INFO"].to_s.start_with?("#{Tensaku.config.mount_path}/")
|
|
16
|
+
|
|
17
|
+
status, headers, body = @app.call(env)
|
|
18
|
+
type = headers["content-type"].to_s
|
|
19
|
+
return [ status, headers, body ] if status == 204 || status == 304 || headers["content-disposition"].to_s.start_with?("attachment")
|
|
20
|
+
|
|
21
|
+
if type.match?(HTML) && !headers["content-encoding"]
|
|
22
|
+
rewrite(status, headers, body) { inject(it, env) }
|
|
23
|
+
elsif type.match?(STRIP_TYPES) && !headers["content-encoding"] && !type.include?("event-stream")
|
|
24
|
+
rewrite(status, headers, body) { I18nMarkers.strip(it) }
|
|
25
|
+
else
|
|
26
|
+
[ status, headers, body ]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def rewrite(status, headers, body)
|
|
33
|
+
html = +""
|
|
34
|
+
body.each { html << it }
|
|
35
|
+
body.close if body.respond_to?(:close)
|
|
36
|
+
html = yield(html.force_encoding(Encoding::UTF_8))
|
|
37
|
+
headers = headers.dup
|
|
38
|
+
headers.delete("content-length")
|
|
39
|
+
headers.delete("etag")
|
|
40
|
+
[ status, headers, [ html ] ]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inject(html, env)
|
|
44
|
+
# Markers inside scripts and styles would end up in code; the overlay can't reach those.
|
|
45
|
+
html = html.gsub(SCRIPT_OR_STYLE) { "#{$1}#{I18nMarkers.strip($3)}#{$4}" } if html.include?(I18nMarkers::FENCE)
|
|
46
|
+
return html if html.include?("data-tensaku-request=")
|
|
47
|
+
|
|
48
|
+
meta = %(<meta name="tensaku" data-tensaku-request="#{ERB::Util.h(env["REQUEST_METHOD"])}" data-tensaku-token="#{Tensaku.token}" data-tensaku-root="#{ERB::Util.h(Tensaku.config.mount_path)}" data-tensaku-shortcut="#{ERB::Util.h(Tensaku.config.shortcut)}">)
|
|
49
|
+
script = %(<script src="#{Tensaku.config.mount_path}/tensaku.js?v=#{Endpoints.asset_digest}" data-turbo-eval="false" data-tensaku-ui defer></script>)
|
|
50
|
+
|
|
51
|
+
html = insert_before(html, "</head>", meta) || "#{meta}#{html}"
|
|
52
|
+
insert_before(html, "</body>", script) || "#{html}#{script}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def insert_before(html, tag, snippet)
|
|
56
|
+
index = html.rindex(/#{Regexp.escape(tag)}/i)
|
|
57
|
+
return nil unless index
|
|
58
|
+
|
|
59
|
+
html.dup.insert(index, snippet)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|