This commit is contained in:
208
pkg/skillkit/rag.go
Normal file
208
pkg/skillkit/rag.go
Normal 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
113
pkg/skillkit/skillkit.go
Normal 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
127
pkg/skillkit/skills/docx.md
Normal 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.
|
||||
62
pkg/skillkit/skills/overview.md
Normal file
62
pkg/skillkit/skills/overview.md
Normal 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.
|
||||
51
pkg/skillkit/skills/pdf.md
Normal file
51
pkg/skillkit/skills/pdf.md
Normal 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.
|
||||
59
pkg/skillkit/skills/pptx.md
Normal file
59
pkg/skillkit/skills/pptx.md
Normal 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.
|
||||
79
pkg/skillkit/skills/xlsx.md
Normal file
79
pkg/skillkit/skills/xlsx.md
Normal 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.
|
||||
Reference in New Issue
Block a user