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.
@@ -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
@@ -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
@@ -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