openclacky 0.9.7 → 0.9.9
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/.clacky/skills/gem-release/SKILL.md +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- data/scripts/install_agent_browser.sh +0 -67
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: pdf-reader
|
|
3
|
-
description: 'Read and analyze PDF files. Use this skill when the user has attached a PDF or mentions a PDF file path and wants to understand, summarize, extract, or ask questions about its content. Trigger on: "read this PDF", "analyze the PDF", "what does this PDF say", "what is in this file", "里面有什么", "帮我看看这个PDF", "总结一下", "这份文件说了什么" — or when a message contains a PDF attachment reference even without an explicit question. Also trigger when the user asks vague questions like "what is this?", "summarize", "tell me about this" if a PDF is attached.'
|
|
4
|
-
disable-model-invocation: false
|
|
5
|
-
user-invocable: true
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# PDF Reading Skill
|
|
9
|
-
|
|
10
|
-
## Your Goal
|
|
11
|
-
Extract text content from the PDF file and answer the user's question based on that content. If the user's question is vague or absent, default to providing a clear structured summary of the document.
|
|
12
|
-
|
|
13
|
-
## Step 1 — Extract text from the PDF
|
|
14
|
-
|
|
15
|
-
Use `pdftotext` (preferred, fastest) or Python `pdfplumber` as fallback.
|
|
16
|
-
|
|
17
|
-
### Option A: pdftotext (use this first)
|
|
18
|
-
```bash
|
|
19
|
-
pdftotext -layout -enc UTF-8 "/path/to/file.pdf" -
|
|
20
|
-
```
|
|
21
|
-
- `-enc UTF-8` ensures correct encoding for Chinese, Japanese, and other non-Latin text
|
|
22
|
-
- `-layout` preserves column layout for tables
|
|
23
|
-
- The `-` at the end prints to stdout (no temp file needed)
|
|
24
|
-
|
|
25
|
-
**Install if missing:**
|
|
26
|
-
- macOS: `brew install poppler`
|
|
27
|
-
- Ubuntu/Debian: `apt install poppler-utils`
|
|
28
|
-
- CentOS/Fedora: `yum install poppler-utils`
|
|
29
|
-
|
|
30
|
-
### Option B: Python pdfplumber (fallback if pdftotext not available)
|
|
31
|
-
```python
|
|
32
|
-
import pdfplumber
|
|
33
|
-
|
|
34
|
-
with pdfplumber.open("/path/to/file.pdf") as pdf:
|
|
35
|
-
for i, page in enumerate(pdf.pages, 1):
|
|
36
|
-
text = page.extract_text()
|
|
37
|
-
if text:
|
|
38
|
-
print(f"--- Page {i} ---")
|
|
39
|
-
print(text)
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### Option C: pypdf (last resort)
|
|
43
|
-
```python
|
|
44
|
-
from pypdf import PdfReader
|
|
45
|
-
|
|
46
|
-
reader = PdfReader("/path/to/file.pdf")
|
|
47
|
-
for i, page in enumerate(reader.pages, 1):
|
|
48
|
-
print(f"--- Page {i} ---")
|
|
49
|
-
print(page.extract_text())
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## Step 2 — Handle large files
|
|
53
|
-
|
|
54
|
-
If the extracted text is truncated or very long (>200 lines):
|
|
55
|
-
- For a **summary request**: read the full output file instead of relying on stdout — save to a temp file first:
|
|
56
|
-
```bash
|
|
57
|
-
pdftotext -layout -enc UTF-8 "/path/to/file.pdf" /tmp/pdf_extracted.txt
|
|
58
|
-
cat /tmp/pdf_extracted.txt
|
|
59
|
-
```
|
|
60
|
-
- For a **specific question**: use `grep` to locate relevant sections before reading the full content:
|
|
61
|
-
```bash
|
|
62
|
-
grep -n "keyword" /tmp/pdf_extracted.txt | head -30
|
|
63
|
-
```
|
|
64
|
-
- Extract once, answer from memory — do NOT re-read the file multiple times.
|
|
65
|
-
|
|
66
|
-
## Step 3 — Answer the user's question
|
|
67
|
-
|
|
68
|
-
### Output format guidelines
|
|
69
|
-
|
|
70
|
-
Adapt the response format to the document type:
|
|
71
|
-
|
|
72
|
-
| Document type | Recommended format |
|
|
73
|
-
|---|---|
|
|
74
|
-
| Business plan / Report | Structured summary with ## headers per section |
|
|
75
|
-
| Contract / Legal | Key clauses in bullet points, highlight dates and parties |
|
|
76
|
-
| Academic paper | Abstract → Key findings → Methodology → Conclusions |
|
|
77
|
-
| Invoice / Receipt | Table: item, amount, total |
|
|
78
|
-
| General / Unknown | Brief overview paragraph + key points as bullets |
|
|
79
|
-
|
|
80
|
-
**General rules:**
|
|
81
|
-
- Use Markdown formatting (headers, bullets, tables) for clarity
|
|
82
|
-
- Match the user's language — if they asked in Chinese, answer in Chinese
|
|
83
|
-
- Lead with the most important information first
|
|
84
|
-
- If the user asked a specific question, answer it directly before summarizing
|
|
85
|
-
|
|
86
|
-
## Rules
|
|
87
|
-
- Always use the **actual file path** from the `[PDF attached: ...]` message
|
|
88
|
-
- If text extraction returns empty (scanned/image PDF), inform the user and suggest: `brew install tesseract` + `tesseract file.pdf output txt`
|
|
89
|
-
- Do NOT re-read the file multiple times — extract once, answer from memory
|
|
90
|
-
- If the user's question is vague (e.g. "里面有什么", "what is this?"), default to a full structured summary
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zip"
|
|
4
|
-
require "rexml/document"
|
|
5
|
-
require "stringio"
|
|
6
|
-
|
|
7
|
-
module Clacky
|
|
8
|
-
module FileParser
|
|
9
|
-
# Parses DOCX/DOC files into Markdown, preserving headings, paragraphs, and tables.
|
|
10
|
-
module DocxParser
|
|
11
|
-
# Parse raw DOCX bytes and return a Markdown string.
|
|
12
|
-
# @param body [String] Raw file bytes
|
|
13
|
-
# @return [String] Markdown representation
|
|
14
|
-
def self.parse(body)
|
|
15
|
-
xml = read_document_xml(body)
|
|
16
|
-
return xml if xml.start_with?("(") # error string
|
|
17
|
-
|
|
18
|
-
doc = REXML::Document.new(xml)
|
|
19
|
-
numbering = read_numbering(body)
|
|
20
|
-
styles = read_styles(body)
|
|
21
|
-
|
|
22
|
-
lines = []
|
|
23
|
-
REXML::XPath.each(doc, "//w:body/*") do |node|
|
|
24
|
-
case node.name
|
|
25
|
-
when "p"
|
|
26
|
-
line = parse_paragraph(node, styles, numbering)
|
|
27
|
-
lines << line unless line.nil?
|
|
28
|
-
when "tbl"
|
|
29
|
-
lines << parse_table(node)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
result = lines.join("\n").strip
|
|
34
|
-
result.empty? ? "(Document appears to be empty)" : result
|
|
35
|
-
rescue => e
|
|
36
|
-
"(Failed to parse document: #{e.message})"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# --- private ---
|
|
40
|
-
|
|
41
|
-
def self.read_document_xml(body)
|
|
42
|
-
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
43
|
-
entry = zip.find_entry("word/document.xml")
|
|
44
|
-
return "(Could not extract content — possibly encrypted or invalid)" unless entry
|
|
45
|
-
entry.get_input_stream.read
|
|
46
|
-
end
|
|
47
|
-
rescue => e
|
|
48
|
-
"(Failed to open file: #{e.message})"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Returns a hash: { abstract_num_id => { ilvl => { numFmt, start } } }
|
|
52
|
-
def self.read_numbering(body)
|
|
53
|
-
result = {}
|
|
54
|
-
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
55
|
-
entry = zip.find_entry("word/numbering.xml")
|
|
56
|
-
break unless entry
|
|
57
|
-
doc = REXML::Document.new(entry.get_input_stream.read)
|
|
58
|
-
REXML::XPath.each(doc, "//w:abstractNum") do |an|
|
|
59
|
-
id = an.attributes["w:abstractNumId"]
|
|
60
|
-
levels = {}
|
|
61
|
-
REXML::XPath.each(an, "w:lvl") do |lvl|
|
|
62
|
-
ilvl = lvl.attributes["w:ilvl"].to_i
|
|
63
|
-
fmt = REXML::XPath.first(lvl, "w:numFmt")&.attributes&.[]("w:val")
|
|
64
|
-
levels[ilvl] = { fmt: fmt || "bullet" }
|
|
65
|
-
end
|
|
66
|
-
result[id] = levels
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
result
|
|
70
|
-
rescue
|
|
71
|
-
{}
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Returns a hash: { styleId => { heading_level } }
|
|
75
|
-
def self.read_styles(body)
|
|
76
|
-
result = {}
|
|
77
|
-
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
78
|
-
entry = zip.find_entry("word/styles.xml")
|
|
79
|
-
break unless entry
|
|
80
|
-
doc = REXML::Document.new(entry.get_input_stream.read)
|
|
81
|
-
REXML::XPath.each(doc, "//w:style") do |s|
|
|
82
|
-
sid = s.attributes["w:styleId"]
|
|
83
|
-
name = REXML::XPath.first(s, "w:name")&.attributes&.[]("w:val").to_s
|
|
84
|
-
if name =~ /^heading (\d)/i
|
|
85
|
-
result[sid] = { heading: $1.to_i }
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
result
|
|
90
|
-
rescue
|
|
91
|
-
{}
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def self.parse_paragraph(node, styles, numbering)
|
|
95
|
-
ppr = REXML::XPath.first(node, "w:pPr")
|
|
96
|
-
style = REXML::XPath.first(ppr, "w:pStyle")&.attributes&.[]("w:val") if ppr
|
|
97
|
-
num_pr = REXML::XPath.first(ppr, "w:numPr") if ppr
|
|
98
|
-
|
|
99
|
-
text = extract_runs(node)
|
|
100
|
-
return nil if text.strip.empty?
|
|
101
|
-
|
|
102
|
-
# Heading
|
|
103
|
-
if style && styles[style]
|
|
104
|
-
level = styles[style][:heading]
|
|
105
|
-
return "#{"#" * level} #{text}"
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# List item
|
|
109
|
-
if num_pr
|
|
110
|
-
ilvl = REXML::XPath.first(num_pr, "w:ilvl")&.attributes&.[]("w:val").to_i
|
|
111
|
-
indent = " " * ilvl
|
|
112
|
-
return "#{indent}- #{text}"
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
text
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def self.extract_runs(para_node)
|
|
119
|
-
parts = []
|
|
120
|
-
REXML::XPath.each(para_node, "w:r") do |run|
|
|
121
|
-
rpr = REXML::XPath.first(run, "w:rPr")
|
|
122
|
-
bold = REXML::XPath.first(rpr, "w:b") if rpr
|
|
123
|
-
text = REXML::XPath.match(run, "w:t").map(&:text).compact.join
|
|
124
|
-
next if text.empty?
|
|
125
|
-
parts << (bold ? "**#{text}**" : text)
|
|
126
|
-
end
|
|
127
|
-
parts.join
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def self.parse_table(tbl_node)
|
|
131
|
-
rows = []
|
|
132
|
-
REXML::XPath.each(tbl_node, "w:tr") do |tr|
|
|
133
|
-
cells = REXML::XPath.match(tr, "w:tc").map do |tc|
|
|
134
|
-
REXML::XPath.match(tc, ".//w:t").map(&:text).compact.join(" ").strip
|
|
135
|
-
end
|
|
136
|
-
rows << cells
|
|
137
|
-
end
|
|
138
|
-
return "" if rows.empty?
|
|
139
|
-
|
|
140
|
-
# Build markdown table
|
|
141
|
-
col_count = rows.map(&:size).max
|
|
142
|
-
lines = []
|
|
143
|
-
rows.each_with_index do |row, i|
|
|
144
|
-
padded = row + [""] * [col_count - row.size, 0].max
|
|
145
|
-
lines << "| #{padded.join(" | ")} |"
|
|
146
|
-
# Header separator after first row
|
|
147
|
-
lines << "|#{" --- |" * col_count}" if i == 0
|
|
148
|
-
end
|
|
149
|
-
lines.join("\n")
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
private_class_method :read_document_xml, :read_numbering, :read_styles,
|
|
153
|
-
:parse_paragraph, :extract_runs, :parse_table
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zip"
|
|
4
|
-
require "rexml/document"
|
|
5
|
-
require "stringio"
|
|
6
|
-
|
|
7
|
-
module Clacky
|
|
8
|
-
module FileParser
|
|
9
|
-
# Parses PPTX files into Markdown, one section per slide.
|
|
10
|
-
# Extracts slide titles, body text, and table content.
|
|
11
|
-
module PptxParser
|
|
12
|
-
# Parse raw PPTX bytes and return a Markdown string.
|
|
13
|
-
# @param body [String] Raw file bytes
|
|
14
|
-
# @return [String] Markdown representation
|
|
15
|
-
def self.parse(body)
|
|
16
|
-
slides = {}
|
|
17
|
-
|
|
18
|
-
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
19
|
-
zip.each do |entry|
|
|
20
|
-
if entry.name =~ %r{ppt/slides/slide(\d+)\.xml}
|
|
21
|
-
slides[$1.to_i] = entry.get_input_stream.read
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
return "(Presentation appears to be empty)" if slides.empty?
|
|
27
|
-
|
|
28
|
-
sections = slides.keys.sort.map do |num|
|
|
29
|
-
doc = REXML::Document.new(slides[num])
|
|
30
|
-
parse_slide(doc, num)
|
|
31
|
-
end.compact
|
|
32
|
-
|
|
33
|
-
sections.empty? ? "(Presentation appears to be empty)" : sections.join("\n\n---\n\n")
|
|
34
|
-
rescue => e
|
|
35
|
-
"(Failed to parse presentation: #{e.message})"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# --- private ---
|
|
39
|
-
|
|
40
|
-
def self.parse_slide(doc, slide_num)
|
|
41
|
-
lines = []
|
|
42
|
-
|
|
43
|
-
# Title: look for ph type="title" or ph type="ctrTitle"
|
|
44
|
-
title_text = nil
|
|
45
|
-
REXML::XPath.each(doc, "//p:sp") do |sp|
|
|
46
|
-
ph = REXML::XPath.first(sp, ".//p:ph")
|
|
47
|
-
next unless ph
|
|
48
|
-
ph_type = ph.attributes["type"]
|
|
49
|
-
if ph_type == "title" || ph_type == "ctrTitle"
|
|
50
|
-
title_text = extract_text(sp).strip
|
|
51
|
-
break
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
lines << "## Slide #{slide_num}#{title_text && !title_text.empty? ? ": #{title_text}" : ""}"
|
|
56
|
-
|
|
57
|
-
# Body: all other text shapes
|
|
58
|
-
REXML::XPath.each(doc, "//p:sp") do |sp|
|
|
59
|
-
ph = REXML::XPath.first(sp, ".//p:ph")
|
|
60
|
-
# Skip title shapes (already handled) and slide number/date placeholders
|
|
61
|
-
if ph
|
|
62
|
-
ph_type = ph.attributes["type"]
|
|
63
|
-
next if %w[title ctrTitle sldNum dt ftr].include?(ph_type)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
text = extract_text(sp).strip
|
|
67
|
-
next if text.empty?
|
|
68
|
-
next if text == title_text # deduplicate
|
|
69
|
-
|
|
70
|
-
# Indent body bullets
|
|
71
|
-
text.each_line do |line|
|
|
72
|
-
lines << "- #{line.rstrip}" unless line.strip.empty?
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Tables
|
|
77
|
-
REXML::XPath.each(doc, "//a:tbl") do |tbl|
|
|
78
|
-
lines << parse_table(tbl)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
lines.join("\n")
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def self.extract_text(shape_node)
|
|
85
|
-
paras = []
|
|
86
|
-
REXML::XPath.each(shape_node, ".//a:p") do |para|
|
|
87
|
-
text = REXML::XPath.match(para, ".//a:t").map(&:text).compact.join
|
|
88
|
-
paras << text unless text.strip.empty?
|
|
89
|
-
end
|
|
90
|
-
paras.join("\n")
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def self.parse_table(tbl_node)
|
|
94
|
-
rows = []
|
|
95
|
-
REXML::XPath.each(tbl_node, ".//a:tr") do |tr|
|
|
96
|
-
cells = REXML::XPath.match(tr, ".//a:tc").map do |tc|
|
|
97
|
-
REXML::XPath.match(tc, ".//a:t").map(&:text).compact.join(" ").strip
|
|
98
|
-
end
|
|
99
|
-
rows << cells
|
|
100
|
-
end
|
|
101
|
-
return "" if rows.empty?
|
|
102
|
-
|
|
103
|
-
col_count = rows.map(&:size).max
|
|
104
|
-
lines = []
|
|
105
|
-
rows.each_with_index do |row, i|
|
|
106
|
-
padded = row + [""] * [col_count - row.size, 0].max
|
|
107
|
-
lines << "| #{padded.join(" | ")} |"
|
|
108
|
-
lines << "|#{" --- |" * col_count}" if i == 0
|
|
109
|
-
end
|
|
110
|
-
lines.join("\n")
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
private_class_method :parse_slide, :extract_text, :parse_table
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zip"
|
|
4
|
-
require "rexml/document"
|
|
5
|
-
require "stringio"
|
|
6
|
-
|
|
7
|
-
module Clacky
|
|
8
|
-
module FileParser
|
|
9
|
-
# Parses XLSX/XLS files into Markdown tables, one section per sheet.
|
|
10
|
-
module XlsxParser
|
|
11
|
-
# Parse raw XLSX bytes and return a Markdown string.
|
|
12
|
-
# @param body [String] Raw file bytes
|
|
13
|
-
# @return [String] Markdown representation with one table per sheet
|
|
14
|
-
def self.parse(body)
|
|
15
|
-
shared_strings = []
|
|
16
|
-
sheet_names = {}
|
|
17
|
-
sheet_xmls = {}
|
|
18
|
-
|
|
19
|
-
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
20
|
-
# Shared strings table
|
|
21
|
-
ss_entry = zip.find_entry("xl/sharedStrings.xml")
|
|
22
|
-
if ss_entry
|
|
23
|
-
doc = REXML::Document.new(ss_entry.get_input_stream.read)
|
|
24
|
-
REXML::XPath.each(doc, "//si") do |si|
|
|
25
|
-
shared_strings << REXML::XPath.match(si, ".//t").map(&:text).compact.join
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Sheet name mapping from workbook.xml
|
|
30
|
-
wb_entry = zip.find_entry("xl/workbook.xml")
|
|
31
|
-
if wb_entry
|
|
32
|
-
doc = REXML::Document.new(wb_entry.get_input_stream.read)
|
|
33
|
-
REXML::XPath.each(doc, "//sheet") do |s|
|
|
34
|
-
idx = s.attributes["sheetId"]
|
|
35
|
-
name = s.attributes["name"]
|
|
36
|
-
sheet_names[idx] = name if idx && name
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Sheet XMLs
|
|
41
|
-
zip.each do |entry|
|
|
42
|
-
if entry.name =~ %r{xl/worksheets/sheet(\d+)\.xml}
|
|
43
|
-
sheet_xmls[$1] = entry.get_input_stream.read
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
return "(Spreadsheet appears to be empty)" if sheet_xmls.empty?
|
|
49
|
-
|
|
50
|
-
sections = []
|
|
51
|
-
sheet_xmls.keys.sort_by(&:to_i).each do |idx|
|
|
52
|
-
name = sheet_names[idx] || "Sheet#{idx}"
|
|
53
|
-
doc = REXML::Document.new(sheet_xmls[idx])
|
|
54
|
-
|
|
55
|
-
rows = []
|
|
56
|
-
REXML::XPath.each(doc, "//row") do |row|
|
|
57
|
-
cells = parse_row(row, shared_strings)
|
|
58
|
-
rows << cells unless cells.all?(&:empty?)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
next if rows.empty?
|
|
62
|
-
|
|
63
|
-
sections << "### #{name}\n\n#{build_markdown_table(rows)}"
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
sections.empty? ? "(Spreadsheet appears to be empty)" : sections.join("\n\n")
|
|
67
|
-
rescue => e
|
|
68
|
-
"(Failed to parse spreadsheet: #{e.message})"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# --- private ---
|
|
72
|
-
|
|
73
|
-
def self.parse_row(row_node, shared_strings)
|
|
74
|
-
REXML::XPath.match(row_node, ".//c").map do |c|
|
|
75
|
-
v = REXML::XPath.first(c, "v")&.text
|
|
76
|
-
next "" unless v
|
|
77
|
-
c.attributes["t"] == "s" ? (shared_strings[v.to_i] || "") : v
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def self.build_markdown_table(rows)
|
|
82
|
-
col_count = rows.map(&:size).max
|
|
83
|
-
lines = []
|
|
84
|
-
rows.each_with_index do |row, i|
|
|
85
|
-
padded = row + [""] * [col_count - row.size, 0].max
|
|
86
|
-
lines << "| #{padded.join(" | ")} |"
|
|
87
|
-
lines << "|#{" --- |" * col_count}" if i == 0
|
|
88
|
-
end
|
|
89
|
-
lines.join("\n")
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
private_class_method :parse_row, :build_markdown_table
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "zip"
|
|
4
|
-
require "stringio"
|
|
5
|
-
|
|
6
|
-
module Clacky
|
|
7
|
-
module FileParser
|
|
8
|
-
# Lists the contents of a ZIP archive as a Markdown file tree.
|
|
9
|
-
module ZipParser
|
|
10
|
-
MAX_ENTRIES = 200
|
|
11
|
-
|
|
12
|
-
# Parse raw ZIP bytes and return a Markdown directory listing.
|
|
13
|
-
# @param body [String] Raw file bytes
|
|
14
|
-
# @return [String] Markdown representation of archive contents
|
|
15
|
-
def self.parse(body)
|
|
16
|
-
entries = []
|
|
17
|
-
|
|
18
|
-
Zip::File.open_buffer(StringIO.new(body)) do |zip|
|
|
19
|
-
zip.each do |entry|
|
|
20
|
-
entries << {
|
|
21
|
-
name: entry.name,
|
|
22
|
-
size: entry.file? ? entry.size : nil,
|
|
23
|
-
dir: entry.directory?
|
|
24
|
-
}
|
|
25
|
-
break if entries.size >= MAX_ENTRIES
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
return "(ZIP archive appears to be empty)" if entries.empty?
|
|
30
|
-
|
|
31
|
-
truncated = entries.size >= MAX_ENTRIES
|
|
32
|
-
lines = ["**Archive contents** (#{entries.size}#{truncated ? "+" : ""} entries):\n"]
|
|
33
|
-
|
|
34
|
-
entries.sort_by { |e| [e[:dir] ? 0 : 1, e[:name]] }.each do |e|
|
|
35
|
-
if e[:dir]
|
|
36
|
-
lines << "- 📁 #{e[:name]}"
|
|
37
|
-
else
|
|
38
|
-
size_str = e[:size] ? " *(#{format_size(e[:size])})*" : ""
|
|
39
|
-
lines << "- #{e[:name]}#{size_str}"
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
lines << "\n*(listing truncated at #{MAX_ENTRIES} entries)*" if truncated
|
|
44
|
-
lines.join("\n")
|
|
45
|
-
rescue => e
|
|
46
|
-
"(Failed to read ZIP: #{e.message})"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# --- private ---
|
|
50
|
-
|
|
51
|
-
def self.format_size(bytes)
|
|
52
|
-
return "#{bytes} B" if bytes < 1024
|
|
53
|
-
return "#{bytes / 1024} KB" if bytes < 1024 * 1024
|
|
54
|
-
"#{(bytes / 1024.0 / 1024).round(1)} MB"
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private_class_method :format_size
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Install or upgrade agent-browser (browser automation tool)
|
|
3
|
-
# Can be sourced by install.sh or run standalone (e.g. from Ruby via shell)
|
|
4
|
-
#
|
|
5
|
-
# Exit codes:
|
|
6
|
-
# 0 — success (installed/upgraded)
|
|
7
|
-
# 1 — failure (npm not found or install failed)
|
|
8
|
-
|
|
9
|
-
set -e
|
|
10
|
-
|
|
11
|
-
RED='\033[0;31m'
|
|
12
|
-
GREEN='\033[0;32m'
|
|
13
|
-
YELLOW='\033[1;33m'
|
|
14
|
-
BLUE='\033[0;34m'
|
|
15
|
-
NC='\033[0m'
|
|
16
|
-
|
|
17
|
-
print_info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
|
18
|
-
print_success() { echo -e "${GREEN}✓${NC} $1"; }
|
|
19
|
-
print_warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
|
20
|
-
print_error() { echo -e "${RED}✗${NC} $1"; }
|
|
21
|
-
print_step() { echo -e "\n${BLUE}==>${NC} $1"; }
|
|
22
|
-
|
|
23
|
-
command_exists() { command -v "$1" >/dev/null 2>&1; }
|
|
24
|
-
|
|
25
|
-
print_step "Installing agent-browser..."
|
|
26
|
-
|
|
27
|
-
# Try to find npm; if missing, attempt to install Node.js via mise
|
|
28
|
-
if ! command_exists npm; then
|
|
29
|
-
mise_bin=""
|
|
30
|
-
if command_exists mise; then
|
|
31
|
-
mise_bin="mise"
|
|
32
|
-
elif [ -x "$HOME/.local/bin/mise" ]; then
|
|
33
|
-
mise_bin="$HOME/.local/bin/mise"
|
|
34
|
-
fi
|
|
35
|
-
|
|
36
|
-
if [ -n "$mise_bin" ]; then
|
|
37
|
-
print_info "Installing Node.js via mise..."
|
|
38
|
-
"$mise_bin" install node@22 > /dev/null 2>&1 || true
|
|
39
|
-
"$mise_bin" use -g node@22 > /dev/null 2>&1 || true
|
|
40
|
-
eval "$("$mise_bin" activate bash 2>/dev/null)" 2>/dev/null || true
|
|
41
|
-
fi
|
|
42
|
-
fi
|
|
43
|
-
|
|
44
|
-
if ! command_exists npm; then
|
|
45
|
-
print_error "agent-browser installation failed: Node.js/npm not found."
|
|
46
|
-
print_info "Please run: mise install node@22 && mise use -g node@22 && npm install -g agent-browser"
|
|
47
|
-
exit 1
|
|
48
|
-
fi
|
|
49
|
-
|
|
50
|
-
print_info "Running: npm install -g agent-browser"
|
|
51
|
-
if npm install -g agent-browser > /dev/null 2>&1; then
|
|
52
|
-
version=$(agent-browser --version 2>/dev/null | awk '{print $NF}')
|
|
53
|
-
print_success "agent-browser ${version} installed/updated"
|
|
54
|
-
else
|
|
55
|
-
print_error "agent-browser installation failed."
|
|
56
|
-
print_info "To install manually: npm install -g agent-browser"
|
|
57
|
-
exit 1
|
|
58
|
-
fi
|
|
59
|
-
|
|
60
|
-
print_info "Installing Playwright Chromium..."
|
|
61
|
-
if npx playwright install chromium > /dev/null 2>&1; then
|
|
62
|
-
print_success "Playwright Chromium installed"
|
|
63
|
-
else
|
|
64
|
-
print_error "Playwright Chromium installation failed."
|
|
65
|
-
print_info "To install manually: npx playwright install chromium"
|
|
66
|
-
exit 1
|
|
67
|
-
fi
|