SessionTool authoring guide
A tool.SessionTool is any tool that wants random access to the
project's block state: block lookups by hash, overlay reads for
"skip if already done", overlay writes for cross-run annotations.
The existing tool.Tool streaming contract is unchanged;
SessionTool is additive.
This note walks through when to implement it and what the wire conventions are. See C-01 for the design rationale.
When to implement
Implement SessionTool when your tool:
- Can skip expensive work if a prior run already produced the output for a block. Canonical case: AI translation; re-calling the LLM for a block whose target is already cached is wasted money and latency.
- Writes annotations that a downstream tool (same flow or next run) wants to consult. Memory fuzzy matches, term hits, check findings.
- Needs block-by-hash lookup for cross-reference (rare, but e.g. "inline-code alignment against the last-known target").
Do not implement it when your tool:
- Is a pure stream transform (filter, identity, encoding convert, format read/write). The stream contract already gives you what you need.
- Produces output that is cheap to recompute, so caching gains nothing.
- Writes output exclusively to the in-flight
model.Blockand has no persistent state story.
Minimal implementation
import (
"github.com/neokapi/neokapi/core/blockstore"
"github.com/neokapi/neokapi/core/tool"
)
// Compile-time assertion catches accidental drift.
var _ tool.SessionTool = (*MyTool)(nil)
func (t *MyTool) SessionProcess(
ctx context.Context,
sess blockstore.Session,
in <-chan *model.Part,
out chan<- *model.Part,
) error {
overlayKind := "targets/" + string(t.targetLocale)
caps := sess.Capabilities()
for {
select {
case <-ctx.Done():
return ctx.Err()
case part, ok := <-in:
if !ok {
return nil
}
// Skip logic, expensive work, overlay write...
if err := t.handle(sess, caps.RandomAccess, overlayKind, part); err != nil {
return err
}
select {
case out <- part:
case <-ctx.Done():
return ctx.Err()
}
}
}
}
The per-block helper checks capabilities, consults the overlay, runs the core work, writes the overlay back:
func (t *MyTool) handle(sess blockstore.Session, ra bool, kind string, part *model.Part) error {
block, ok := part.Resource.(*model.Block)
if !ok || !block.Translatable || block.ID == "" {
_, err := t.doTheWork(part)
return err
}
// Hydrate from cache when possible.
if ra {
if sc, err := sess.GetOverlay(kind, block.ID); err == nil && len(sc.Payload) > 0 {
var cached myOverlay
if json.Unmarshal(sc.Payload, &cached) == nil && cached.Text != "" {
block.SetTargetText(t.targetLocale, cached.Text)
return nil
}
}
}
// Do the expensive work.
if _, err := t.doTheWork(part); err != nil {
return err
}
// Cache the result for next time.
if target := block.TargetText(t.targetLocale); target != "" {
payload, _ := json.Marshal(myOverlay{Text: target})
if err := sess.PutOverlay(blockstore.Overlay{
Kind: kind,
BlockHash: block.ID,
Payload: payload,
}); err != nil && !errors.Is(err, blockstore.ErrReadOnly) {
return fmt.Errorf("my-tool: write overlay: %w", err)
}
}
return nil
}
Overlay conventions
| Kind prefix | Used by | Payload shape |
|---|---|---|
targets/<locale> | translators (translate, pseudo-translate, human editor) | {"runs": [...], "text": "...", "status": "...", "origin": {...}} |
annotations/<name> | term-lookup, recycle, qa checks | tool-specific JSON |
skeletons/<format> | format writers (round-trip skeletons) | opaque payload |
The targets/<locale> shape is cross-tool: any translator writes
and reads the same key, so a session hydrated by one can be
continued by another. Keep the payload small and JSON-compatible.
origin carries the provenance the producer stamped, including the
ContextFingerprint of the governing context. Most target formats
have nowhere to keep it, so for those the overlay is the only durable
record of what governed the answer, and it is what a convergence reads
back when it records the basis the staleness gate compares
(host/basisrecord.go).
annotations/<name> is cross-tool in the same way, one level down:
<name> is the block-annotation key, so a store that holds blocks
rather than loose overlays files the payload as that block's
annotation. A <name> the payload registry knows (note,
quality.findings, …) means the body has to match that
annotation's schema; anything else is kept verbatim and comes back
to your tool exactly as written. skeletons/<format> and any other
prefix stay opaque: they name no annotation and no store reads
them but the tool that wrote them.
Read-only stores
The FormatReaderStore wraps a raw XLIFF / JSON / etc. file as a
read-only blockstore.Store. Its PutOverlay returns
blockstore.ErrReadOnly. Tools should ignore this error on the
overlay-write path: the in-flight *model.Block already carries
the result, and caching is best-effort for the next run. See the
pattern in core/tools/pseudo.go and
core/ai/tools/translate.go.
Batching + concurrency
If your tool has a concurrent / batched path (like translate
with batchSize > 1 or concurrency > 1), wrap the batched path
with session filtering at the input (skip cached) and
overlay-write at the output. Example:
core/ai/tools/translate.go::processBatchedWithSession.
Store providers
The providers are plain constructors in core/blockstore, not string-keyed
entries declared in a recipe. The caller (CLI, project runner, executor)
constructs the one it wants and hands it to the executor:
NewMemoryStore(): the default when no store is passed. Snapshot-per-session, last-writer-wins on commit. Capabilities: RandomAccess + Concurrent + Writable; not Persistent.sqlitestore.New(path)/sqlitestore.NewFromDB(db, …)(core/blockstore/sqlitestore): the SQLite-backed store, in a kapi project the block-cache tables of.kapi/work/store.db. The default for kapi projects. Full ACID, persistent across runs.NewPersistentMemoryStore(): a memory store that advertisesPersistentand whoseCloseis a no-op, so it survives repeated open and close across commands in one process. It stands in for the SQLite cache on the wasm build, where SQLite is unavailable.NewFormatReaderStore(factory): wraps aformat.DataFormatReaderfactory as a read-only store. Useful for ad-hoc CLI flows (kapi translate -i file.xliff): RandomAccess=true, Writable=false. ItsPutOverlayreturnsblockstore.ErrReadOnly.
The executor receives the store via the flow.WithBlockStore(s) option
(default NewMemoryStore()); tools never open the store directly.