Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

208
pkg/skillkit/rag.go Normal file
View File

@@ -0,0 +1,208 @@
// rag.go adds retrieval-augmented search on top of the plain
// read-a-whole-file skill lookup in skillkit.go. The read_office_skill
// tool (skillkit.Read) is the right fit when the model already knows
// which format it needs and wants that skill's full guidance before a
// multi-step task — but for a narrow, specific question ("can a cell
// hold a formula", "how do I bold part of a paragraph"), returning an
// entire 3-4KB skill file just to answer it is the same imprecision
// Claude Code's own skills avoid by splitting a large skill into a
// short SKILL.md plus separate reference files loaded on demand.
// Because every skill here already follows one convention — a "##"
// heading per topic — each section is naturally a single, coherent
// chunk, and because the whole corpus is a few dozen short sections
// fixed at compile time (go:embed, never changes between deploys),
// embedding it once per process and ranking in memory is simpler and
// faster than standing up a vector-DB round trip for content this
// small and this static. search_codebase, by contrast, indexes a
// workspace's actual source files — large, per-user, and changing —
// which is exactly the shape services/knowledge's Postgres/pgvector
// pipeline exists for; that pipeline would be the wrong tool here, not
// a missing piece.
package skillkit
import (
"context"
"fmt"
"math"
"sort"
"strings"
"sync"
)
// Embedder is the subset of *vllmkit.Client this package needs —
// declared as a narrow interface (rather than importing vllmkit
// directly) so the caller supplies whatever embedding client it
// already has, and so this package stays testable without a live
// vLLM instance.
type Embedder interface {
Embed(ctx context.Context, text string) ([]float32, error)
}
// Chunk is one semantically coherent section of a skill file — in
// practice, one "## Heading" and the text under it (or the file's
// title/intro, for the section before the first heading).
type Chunk struct {
Skill string // the skill this section belongs to, e.g. "docx"
Heading string // the section's heading, e.g. "Editing an existing document" — "Overview" for the lead-in before any "##"
Text string // the section's full text, heading line included — what gets embedded, and what Search returns
}
// chunkSkill splits one skill's raw markdown body into Chunks, cutting
// at every "## " line. This mirrors the actual structure every file in
// skills/*.md already uses, so no separate chunk-size tuning
// (domain.ChunkText's char budget, needed for arbitrary source code) is
// necessary here — a skill's own section boundaries are already the
// right retrieval granularity.
func chunkSkill(name, body string) []Chunk {
var (
chunks []Chunk
heading string
cur []string
)
flush := func() {
text := strings.TrimSpace(strings.Join(cur, "\n"))
if text == "" {
return
}
h := heading
if h == "" {
h = "Overview"
}
chunks = append(chunks, Chunk{Skill: name, Heading: h, Text: text})
}
for _, line := range strings.Split(body, "\n") {
if strings.HasPrefix(line, "## ") {
flush()
heading = strings.TrimPrefix(line, "## ")
cur = []string{line}
continue
}
cur = append(cur, line)
}
flush()
return chunks
}
// allChunks returns every skill's chunks, in skillkit.Names() order.
func allChunks() []Chunk {
var out []Chunk
for _, name := range Names() {
out = append(out, chunkSkill(name, registry[name].Body)...)
}
return out
}
// Match is one ranked search result.
type Match struct {
Chunk
Score float64 // cosine similarity to the query, higher is more relevant
}
type vectorChunk struct {
Chunk
vec []float32
}
// Index is a lazily-built, in-memory semantic index over every skill's
// chunks. The zero value is not usable — construct with NewIndex. An
// Index is safe for concurrent use and is meant to be built once and
// reused for the life of the process (see package doc for why an
// in-memory index, not a persisted one, is the right call here).
type Index struct {
embedder Embedder
mu sync.Mutex
vectors []vectorChunk // nil until a build has succeeded at least once
}
// NewIndex returns an Index that will embed every skill chunk, once,
// the first time Search is called (not eagerly here) — so a
// deployment that never calls search_office_skills never pays the
// embedding cost at all, and the first real call pays it exactly once.
func NewIndex(embedder Embedder) *Index {
return &Index{embedder: embedder}
}
// ensureBuilt embeds every chunk if that hasn't already succeeded.
// Deliberately NOT a sync.Once: if vLLM is unreachable on the very
// first call, that failure must not be cached forever — the next
// Search should try again rather than fail for the rest of the
// process's life over one transient outage. Concurrent callers during
// a build simply serialize on the mutex rather than building twice.
func (ix *Index) ensureBuilt(ctx context.Context) error {
ix.mu.Lock()
defer ix.mu.Unlock()
if ix.vectors != nil {
return nil
}
chunks := allChunks()
vectors := make([]vectorChunk, 0, len(chunks))
for _, c := range chunks {
vec, err := ix.embedder.Embed(ctx, c.Text)
if err != nil {
return fmt.Errorf("skillkit: embed %s/%s: %w", c.Skill, c.Heading, err)
}
vectors = append(vectors, vectorChunk{Chunk: c, vec: vec})
}
ix.vectors = vectors
return nil
}
// Search embeds query and returns the topK chunks whose own embedding
// is most cosine-similar to it, best first. skillFilter, if non-empty,
// restricts the search to one skill's chunks (matched the same way
// Read does: case-insensitive against the skill name). topK<=0 defaults
// to 4.
func (ix *Index) Search(ctx context.Context, query string, topK int, skillFilter string) ([]Match, error) {
if topK <= 0 {
topK = 4
}
if err := ix.ensureBuilt(ctx); err != nil {
return nil, err
}
qvec, err := ix.embedder.Embed(ctx, query)
if err != nil {
return nil, fmt.Errorf("skillkit: embed query: %w", err)
}
ix.mu.Lock()
vectors := ix.vectors
ix.mu.Unlock()
matches := make([]Match, 0, len(vectors))
for _, v := range vectors {
if skillFilter != "" && !strings.EqualFold(v.Skill, skillFilter) {
continue
}
matches = append(matches, Match{Chunk: v.Chunk, Score: cosineSimilarity(qvec, v.vec)})
}
sort.Slice(matches, func(i, j int) bool { return matches[i].Score > matches[j].Score })
if len(matches) > topK {
matches = matches[:topK]
}
return matches, nil
}
// cosineSimilarity is the standard cosine of the angle between two
// vectors, in [-1, 1] for any nonzero inputs (in practice embeddings
// from one model land in a narrower positive range). Defensively walks
// only the shorter of the two lengths and returns 0 for a zero vector,
// rather than panicking or dividing by zero, though in normal operation
// every vector here comes from the same embedding model and is the
// same length.
func cosineSimilarity(a, b []float32) float64 {
n := len(a)
if len(b) < n {
n = len(b)
}
var dot, normA, normB float64
for i := 0; i < n; i++ {
dot += float64(a[i]) * float64(b[i])
normA += float64(a[i]) * float64(a[i])
normB += float64(b[i]) * float64(b[i])
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}

113
pkg/skillkit/skillkit.go Normal file
View File

@@ -0,0 +1,113 @@
// Package skillkit implements, for enkly's local agent, the same
// progressive-disclosure "skill" pattern Claude Code uses for its
// own document-editing skills: short, versioned markdown files that
// document how to use a tool family well — conventions, best
// practices, and honestly-stated limitations — kept out of the tool
// schema itself (which the model sees on every single request) and
// instead loaded on demand, once, the first time the model is about
// to actually use that tool family.
//
// Today that's the three office document skills (docx, xlsx, pptx),
// exposed to the model through the read_office_skill tool (see
// toolexec/office_skill.go and toolcatalog/catalog.go). The skill
// files themselves live in skills/*.md as plain, human-editable
// markdown — updating a skill's guidance is a markdown edit, not a Go
// code change, the same way updating a Claude Code skill is.
package skillkit
import (
"embed"
"fmt"
"sort"
"strings"
)
//go:embed skills/*.md
var skillFS embed.FS
// Skill is one loaded SKILL.md-equivalent file.
type Skill struct {
// Name is the catalog key the model passes to read_office_skill —
// the file's basename without ".md" (e.g. "docx").
Name string
// Description is the one-line summary (the "> ..." blockquote
// line right after the file's title) shown in the
// read_office_skill tool description, so the model can pick the
// right skill without having read any of them yet — mirroring how
// Claude Code lists available skills by their one-line
// descriptions before reading a full SKILL.md.
Description string
// Body is the file's full content, returned verbatim to the model
// when it reads this skill.
Body string
}
var registry = mustLoad()
func mustLoad() map[string]Skill {
entries, err := skillFS.ReadDir("skills")
if err != nil {
// Embedded at build time; a failure here means the package
// itself is broken, not a runtime condition callers can
// recover from.
panic(fmt.Sprintf("skillkit: embedded skills directory unreadable: %v", err))
}
reg := make(map[string]Skill, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
raw, err := skillFS.ReadFile("skills/" + entry.Name())
if err != nil {
panic(fmt.Sprintf("skillkit: reading embedded skills/%s: %v", entry.Name(), err))
}
name := strings.TrimSuffix(entry.Name(), ".md")
reg[name] = Skill{Name: name, Description: parseDescription(string(raw)), Body: string(raw)}
}
return reg
}
// parseDescription pulls the one-line "> ..." blockquote that follows
// each skill file's title — see skills/*.md for the convention.
// Falls back to the empty string if a file doesn't follow it, rather
// than failing the whole package load over one malformed doc comment.
func parseDescription(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "> ") {
return strings.TrimPrefix(line, "> ")
}
}
return ""
}
// List returns every available skill (name + description, no body),
// sorted by name, for building the read_office_skill tool's
// description and for any other "what skills exist" listing.
func List() []Skill {
out := make([]Skill, 0, len(registry))
for _, s := range registry {
out = append(out, Skill{Name: s.Name, Description: s.Description})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Read returns one skill's full content by name, or ok=false if no
// such skill exists.
func Read(name string) (Skill, bool) {
s, ok := registry[strings.ToLower(strings.TrimSpace(name))]
return s, ok
}
// Names returns every valid skill name, sorted — used to build the
// read_office_skill tool's JSON-schema "enum" so the model can only
// ever request a skill that actually exists.
func Names() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
sort.Strings(names)
return names
}

127
pkg/skillkit/skills/docx.md Normal file
View File

@@ -0,0 +1,127 @@
# docx — Word documents
> Best practices for create_document / read_document / edit_document: structure, styles, and what these tools can't do yet.
## Content model
A `.docx` here is just an ordered list of paragraphs. Each paragraph has:
- `text` — plain text (no inline bold/italic *within* one paragraph — see
Limitations; `bold`/`italic`/`underline` apply to the whole paragraph).
- `style` — one of `normal`, `heading1`, `heading2`, `heading3`, `bullet`.
Defaults to `normal`.
- `bold` — bolds the whole paragraph. Implied `true` for headings; set it
explicitly on a `normal`/`bullet` paragraph if you want it bold too.
- `italic` — italicizes the whole paragraph. Defaults to `false`.
- `underline` — underlines the whole paragraph. Defaults to `false`.
Combine freely with `bold`/`italic` and with any `style`.
There is no separate "title page" or "section" concept — structure a
document the way you'd structure Markdown: one `heading1` for the
document title, `heading2`/`heading3` for subsections, `bullet`
paragraphs for lists, `normal` for body copy.
## Writing a good structured document
- Lead with a `heading1`. Don't put the document's title in a `normal`
paragraph — it won't render distinctly and won't show up in Word's
navigation pane.
- Keep paragraphs as actual paragraphs, not single giant blocks — split
by idea the way you would in prose, not by sentence.
- Use `bullet` for genuinely list-shaped content (steps, options,
short items), not as a substitute for paragraph breaks.
- A short memo doesn't need every heading level — `heading1` +
`normal`/`bullet` is often enough. Reach for `heading2`/`heading3` only
when the document actually has that many levels of nesting.
## Editing an existing document
`edit_document` supports exactly two operations:
- `append_paragraph` — adds one new paragraph at the end. Same fields as
a `create_document` paragraph (including `bold`/`italic`/`underline`).
This preserves everything already in the file — tables, images,
headers, other formatting — untouched; only a new paragraph is added.
- `replace_text` — a literal, case-sensitive find/replace across every
text run in the document, including text inside table cells. This is
the structured analogue of `edit_file`'s `old_string`/`new_string`: it
matches plain text only, not formatting, and every occurrence is
replaced (there's no "exactly one match" requirement the way
`edit_file` has). One real caveat: Word sometimes splits what reads as
one phrase across more than one internal text run (spell-check and
revision tracking both do this), and `find` only matches within a
single run — if a replacement you expect to apply doesn't, try a
shorter, more literal substring, or `read_document` first to see the
text as the model sees it.
There's no "insert paragraph at position N" or "delete paragraph"
operation yet — if a document needs real restructuring (not just adding
to the end or swapping text), the practical option today is
`read_document` to get the current content, then `create_document` a
replacement with the full new paragraph list (after confirming with the
person you're overwriting it, the same judgment call you'd make before
`write_file`-replacing an existing text file).
Always `read_document` first if you're not certain what's already in the
file — don't guess at existing paragraph text before a `replace_text`
call.
## Redlining (Track Changes)
Two of `edit_document`'s operations record the edit as a reviewable
tracked change instead of rewriting the text in place:
- `append_paragraph_tracked` — same fields as `append_paragraph`, but
the new paragraph shows up in Word as a pending insertion (Review >
Track Changes), not as ordinary content.
- `replace_text_tracked` — same fields as `replace_text`, but instead
of silently rewriting matching text it marks the containing run as
changed: the old text struck through (a deletion) and the new text
inserted beside it. This works at whole-run granularity, not a
minimal character diff — in practice a run is usually already one
coherent span of matching formatting, so this reads as a natural
redline rather than an oversized strikethrough.
- Both accept an optional `author` (defaults to `"enkly agent"`) —
the reviewer name Word shows on hover and in the Reviewing pane.
Resolving changes: `accept_all_changes` makes every pending tracked
change in the document permanent (deletions removed, insertions kept
as ordinary text); `reject_all_changes` discards every pending
insertion and restores every pending deletion. Both act on *every*
tracked change in the file at once — there's no per-change accept or
reject yet, so say so if someone asks for that.
`read_document` automatically reports any pending tracked changes
(author + the affected text) after the plain text, so you don't need a
separate call to discover a document already has open redlines before
deciding whether to add more, accept, or reject them.
## Converting Markdown into a document
`create_document` accepts `markdown` as an alternative to `paragraphs`:
pass a Markdown document's source text and headings (`#`/`##`/`###`),
paragraphs, and bullet/numbered lists are converted into the matching
paragraph styles automatically (numbered lists come out as bullets —
there's no numbered-list style yet). Inline formatting (`**bold**`,
`*italic*`, links, inline code) is flattened to plain text, the same
whole-paragraph-only limit `paragraphs` already has. Tables and raw
HTML blocks in the source are dropped, not approximated — say so if a
Markdown document being converted has either.
## Limitations (so you don't promise more than the tool does)
- No inline run-level formatting — a paragraph is bold/italic/underline
or it isn't; there's no bolding one word mid-sentence.
- Can't create tables, images, headers/footers, footnotes, page breaks,
or columns. `read_document` and `replace_text` *do* see and reach
text already inside a table in a file you didn't create with this
tool (e.g. a user upload) — they just can't add a new table.
- No numbered lists — `bullet` is always a bullet, never `1.`/`2.`/`3.`.
- `read_document` extracts plain text only (bullets prefixed `"- "`,
one line per paragraph wherever it occurs, table cells included);
images and complex formatting in a real user-uploaded `.docx` are
still invisible to it — just the text survives.
If someone explicitly asks for one of these (a table, an image, inline
bold mid-paragraph), say plainly that today's document tool doesn't
support it rather than silently producing something close but different.

View File

@@ -0,0 +1,62 @@
# Office document skills — overview
> Read this first if you're not sure which office skill applies, or if the task spans more than one document type.
enkly can create and edit real Word (`.docx`), Excel (`.xlsx`) and PowerPoint (`.pptx`)
files through nine tools (`create_document`/`read_document`/`edit_document`,
`create_spreadsheet`/`read_spreadsheet`/`edit_spreadsheet`,
`create_presentation`/`read_presentation`/`edit_presentation`), plus a
tenth, read-only `read_pdf` for extracting text out of a PDF someone
uploaded. Every creation/editing tool takes plain structured fields —
text, styles, cell values, slide titles and bullets — and never raw
OOXML/XML. All the file-format work happens server-side in
`pkg/officekit`; you only ever describe *content*.
## Required first step
Before your **first** call to any `create_*`/`edit_*` office tool in a
conversation, call `read_office_skill` with the matching skill name
(`docx`, `xlsx`, or `pptx`). Each skill documents the exact shape that
tool family expects and the specific things it can't do yet — that detail
isn't repeated in the tool schema itself, the same way Claude Code's own
document skills work. You don't need to re-read a skill you've already
read earlier in the same conversation. `read_pdf` is simple enough that
its own tool description is usually enough; read the **pdf** skill only
if a result looks wrong or someone asks what it can/can't extract.
## Picking the right skill
- A letter, report, memo, or any text-first document people will read top
to bottom → **docx**. Need redlining/Track Changes, or converting an
existing Markdown document straight into one? Still **docx** — see its
"Redlining" and "Converting Markdown" sections.
- Tabular data, a budget, a grid of numbers, anything with rows and
columns, including a workbook with more than one tab → **xlsx**.
- Slides for a talk, a pitch, or a deck summarizing something in bullet
points → **pptx**.
- Extracting text from an uploaded PDF (a report, contract, paper) →
**pdf** / `read_pdf`.
- A request that genuinely needs more than one of these (e.g. "write a
report and also a slide deck summarizing it") still gets one skill read
per format you'll actually create — read docx and pptx, not one
combined skill.
## What these tools are *not* for
There is no `create_pdf`/`edit_pdf` — `read_pdf` is extraction only. If
someone specifically needs a new `.pdf` produced, say so plainly rather
than silently substituting a `.docx` or `.pptx` — don't imply the file
you created is a PDF, and don't invent a conversion step that doesn't
exist.
## General working pattern
1. Read the matching skill (once per conversation, per format).
2. If you're changing an existing file rather than creating a new one,
`read_*` it first — don't guess at what paragraphs/cells/slides are
already there.
3. `create_*` for a brand-new file (it fails loudly if the path already
exists, by design — this is the structured-content analogue of
`write_file` vs `edit_file`), `edit_*` for a targeted change to one.
4. Report back in plain language what you made or changed — the file
itself is the deliverable, not a transcript of its contents.

View File

@@ -0,0 +1,51 @@
# pdf — reading PDFs
> Best practices for read_pdf: what it can extract, what it can't, and how to tell the difference.
## What it does
`read_pdf` extracts a PDF's plain text content, best-effort, and
returns it as one block of text — no tool call needed to set anything
up first. It works well on PDFs that have a real embedded text layer,
which is the overwhelming majority of PDFs produced by real software:
Word or LibreOffice's "Export/Save as PDF", a browser's "Print to
PDF", Pandoc, Pages, most invoicing/reporting tools, and so on.
There's no `create_pdf` or `edit_pdf` — this skill (and `read_pdf`)
is read-only, for extracting data out of a PDF someone uploaded, not
for producing one. If someone wants a *new* PDF-shaped deliverable,
build it as a `.docx`/`.pptx`/`.xlsx` with `create_document` /
`create_presentation` / `create_spreadsheet` instead and say plainly
that this deployment can't export straight to PDF yet.
## What it can't do
- **No OCR.** A scanned PDF (a photographed or scanned document saved
as a PDF of page images, with no underlying text layer) has nothing
for `read_pdf` to extract — it returns an error saying so rather
than returning nothing silently or guessing at content. If that
happens, say plainly that the PDF appears to be scanned/image-only
and this tool can't OCR it.
- **No layout.** Tables, multi-column text, headers/footers, and
figure captions all come back as part of the same flat text stream,
roughly in reading order but with no structural markers — a
two-column PDF's columns may interleave line by line rather than
reading column-by-column. Don't present extracted text as if it
preserves a PDF's original formatting or table structure.
- **Unusual font encodings** (a PDF whose fonts remap character codes
in a nonstandard way, with no usable mapping back to plain text) can
come out as mangled or missing text for just that portion of the
document, even when the rest extracts cleanly.
## Using it well
- Read the whole PDF first before answering questions about it — the
tool has no "just page 3" mode yet, it always returns everything it
can extract in one call.
- If the extracted text looks suspiciously short relative to what the
person describes the PDF as containing, say so rather than treating
a short result as the complete document — it may be mostly scanned
pages with only a cover page or two of real text.
- Don't infer a PDF's page count, visual layout, or anything about
images/charts in it from `read_pdf`'s output; it only ever reports
what text it found, never a page-by-page breakdown.

View File

@@ -0,0 +1,59 @@
# pptx — PowerPoint decks
> Best practices for create_presentation / read_presentation / edit_presentation: slide structure and what these tools can't do yet.
## Content model
Every slide is exactly a title plus a flat list of bullet lines — two
fixed text boxes, nothing else. There's no layout to choose, no manual
positioning, no per-bullet nesting (sub-bullets). If a slide's content
doesn't reduce cleanly to "one title, some bullets," it's being forced
into a shape it won't fit — say so rather than cramming a table or a
diagram's content into bullet text and calling it equivalent.
## Writing a good deck
- One idea per slide. If a slide's bullet list is getting long (much
past 5-6 lines), that's usually a sign it should be two slides, not
one dense one.
- Titles should be short and specific ("Q3 Revenue by Region", not
"Update") — they're the only thing distinguishing slides in
`read_presentation`'s output and in Word's/PowerPoint's own slide
sorter.
- Bullets are terse fragments, not full paragraphs — if a "bullet" is
three sentences long, trim it or split it into more bullets.
- A deck needs a title slide too: make slide 1 a title-only slide (title
set, `bullets` omitted or empty) rather than starting straight into
content.
## Editing an existing deck
`edit_presentation` supports exactly two operations:
- `add_slide` — appends one new slide at the end. Same `title`/`bullets`
shape as `create_presentation`.
- `update_slide` — replaces one existing slide's title and bullets
entirely, by 1-based `index`. This **replaces the whole slide's
content**, not a single bullet — if you only want to change one line,
`read_presentation` first, take its current title/bullets, and pass
back the full set with just that one line changed.
There's no "insert slide at position N," "delete slide," or "reorder
slides" operation yet. `read_presentation` first if you're not sure how
many slides exist or what index to target — don't guess at slide
numbers on a deck you haven't read in this conversation.
## Limitations (so you don't promise more than the tool does)
- No images, charts, tables, or speaker notes.
- No custom layouts or manual text-box positioning — always the same
title-plus-bullets shape, two fixed text boxes.
- No slide deletion or reordering.
- `read_presentation` extracts title + bullet text only; a real
user-uploaded `.pptx` with images, custom layouts or multiple text
boxes per slide will lose all of that and return text only.
If someone explicitly asks for an image, a chart, or a custom layout,
say plainly that today's presentation tool doesn't support it rather
than silently producing a bullet-only slide and calling it the same
thing.

View File

@@ -0,0 +1,79 @@
# xlsx — Excel workbooks
> Best practices for create_spreadsheet / read_spreadsheet / edit_spreadsheet: sheets, cell values, and sparse edits.
## Content model
A workbook is one or more sheets, each a row-major grid of plain-text
cell values. Row 1 is the top row, column A is the leftmost column — the
grid you pass is literally what lands in the file, top-to-bottom,
left-to-right. Each value is classified the same way on both
`create_spreadsheet` and `edit_spreadsheet`:
- Starts with `=` (e.g. `"=SUM(A1:A4)"`, `"=B2*C2"`) → stored as a live
Excel **formula**, same as typing it into a cell in Excel. Excel
computes and displays the result when the file is opened; this tool
never evaluates formulas itself, so `read_spreadsheet` right after
writing one shows the formula text (prefixed `=`), not a computed
number — that's expected, not a bug.
- Otherwise parses as a plain number (`"92"`, `"3.5"`, `"-1"`) → stored
as a **numeric** cell.
- Anything else, including something that merely *looks* numeric-ish
like `"$92"` or `"92%"`, is stored as **text**.
If you want a literal computed total rather than a live formula (e.g.
because the person asked for a static report, not a workbook they'll
keep editing), compute it yourself — with `solve_ml_math` for anything
nontrivial — and write the plain number instead of a formula string.
## Single sheet vs. multiple sheets
`create_spreadsheet` has two forms:
- Single sheet: `rows` (+ optional `sheet_name`, defaults to `"Sheet1"`).
- Multiple sheets: `sheets`, an array of `{name, rows}` — one entry per
tab, in the order they should appear. Use this whenever the request
implies more than one tab ("a workbook with a summary sheet and a
detail sheet", "separate sheets per quarter") — don't flatten
multi-sheet data onto one sheet with a column that says which sheet it
"would" be.
Pick one form, not both, in a single `create_spreadsheet` call.
## Reading and editing a specific sheet
`read_spreadsheet` and `edit_spreadsheet` both take an optional `sheet`
field: a 1-based index (`"2"`) or a sheet name (case-insensitive).
Omitting it means the first sheet. When a workbook has more than one
sheet, `read_spreadsheet`'s result starts with a line naming which sheet
you got and listing every sheet's name — read that line before assuming
you're looking at the sheet you meant, especially on a user-uploaded
file whose sheet order you don't already know.
`edit_spreadsheet` writes a **sparse** set of cells by A1-style
reference (e.g. `"B12"`) to one sheet; every other cell on that sheet is
left exactly as it was, and a reference past the current grid extends
it rather than erroring. There's no "edit multiple sheets in one call" —
make one `edit_spreadsheet` call per sheet that needs a change.
Always `read_spreadsheet` first if you're not sure what's already in a
sheet or what the other sheets are called, the same way you'd
`read_file` before `edit_file`.
## Limitations (so you don't promise more than the tool does)
- No cell formatting (bold, colors, borders, column widths, number
formats like currency or percentages).
- No charts.
- No sheet deletion or reordering, and no renaming a sheet after
creation.
- `read_spreadsheet` returns display strings only — no distinction
between a cell's underlying value and how Excel would format it. For
a formula cell, it shows Excel's own cached result when one exists
(always true for a real Excel-authored or user-uploaded file), or the
formula text itself (prefixed `=`) when nothing has computed it yet
(always true immediately after this tool writes one).
If someone explicitly asks for formatting or a chart, say plainly that
today's tool doesn't support it rather than quietly writing a plain,
unformatted workbook and calling it done.