Gå til hovedinnhold

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 AD-008 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. TM fuzzy matches, term hits, QA 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's cheap to recompute — no caching benefit.
  • Writes output exclusively to the in-flight model.Block and 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 prefixUsed byPayload shape
targets/<locale>translators (ai-translate, mt-translate, pseudo-translate, human editor){"text": "...", "provider": "..."}
annotations/<name>term-lookup, tm-leverage, qa checkstool-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.

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 ai-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.
  • NewCacheStore(path) — SQLite-backed store, typically at .kapi/cache/blocks.db. The default for kapi projects. Full ACID, persistent across runs.
  • NewFormatReaderStore(factory) — wraps a format.DataFormatReader factory as a read-only store. Useful for ad-hoc CLI flows (kapi ai-translate -i file.xliff): RandomAccess=true, Writable=false. Its PutOverlay returns blockstore.ErrReadOnly.

The executor receives the store via the flow.WithBlockStore(s) option (default NewMemoryStore()); tools never open the store directly.