From 55b83baa4c2994f20ba8bf13c1c807499a2c6a91 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 2 Apr 2026 15:18:56 +0000 Subject: [PATCH] Add v0.8.x design docs and expand roadmap through v1.0 Land two design documents from planning sessions: - DESIGN-storage-primitives.md (files module, workspace, capability negotiation, vector columns) - DESIGN-extension-composability.md (slots/contributes manifest fields, lib.require relaxation, SDK helpers) Expand ROADMAP.md with detailed v0.8.x-v1.0 plan: storage primitives, reference extensions, sidecar tier, stable release gate criteria. Merge design decisions from both planning docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- ROADMAP.md | 232 ++++++++- docs/DESIGN-extension-composability.md | 687 +++++++++++++++++++++++++ docs/DESIGN-storage-primitives.md | 668 ++++++++++++++++++++++++ 3 files changed, 1571 insertions(+), 16 deletions(-) create mode 100644 docs/DESIGN-extension-composability.md create mode 100644 docs/DESIGN-storage-primitives.md diff --git a/ROADMAP.md b/ROADMAP.md index 6b737c2..aaed007 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,14 +1,15 @@ # Armature — Roadmap -## Current: v0.7.4 — Documentation + Deferred Surface Work +## Current: v0.7.5 — Headless E2E + CI Gate -Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox, -storage, realtime, and ops are kernel primitives. Everything else is an extension. +Self-hosted extensible platform kernel. Auth, identity, packages, Starlark +sandbox, storage, realtime, and ops are kernel primitives. Everything else +is an extension. **Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC · Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) · Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub · -Audit log · Notifications · Scheduled tasks +Audit log · Notifications · Scheduled tasks · Cluster registry + HA **Completed history:** v0.2.x–v0.5.x fully documented in `CHANGELOG.md`. Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization, @@ -184,22 +185,212 @@ same pattern as the kernel surface migrations. | Step | Status | Description | |------|--------|-------------| -| Playwright test harness | | `ci/e2e-surface-test.sh` — docker-compose, chromium, run-all, assert. | -| Screenshot-on-failure | | Full-page screenshot + console log. CI artifacts. | -| Navigation smoke test | | Playwright visits every surface. Asserts topbar, no JS errors, home link works. | -| Visual regression baseline | | Optional screenshot diff. Not a gate — report for review. | -| CI pipeline integration | | Enable `test-runners` stage, add `e2e-smoke` stage. Failure blocks merge. | +| CI gating redesign | | Fix path rules: `VERSION`/`scripts/*` deploy-only, `docs/*` no longer skips deploy, test-runners trigger on any code change. | +| E2E smoke test harness | | `ci/e2e-smoke-test.sh` + `ci/e2e-smoke-driver.js` — Playwright visits every surface, asserts topbar, no JS errors, home link. | +| Screenshot-on-failure | | Full-page screenshot + console log saved as CI artifacts. | +| CI pipeline integration | | Re-enable `test-runners` stage, add `e2e-smoke` stage. Failure blocks merge. | +| Design docs landed | | `docs/DESIGN-storage-primitives.md`, `docs/DESIGN-extension-composability.md`. Roadmap expanded through v1.0. | --- -## Post-v0.7.x +## Planned -- **LLM participation** (`llm-bridge` extension) -- **Rich media extensions:** image generation, code sandbox, STT/TTS -- **Desktop app** (Tauri or Electron) -- **Sidecar tier:** container-based extensions -- **Federation:** cross-instance package sharing -- **Plugin marketplace** with signing and review +### v0.8.x — Storage Primitives + +Design doc: `docs/DESIGN-storage-primitives.md` + +The last major kernel expansion. Completes the primitive set that the +entire extension ecosystem builds on. After v0.8.x, the kernel is +feature-complete for 1.0 — all new capabilities are extensions. + +**v0.8.0 — `files` Module** + +Bridge `ObjectStore` (PVC/S3) into the Starlark sandbox. Extension-scoped +key namespacing (`ext/{packageID}/`). Permissions: `files.read`, +`files.write`. Metadata companions stored as sibling objects. + +New: `sandbox/files_module.go`. Modified: runner wiring, permission +constants. + +**v0.8.1 — `workspace` Module** + +Managed disk directories for tools that need a real filesystem (git, +compilers, media tools). `workspace.create(name)` → scoped path. +Permission: `workspace.manage`. Quota enforcement via `WORKSPACE_QUOTA_MB`. + +New: `sandbox/workspace_module.go`. Modified: runner wiring, permission +constants. + +**v0.8.2 — Capability Negotiation** + +Extensions declare environment requirements in manifest (`capabilities.required`, +`capabilities.optional`). Kernel validates at install time. Runtime query +via `settings.has_capability()`. Admin endpoint: `GET /admin/capabilities`. + +Detected capabilities: `pgvector`, `workspace`, `object_storage`, `s3`, +`postgres`. + +New: `handlers/capabilities.go`. Modified: install handler, settings module. + +**v0.8.3 — Vector Column Type** + +`"vector(N)"` in `db_tables` manifest. Maps to native `vector(N)` on +PG+pgvector, `JSONB` on PG without, `TEXT` on SQLite. New `db.query_similar()` +with dual-path dispatch (native operator vs Go-side brute force). + +Modified: `ext_db_schema.go`, `db_module.go`. + +**v0.8.4 — Extension Composability** + +Design doc: `docs/DESIGN-extension-composability.md` + +Manifest declarations for `slots` (host surfaces declare injection points) +and `contributes` (extensions declare UI contributions to other packages' +slots). `lib.require()` relaxed from library-only to any package with +`exports` — enables full packages (with surfaces, settings, UI) to also +expose callable backend functions. `sw.slots.renderAll()` SDK helper. +Admin slot aggregation endpoint. + +The composability primitive that enables: LLM tool registration, UI +contribution (toolbar buttons, image actions), and cross-extension +function calls. No new tables, migrations, or permissions. + +Modified: `sandbox/lib_module.go` (~1 line), `handlers/extensions.go`, +`src/js/sw/sdk/slots.js`. New: `handlers/admin_slots.go`. + +--- + +### v0.9.x — Reference Extensions + +The kernel is complete. This series proves the platform by shipping +first-party extensions that exercise every primitive. These are +**extensions, not kernel code** — they ship as `.pkg` files and can be +uninstalled. + +**v0.9.0 — `vector-store` Library** + +Library extension: document ingestion, chunk storage, embedding persistence, +semantic similarity search. Three-tier vector strategy (pgvector → JSONB +fallback → TEXT fallback). Consumes: `db.write`, `files.read`. + +**v0.9.1 — `llm-bridge` Library** + +Library extension: model abstraction, tool-use routing, provider BYOK via +connections. Exposes `complete()`, `embed()`, `classify()`, `register_tool()` +to consumer extensions. Tool extensions (image-gen, code-exec, web-search) +register at load time — LLM discovers available tools automatically. +Consumes: `connections.read`, `api.http`, `db.write`. + +**v0.9.2 — `file-share` Extension** + +File upload via `api_routes`, storage via `files` module, download links, +optional team/group ACLs via resource grants. First extension to exercise +the full `files` primitive end-to-end. + +**v0.9.3 — `code-workspace` Extension** + +Managed code repositories via `workspace` module. Git clone/pull via +`api.http` to Gitea/GitHub API. File browser surface. Structural indexing +(AST-aware) for code navigation. Consumes: `workspace.manage`, `files.write`, +`api.http`. + +**v0.9.4 — `image-gen` + `image-edit` Extensions** + +Image generation via external APIs (DALL-E, Stable Diffusion). Registers +as LLM tool with `llm-bridge`. Contributes regen/edit buttons to +`chat:image-actions` slot. Image-edit extends with inpaint, outpaint, +upscale, and style transfer — each contributing action buttons to the +same slot independently. First full demonstration of the composability +pattern: three extensions contributing UI to a fourth's surface. + +**v0.9.5 — Chat System** + +Generic 1-to-N human messaging. `chat-core` library (conversation/message +CRUD) + `chat` surface. Declares `chat:message-actions`, `chat:image-actions`, +`chat:composer-tools` slots for extension contribution. LLM participation +as a follow-on consuming `llm-bridge`. Consumes: `db.write`, +`realtime.publish`, `files.write` (attachments). + +--- + +### v0.10.x — Sidecar Tier + Polish + +**v0.10.0 — Sidecar Tier** + +Autonomous out-of-process extensions for workloads that can't run in +Starlark: ML inference, media transcoding, language servers, local git +operations. The kernel does NOT manage container lifecycle — sidecars +are independent processes (Docker, systemd, bare binary) that connect +inward to the kernel, following the cluster registry pattern: + +- Sidecar boots with cluster config (instance URLs, credentials). +- Connects and self-registers, declaring capabilities ("sklearn", + "gpu", "ffmpeg", "sentence-transformers"). +- Goes inert until an admin authorizes (same flow as extension permissions). +- Kernel dispatches work; sidecar returns results. +- Heartbeat keeps registration alive; missed heartbeat = eviction. + +Communication channel TBD (WebSocket over existing event bus, gRPC, or +HTTP callback model — design doc required). Authentication via mTLS +certs or registration tokens. + +This model eliminates k8s RBAC dependency for local deployments. The +kernel stays thin — it accepts registrations and dispatches work. The +sidecar brings its own compute and manages its own lifecycle. + +**v0.10.1 — Native Dialog Audit** + +Replace `prompt()`/`confirm()`/`alert()` with `sw.dialog` SDK primitives. +Accessible, themed, promise-based. Small kernel SDK change that improves +every surface. + +**v0.10.2 — Stability + Migration Tooling** + +Proper versioned migrations (post-MVP discipline). `armature migrate` +CLI command. Backup/restore validation against reference dataset. +Pre-1.0 schema freeze. + +--- + +### v1.0.0 — Stable Release + +The kernel API surface is frozen. Extensions are the product. + +Gate criteria: + +- All kernel Starlark modules documented with examples +- All `api_routes` covered by OpenAPI spec +- Upgrade path tested from v0.8.0 → v1.0.0 +- At least 3 reference extensions shipped and tested (vector-store, llm-bridge, chat) +- At least 1 cross-extension composability demo (image-gen → chat:image-actions) +- Headless E2E green on PG + SQLite +- `armature-ca.sh` + mTLS deployment guide +- Single-binary + Docker + K8s deployment paths documented + +--- + +## Post-1.0 Horizon + +These are candidates, not commitments. Each requires a design doc. + +- **Federation** — cross-instance package sharing, identity federation +- **Package marketplace** — signing, review, discovery registry +- **Desktop app** — Tauri wrapper for local-first deployment +- **Offline/sync** — SQLite-first with PG sync for field deployments +- **Multi-tenant SaaS mode** — tenant isolation at the team boundary + +--- + +## Design Principles + +| Principle | Implication | +|-----------|-------------| +| Extensions are the product | Chat, tasks, LLM, vector search, file sharing — all extensions. Zero kernel awareness of domain logic. | +| Kernel stays thin | New kernel primitives require justification. If it can be an extension, it must be. | +| Progressive enhancement | Every feature works on SQLite. PG adds performance. pgvector adds native vectors. S3 adds scalable storage. | +| KISS-first | No unnecessary dependencies. Preact+htm (3KB), single binary, dual-DB from one codebase. | +| Changeset discipline | Each CS independently CI-green. Design docs as implementation contracts. | +| Pre-1.0 migration freedom | Schema changes fold into existing migrations. Post-1.0: proper versioned migrations. | --- @@ -228,3 +419,12 @@ same pattern as the kernel surface migrations. | Docs is the reference surface | Only surface with shell Topbar, bell, user menu, inline errors. Others converge. | | Surface runners over expanding ICD | Different test tier, different failure class. | | Headless E2E via Playwright | Runners produce structured JSON; Playwright navigates and reads output. | +| `db` module is the structured store | No separate KV primitive. Extensions declare tables — infrastructure exists. | +| `files` module rides ObjectStore | No new kernel tables. Metadata as companion objects. Works on PVC and S3 identically. | +| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths are the explicit escape hatch. | +| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. | +| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. Documentation > hard gates. | +| Sidecar deferred to v0.10.x | HTTP module covers external APIs. Local compute is a v0.10.x concern. Sidecars connect inward (like cluster nodes), not spawned outward (no k8s RBAC needed). | +| No second scripting runtime | Starlark for glue, HTTP for external compute, sidecars for local compute. Three tiers with clear boundaries. Lua/WASM middle tier rejected — Turing-complete enough to be dangerous, not isolated enough to be trustworthy. | +| Reference extensions prove the platform | First-party `.pkg` files, not kernel code. Uninstallable. Dogfooding. | +| Composability via slots + exports, not kernel | `sw.slots` + `sw.actions` + `lib.require()` are the composition primitives. Manifest declarations add admin visibility. No inter-extension message bus — too complex, too fragile. | diff --git a/docs/DESIGN-extension-composability.md b/docs/DESIGN-extension-composability.md new file mode 100644 index 0000000..a50f55f --- /dev/null +++ b/docs/DESIGN-extension-composability.md @@ -0,0 +1,687 @@ +# DESIGN — Extension Composability + +**Version:** v0.8.4 +**Status:** Draft +**Author:** Jeff / Claude session 2026-04-02 + +--- + +## Problem + +Extensions cannot meaningfully compose with each other. A Notes surface +can't accept toolbar buttons from an STT extension. An LLM bridge can't +invoke an image generator's functions. A chat surface can't display action +buttons contributed by an image-editing extension. + +The runtime primitives for contribution exist — `sw.slots`, `sw.actions`, +and `sw.renderers` are live in the SDK. But there is no manifest layer +to declare the relationships, no backend mechanism for non-library packages +to call each other's exported functions, and no conventions for slot +naming or context contracts. + +This blocks the entire "extensions extending extensions" pattern that +makes the platform an ecosystem rather than a collection of packages. + +--- + +## Non-Goals + +- Arbitrary inter-extension communication (message passing, shared memory). + Extensions compose through declared slots, exported functions, and events. +- Runtime dependency injection. Dependencies are declared in manifests and + resolved at load time. +- Extension sandboxing on the frontend. Browser-tier JS runs in the same + page context. Isolation is by convention and code review, not enforcement. + +--- + +## What Already Exists + +Three SDK registries are live and functional: + +**`sw.slots`** — Named UI injection points. `register(name, {id, component, priority})` +adds a Preact component to a named slot. `get(name)` returns sorted entries. +Emits `slots.changed` events. Dedup by id. Priority ordering. + +**`sw.actions`** — Named callable actions. `register(id, {handler, label, icon})` +exposes a function by name. `run(id, ...args)` invokes it. Cross-extension +function calls on the frontend. + +**`sw.renderers`** — Block and post renderers for markdown content. +`register(name, {type, pattern, render})`. Already used by mermaid, KaTeX, +CSV, and diff extensions. + +**`lib.require()`** — Backend cross-package function calls. Starlark +packages call exported functions from library packages with the library's +own permission context. + +The gap: `sw.slots` has no manifest awareness — the admin can't see which +extensions contribute where, install/uninstall can't warn about orphaned +contributions. `lib.require()` is restricted to `type: "library"` packages, +so a full package like an image generator can't export callable functions. +There are no conventions for slot naming or context contracts. + +--- + +## Architecture + +Three additions: manifest declarations, backend relaxation, and SDK helpers. + +### 1. Manifest Declarations + +#### Host Surfaces: `slots` Field + +Surfaces declare named injection points with context descriptions: + +```json +{ + "id": "notes", + "slots": { + "toolbar-actions": { + "description": "Toolbar action buttons", + "context": { + "noteId": "string — current note ID", + "getContent": "function — returns note body text", + "setContent": "function — replaces note body text" + } + }, + "note-footer": { + "description": "Content rendered below note body", + "context": { + "noteId": "string", + "content": "string — rendered HTML content" + } + } + } +} +``` + +```json +{ + "id": "chat", + "slots": { + "message-actions": { + "description": "Action buttons on individual messages", + "context": { + "messageId": "string", + "content": "string — message text", + "attachments": "array — [{url, type, name}]" + } + }, + "image-actions": { + "description": "Action buttons overlaid on rendered images", + "context": { + "imageUrl": "string", + "messageId": "string", + "metadata": "object — generation params if available" + } + }, + "composer-tools": { + "description": "Tool buttons in the message composer area", + "context": { + "conversationId": "string", + "insertText": "function — appends to composer" + } + } + } +} +``` + +The `context` field is documentation, not enforcement. It tells extension +authors what data the slot provides. The kernel parses and stores slot +declarations but does not validate context shapes at runtime. + +#### Contributing Extensions: `contributes` Field + +Extensions declare which slots they inject into: + +```json +{ + "id": "note-dictate", + "title": "Note Dictation", + "type": "extension", + "tier": "browser", + "contributes": { + "notes:toolbar-actions": { + "label": "Dictate", + "icon": "🎤", + "description": "Voice-to-text note dictation" + } + }, + "permissions": ["api.http"] +} +``` + +```json +{ + "id": "image-gen", + "title": "Image Generator", + "type": "full", + "tier": "starlark", + "contributes": { + "chat:composer-tools": { + "label": "Generate Image", + "icon": "🎨", + "description": "AI image generation from text prompt" + } + }, + "exports": ["generate", "list_models"], + "permissions": ["api.http", "connections.read", "files.write", "db.write"] +} +``` + +```json +{ + "id": "image-edit", + "title": "Image Editor", + "type": "extension", + "tier": "starlark", + "contributes": { + "chat:image-actions": { + "label": "Edit Image", + "icon": "✏️", + "description": "Inpaint, outpaint, upscale, style transfer" + } + }, + "exports": ["inpaint", "outpaint", "upscale", "restyle"], + "permissions": ["api.http", "connections.read", "files.write"] +} +``` + +**Slot naming convention:** `{host-package-id}:{slot-name}`. The colon +separates namespace from slot. Extensions contributing to `notes:toolbar-actions` +are declaring a relationship with the `notes` package. + +#### What the Kernel Does with Declarations + +**At install time:** +- Parse `slots` field → store in manifest (no separate table needed). +- Parse `contributes` field → validate that each target slot name follows + the `{pkg}:{slot}` convention. Do NOT validate that the host package + exists — contributing extensions can be installed before or after their + host. Soft coupling. +- Parse `exports` field → already stored in manifest. No changes needed. + +**At admin display time:** +- `GET /api/v1/admin/packages/:id` includes `slots` and `contributes` + from manifest. Admin UI shows "This package provides X slots" and + "This package contributes to Y slots." +- New endpoint: `GET /api/v1/admin/slots` → returns a map of all declared + slots across installed packages with their contributors. Built by + scanning all package manifests — no new table. + +**At uninstall time:** +- If uninstalling a package that declares `slots`, check if any enabled + packages declare `contributes` targeting those slots. Warn (not block): + "Uninstalling 'notes' will orphan contributions from: note-dictate, + note-ai." The admin decides. +- If uninstalling a contributing package, no warning needed — the slot + just has fewer entries. + +--- + +### 2. Backend: `lib.require()` Relaxation + +Currently `lib.require()` enforces `libPkg.Type == "library"`. This +prevents full packages (which have surfaces, settings, UI) from also +exporting callable functions. + +**Change:** Replace the type check with an exports check. + +In `sandbox/lib_module.go`, the current guard: + +```go +if libPkg.Type != "library" { + return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type) +} +``` + +Becomes: + +```go +exports := extractExports(libPkg.Manifest) +if len(exports) == 0 { + return nil, fmt.Errorf("lib.require: package %q declares no exports", libraryID) +} +``` + +Any package that declares `"exports": [...]` is callable via `lib.require()`, +regardless of type. The dependency check (`ListByConsumer`) remains +enforced — the consumer must still declare `"depends": ["image-gen"]` +in its manifest. + +The `depends` field already accepts any package ID, not just libraries. +The `DependencyStore` has no type check. This change is one line in +`lib_module.go`. + +**Security implication:** A full package's exported functions run with +that package's own permissions, same as libraries today. An image-gen +package with `api.http` + `files.write` permissions exposes `generate()` +— when llm-bridge calls it, it runs with image-gen's permissions, not +llm-bridge's. This is correct and already how `lib.require()` works. + +--- + +### 3. SDK Additions + +#### `sw.slots.renderAll(name, context)` Helper + +Currently host surfaces must manually iterate slot entries: + +```javascript +const entries = sw.slots.get('notes:toolbar-actions'); +return entries.map(e => html`<${e.component} ...${ctx} />`); +``` + +Add a convenience helper: + +```javascript +/** + * Render all components registered in a slot. + * @param {string} name — slot name + * @param {object} context — props passed to each component + * @returns {Array} — array of rendered vnodes + */ +renderAll(name, context = {}) { + return this.get(name).map(e => { + try { + return e.component(context); + } catch (err) { + console.error(`[sw.slots] Error rendering "${e.id}" in slot "${name}":`, err); + return null; + } + }).filter(Boolean); +} +``` + +Host surface usage becomes: + +```javascript +// In Notes toolbar: +html`
+ + ${sw.slots.renderAll('notes:toolbar-actions', { + noteId: currentNote.id, + getContent: () => editor.getValue(), + setContent: (text) => editor.setValue(text) + })} +
` +``` + +#### `sw.slots.declare(name, description)` (Optional) + +Runtime declaration for slots that don't appear in the manifest (e.g., +dynamically created slots). Mostly for discoverability — the debug panel +can list all active slots whether manifest-declared or runtime-declared. + +```javascript +// In chat surface, when rendering an image: +sw.slots.declare('chat:image-actions', 'Action buttons on images'); +``` + +Not enforced — `sw.slots.register()` works on any name regardless. +This is a documentation/debugging aid only. + +--- + +## Composition Patterns + +### Pattern A: LLM Tool Registration + +LLM bridge exposes a tool registry. Tool extensions register at load time +via `lib.require()` on the backend and `sw.actions` on the frontend. + +**llm-bridge (library) — script.star:** +```python +_tools = {} + +def register_tool(name, description, parameters, handler): + """Register a callable tool for LLM function calling.""" + _tools[name] = { + "name": name, + "description": description, + "parameters": parameters, + "handler": handler, + } + +def complete(messages, tools=None): + """Send messages to LLM with registered tools available.""" + tool_schemas = [] + for t in _tools.values(): + tool_schemas.append({ + "name": t["name"], + "description": t["description"], + "parameters": t["parameters"], + }) + # ... call LLM API with tool_schemas, handle tool_use responses + # by dispatching to _tools[name]["handler"] +``` + +**image-gen (full package) — script.star:** +```python +llm = lib.require("llm-bridge") + +def generate(prompt, model="dall-e-3", size="1024x1024", seed=None): + conn = connections.get("openai") + # ... call API, store result in files module ... + return {"image_url": url, "prompt": prompt, "seed": actual_seed} + +# Register as LLM tool at load time +llm.register_tool( + name="generate_image", + description="Generate an image from a text description", + parameters={"prompt": "string", "size": "string"}, + handler=generate, +) +``` + +### Pattern B: UI Contribution (Toolbar Buttons) + +**note-dictate (extension) — js/index.js:** +```javascript +sw.slots.register('notes:toolbar-actions', { + id: 'note-dictate', + priority: 200, + component: ({ noteId, setContent, getContent }) => { + const [recording, setRecording] = preact.useState(false); + + const toggle = async () => { + if (recording) { + // Stop recording, send audio to STT api_route + const text = await sw.api.ext('note-dictate').post('/transcribe', { + audio: audioBlob + }); + setContent(getContent() + '\n' + text.transcription); + setRecording(false); + } else { + // Start recording + setRecording(true); + } + }; + + return html``; + } +}); +``` + +**notes (surface) — toolbar rendering:** +```javascript +html`
+ + + ${sw.slots.renderAll('notes:toolbar-actions', { + noteId: note.id, + getContent: () => editor.getValue(), + setContent: (text) => editor.setValue(text), + })} +
` +``` + +Notes doesn't know dictation exists. Dictation doesn't know Notes' +internals — just the slot contract (noteId, getContent, setContent). + +### Pattern C: Image Actions (Generate + Edit + Upscale) + +Three independent extensions contribute to the same image display slot: + +**chat surface — image rendering:** +```javascript +function ChatImage({ src, messageId, metadata }) { + return html`
+ +
+ ${sw.slots.renderAll('chat:image-actions', { + imageUrl: src, + messageId, + metadata, + })} +
+
`; +} +``` + +**image-gen — contributes regen button:** +```javascript +sw.slots.register('chat:image-actions', { + id: 'image-gen:regenerate', + priority: 100, + component: ({ imageUrl, metadata }) => { + const regen = async () => { + // Open prompt editor with original params + const params = await sw.prompt('Edit prompt', { + default: metadata?.prompt || '', + multiline: true, + }); + if (!params) return; + const result = await sw.api.ext('image-gen').post('/generate', { + prompt: params, + seed: metadata?.seed, + negative_prompt: metadata?.negative_prompt, + }); + // result triggers a new chat message with the generated image + }; + + return html``; + } +}); +``` + +**image-edit — contributes edit drawer:** +```javascript +sw.slots.register('chat:image-actions', { + id: 'image-edit:edit', + priority: 200, + component: ({ imageUrl, metadata }) => { + const openEditor = () => { + // Open a drawer with editing options + sw.emit('drawer.open', { + title: 'Edit Image', + component: ImageEditPanel, + props: { imageUrl, metadata }, + }); + }; + + return html``; + } +}); +``` + +**image-upscale — contributes upscale button:** +```javascript +sw.slots.register('chat:image-actions', { + id: 'image-upscale:upscale', + priority: 300, + component: ({ imageUrl }) => { + const upscale = async () => { + const result = await sw.api.ext('image-upscale').post('/upscale', { + image_url: imageUrl, + scale: 2, + }); + // Display or replace with upscaled image + }; + + return html``; + } +}); +``` + +Result: an image in chat gets three action buttons from three separate +extensions. Install one, get one button. Install all three, get all three. +Uninstall one, the others keep working. The chat surface has no knowledge +of any of them. + +### Pattern D: LLM Note Restructuring + +Combines backend composability (lib.require) with frontend contribution +(slots): + +**note-ai (extension) — manifest.json:** +```json +{ + "id": "note-ai", + "type": "extension", + "tier": "starlark", + "depends": ["llm-bridge"], + "contributes": { + "notes:toolbar-actions": { + "label": "AI Restructure", + "icon": "✨" + } + }, + "permissions": ["api.http"], + "settings": { + "restructure_prompt": { + "type": "string", + "label": "Restructure Prompt", + "description": "System prompt for AI note restructuring", + "default": "Restructure the following note into clear sections with headers. Preserve all information. Use markdown formatting.", + "user_overridable": true + } + } +} +``` + +**note-ai — script.star (api_route handler):** +```python +llm = lib.require("llm-bridge") + +def on_request(req): + if req["method"] == "POST" and req["path"] == "/restructure": + content = req["body"]["content"] + prompt = settings.get("restructure_prompt") + result = llm.complete([ + {"role": "system", "content": prompt}, + {"role": "user", "content": content}, + ]) + return {"status": 200, "body": {"restructured": result["text"]}} +``` + +**note-ai — js/index.js:** +```javascript +sw.slots.register('notes:toolbar-actions', { + id: 'note-ai:restructure', + priority: 500, + component: ({ noteId, getContent, setContent }) => { + const [loading, setLoading] = preact.useState(false); + + const restructure = async () => { + setLoading(true); + try { + const result = await sw.api.ext('note-ai').post('/restructure', { + content: getContent() + }); + setContent(result.restructured); + sw.toast('Note restructured', 'success'); + } catch (e) { + sw.toast('Restructure failed: ' + e.message, 'error'); + } finally { + setLoading(false); + } + }; + + return html``; + } +}); +``` + +The user configures their restructuring prompt in Settings. The button +appears in Notes toolbar. Clicking it sends the note content to the +api_route, which calls llm-bridge, which calls the configured LLM provider. +Four packages involved (notes, note-ai, llm-bridge, the LLM connection), +zero hardcoded dependencies between surfaces. + +--- + +## Kernel Changes + +### Modified Files + +| File | Change | +|------|--------| +| `sandbox/lib_module.go` | Replace type check with exports check (~1 line) | +| `handlers/extensions.go` | Parse `contributes` and `slots` manifest fields (validation) | +| `handlers/packages.go` | Uninstall warning for orphaned contributions | +| `src/js/sw/sdk/slots.js` | Add `renderAll(name, context)` and `declare(name, desc)` | +| `docs/PACKAGE-FORMAT.md` | Document `slots`, `contributes`, `exports` fields | +| `docs/EXTENSION-GUIDE.md` | Composability patterns section | + +### New Files + +| File | Purpose | +|------|---------| +| `handlers/admin_slots.go` | `GET /admin/slots` aggregation endpoint | + +### No New: + +- Database tables +- Migrations +- Starlark modules +- Permission constants +- Config env vars + +This is deliberately small. The runtime infrastructure exists. The +design adds manifest-level visibility, one backend guard relaxation, +and one SDK helper. The composability comes from conventions and +documentation, not kernel complexity. + +--- + +## Slot Catalog (Initial) + +These are the recommended slots for first-party packages. Extension +authors may define additional slots following the naming convention. + +| Slot | Host | Context | Use Case | +|------|------|---------|----------| +| `notes:toolbar-actions` | notes | noteId, getContent, setContent | Dictation, AI tools, formatting | +| `notes:note-footer` | notes | noteId, content | Related items, AI summary, metadata | +| `chat:composer-tools` | chat | conversationId, insertText | Image gen, file attach, slash commands | +| `chat:message-actions` | chat | messageId, content, attachments | Reactions, translate, bookmark | +| `chat:image-actions` | chat | imageUrl, messageId, metadata | Regen, edit, upscale, style transfer | +| `schedules:event-actions` | schedules | eventId, event | Add to calendar, share, convert to task | +| `admin:package-actions` | admin | packageId, package | Custom admin tools per package | + +Each host surface adds `sw.slots.renderAll()` calls at the appropriate +locations. Extensions contribute via `sw.slots.register()` in their JS. +The manifest `contributes` field makes the relationship visible to admins. + +--- + +## Future Considerations + +- **Slot schema validation.** Currently context contracts are documentation + only. A runtime assertion mode (dev only) could warn when a contributor + receives unexpected props. Deferred — convention is sufficient pre-1.0. + +- **Slot visibility controls.** An admin might want to disable a specific + contribution without disabling the entire contributing extension. A + per-contribution enable/disable toggle in the admin UI. Deferred — + extension enable/disable is the current granularity. + +- **Cross-surface slot contributions.** An extension contributing to + `notes:toolbar-actions` AND `chat:message-actions` with the same + underlying logic but different UI. The `contributes` field already + supports multiple entries. The extension's JS registers into both + slots with slot-appropriate components. + +- **Backend event subscriptions between extensions.** Currently extensions + react to kernel events via `hooks`. Extension-to-extension events + (e.g., "image-gen completed" → "chat refreshes") flow through the + existing event bus — the generating extension's api_route publishes + via `realtime.publish()`, the consuming surface listens via + `sw.realtime.subscribe()`. No new primitive needed. diff --git a/docs/DESIGN-storage-primitives.md b/docs/DESIGN-storage-primitives.md new file mode 100644 index 0000000..3c69dbf --- /dev/null +++ b/docs/DESIGN-storage-primitives.md @@ -0,0 +1,668 @@ +# DESIGN — Storage Primitives + +**Version:** v0.8.0 +**Status:** Draft +**Author:** Jeff / Claude session 2026-04-02 + +--- + +## Problem + +Extensions cannot access blob storage or managed disk paths. The existing +`ObjectStore` interface (PVC + S3 backends) is fully implemented but only +exposed to the admin status endpoint — no Starlark bridge exists. The `db` +module provides structured data storage via extension-scoped tables, but +there is no equivalent for binary files, no managed filesystem for tools +like `git`, and no mechanism for extensions to declare environment +requirements (pgvector, workspace root, S3) that the kernel validates at +install time. + +This blocks every future capability that depends on file handling: +RAG/vector search, LLM bridge, code workspaces, file upload/sharing, +media processing, and document indexing. + +--- + +## Non-Goals + +- Replacing the `db` module. Extension-scoped tables (`ext_{pkg}_{table}`) + are the structured data primitive and remain unchanged. +- Building a KV store. Extensions that need key-value semantics declare a + table with `key TEXT, value TEXT` columns — the infrastructure exists. +- Multi-tenant file isolation beyond package scoping. Team/user-level + file ACLs are extension-layer concerns built on top of these primitives. +- Streaming / chunked upload through Starlark. Large file ingestion goes + through HTTP routes; the `files` module handles storage after receipt. + +--- + +## Architecture + +Three new primitives plus one extension to the existing `db` module. + +### Primitive 1: `files` Starlark Module + +Bridges the existing `ObjectStore` into the sandbox. Follows the same +pattern as `db_module.go`: a `Build*Module` factory, permission-gated, +package-scoped key namespacing. + +**Permissions:** `files.read`, `files.write` + +**Starlark API:** + +```python +# Write a file. content is string (UTF-8) or bytes. +# metadata is an optional dict stored alongside (JSON-serialized). +files.put(name, content, content_type="application/octet-stream", metadata={}) + +# Read a file. Returns dict: {"content": , "content_type": "...", "size": N, "metadata": {...}} +# Returns None if not found. +result = files.get(name) + +# Read metadata only (no content transfer). Returns dict or None. +meta = files.meta(name) + +# List files by prefix. Returns list of dicts: [{"name": "...", "size": N, "content_type": "..."}] +entries = files.list(prefix="", limit=100) + +# Delete a file. Idempotent — no error if missing. +files.delete(name) + +# Delete all files under a prefix. Use with caution. +files.delete_prefix(prefix) + +# Check existence without reading. +exists = files.exists(name) +``` + +**Key namespacing:** + +All keys are automatically prefixed with `ext/{packageID}/`. An extension +calling `files.put("models/v1.bin", data)` writes to the ObjectStore key +`ext/my-extension/models/v1.bin`. Extensions cannot escape their namespace. + +Name validation rejects `..`, absolute paths, and control characters — +same sanitization rules as `physicalTable()` in `db_module.go`. + +**Implementation notes:** + +- New file: `sandbox/files_module.go` +- `FilesModuleConfig` struct mirrors `DBModuleConfig`: + ```go + type FilesModuleConfig struct { + PackageID string + CanWrite bool + Store storage.ObjectStore + } + ``` +- Metadata is stored as a companion JSON object at key + `ext/{packageID}/_meta/{name}`. This avoids schema changes — the + ObjectStore interface is unchanged. The `files` module manages the + companion transparently. +- Size limit per `files.put()` call: 50MB (configurable via + `EXT_FILES_MAX_SIZE`). Enforced in the builtin before calling + `Store.Put()`. +- `files.get()` returns content as `starlark.Bytes` for binary safety. + Starlark's `Bytes` type was added in go.starlark.net v0.0.0-20240725214946 + and handles non-UTF-8 content correctly. +- If `ObjectStore` is nil (storage not configured), the module is not + injected — same pattern as `db` module when `r.db == nil`. + +**Runner wiring:** + +```go +// In buildModulesWithLibCtx, add cases: +case models.ExtPermFilesRead: + if filesLevel < 1 { filesLevel = 1 } +case models.ExtPermFilesWrite: + filesLevel = 2 + +// After permission loop: +if filesLevel > 0 && r.objectStore != nil { + modules["files"] = BuildFilesModule(ctx, FilesModuleConfig{ + PackageID: packageID, + CanWrite: filesLevel == 2, + Store: r.objectStore, + }) +} +``` + +Runner gains `SetObjectStore(s storage.ObjectStore)` setter, called from +`main.go` after `storage.Init()`. + +--- + +### Primitive 2: `workspace` Starlark Module + +Managed disk directories for extensions that need a real filesystem — +git repos, compilers, ffmpeg, pandoc, code analysis tools. These tools +cannot operate through put/get blob semantics; they need paths. + +**Permission:** `workspace.manage` + +**Starlark API:** + +```python +# Create a named workspace directory. Returns the absolute path. +# Idempotent — returns existing path if already created. +path = workspace.create(name) + +# Get the path for an existing workspace. Returns string or None. +path = workspace.path(name) + +# List workspace names for this extension. +names = workspace.list() + +# Delete a workspace and all its contents. +workspace.delete(name) + +# Disk usage in bytes for a workspace. +size = workspace.usage(name) +``` + +**Directory layout:** + +``` +{WORKSPACE_ROOT}/ + {packageID}/ + {name}/ + ... (extension-managed contents) +``` + +`WORKSPACE_ROOT` defaults to `/data/workspaces` (configurable via +`WORKSPACE_ROOT` env var). The kernel creates the package subdirectory +on first `workspace.create()`. Extensions own everything below their +directory — the kernel does not inspect contents. + +**Implementation notes:** + +- New file: `sandbox/workspace_module.go` +- `WorkspaceModuleConfig`: + ```go + type WorkspaceModuleConfig struct { + PackageID string + WorkspaceRoot string + } + ``` +- Name validation: same rules as table names (`^[a-z][a-z0-9_]{0,62}$`). + No path separators, no `..`, no spaces. +- `workspace.create()` calls `os.MkdirAll` for the scoped path. +- `workspace.delete()` calls `os.RemoveAll` — destructive by design. + Extensions must handle confirmation in their own UX. +- `workspace.usage()` walks the directory tree and sums file sizes. + Bounded by a 10-second context timeout to prevent hangs on huge trees. +- **Quota enforcement** (optional): `WORKSPACE_QUOTA_MB` env var. When + set, `workspace.create()` checks cumulative usage for the package + before creating. Returns error if quota exceeded. Default: unlimited. +- If `WORKSPACE_ROOT` is empty or not writable, the module is not + injected. Extensions that declare `workspace.manage` without the + root configured will have their permission granted but the module + absent — same degradation pattern as `db` when no DB is available. + +**Runner wiring:** + +```go +case models.ExtPermWorkspaceManage: + if r.workspaceRoot != "" { + modules["workspace"] = BuildWorkspaceModule(ctx, WorkspaceModuleConfig{ + PackageID: packageID, + WorkspaceRoot: r.workspaceRoot, + }) + } +``` + +Runner gains `SetWorkspaceRoot(path string)` setter. + +**Security considerations:** + +- The returned path is an absolute filesystem path. Extensions can pass + this to `http` module calls (e.g., POST a file to an API) or use it + in `db` records as a reference. They cannot execute arbitrary binaries — + the Starlark sandbox has no `os.exec`. Execution requires a sidecar + tier package or an `api_route` handler that shells out server-side. +- For sidecar-tier packages that _can_ execute binaries, the workspace + path is the designated scratch space. The kernel ensures the path is + within the scoped directory via `filepath.Clean` + prefix check. + +--- + +### Primitive 3: Capability Negotiation + +Extensions declare environment requirements in their manifest. The kernel +validates these at install time and reports failures with actionable +messages. This is what makes progressive enhancement strategies (e.g., +three-tier vector search) work. + +**Manifest field:** + +```json +{ + "id": "vector-store", + "capabilities": { + "required": ["files.read", "files.write"], + "optional": ["pgvector"] + }, + "permissions": ["db.write", "files.write"], + "db_tables": { + "embeddings": { + "columns": { + "source_id": "text", + "chunk_text": "text", + "embedding": "vector(384)" + }, + "indexes": [["source_id"]] + } + } +} +``` + +`capabilities.required` — install fails if any are missing. Admin gets +a clear error: "Package 'vector-store' requires capability 'pgvector' +which is not available. Run `CREATE EXTENSION vector;` in your PostgreSQL +database to enable it." + +`capabilities.optional` — install succeeds regardless. The capability +state is queryable at runtime so extensions can degrade gracefully. + +**Runtime query (Starlark):** + +Settings module is the natural home since it's always available: + +```python +# Returns True/False for a capability name. +has_pgvector = settings.has_capability("pgvector") +``` + +**Kernel capability registry:** + +A simple function in the `handlers` package that probes the environment: + +```go +func DetectCapabilities(db *sql.DB, isPostgres bool, workspaceRoot string, objStore storage.ObjectStore) map[string]bool { + caps := make(map[string]bool) + + // pgvector: check pg_extension + if isPostgres && db != nil { + var exists bool + row := db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname='vector')") + if row.Scan(&exists) == nil && exists { + caps["pgvector"] = true + } + } + + // workspace: check root is writable + if workspaceRoot != "" && storage.IsPathWritable(workspaceRoot) { + caps["workspace"] = true + } + + // object storage: check configured and healthy + if objStore != nil { + if err := objStore.Healthy(context.Background()); err == nil { + caps["object_storage"] = true + } + } + + // s3: specific backend check + if objStore != nil && objStore.Backend() == "s3" { + caps["s3"] = true + } + + // postgres: dialect check + if isPostgres { + caps["postgres"] = true + } + + return caps +} +``` + +Called once at startup, stored on the `Runner` (or a shared config +struct). Re-probed on admin request for the capabilities endpoint. + +**Install-time validation:** + +In the package install handler (`handlers/extensions.go`), after +`ParseDBTables` and before `SyncManifestPermissions`: + +```go +if caps, ok := ParseCapabilities(manifestMap); ok { + missing := CheckRequiredCapabilities(caps.Required, detectedCaps) + if len(missing) > 0 { + // Roll back: delete the just-created package row + h.stores.Packages.Delete(c.Request.Context(), pkg.ID) + c.JSON(422, gin.H{ + "error": "missing required capabilities", + "missing": missing, + "help": capabilityHelpText(missing), + }) + return + } +} +``` + +**Admin API:** + +``` +GET /api/v1/admin/capabilities +→ {"pgvector": true, "workspace": true, "object_storage": true, "s3": false, "postgres": true} +``` + +Displayed in the Admin UI alongside storage status. Gives operators +visibility into what their deployment supports. + +--- + +### Extension to `db` Module: Vector Column Type + +The existing `mapColType()` in `ext_db_schema.go` gains a `vector` type +with progressive enhancement across backends. + +**Manifest declaration:** + +```json +"db_tables": { + "embeddings": { + "columns": { + "embedding": "vector(384)" + } + } +} +``` + +**Column type mapping:** + +| Manifest type | PG + pgvector | PG without pgvector | SQLite | +|------------------|-------------------------|---------------------|--------------| +| `vector(N)` | `vector(N)` | `JSONB` | `TEXT` | + +Implementation in `mapColType`: + +```go +case "vector": + // vector or vector(384) — extract dimension if present + dim := extractVectorDim(typStr) // returns "384" or "" + if isPostgres && hasPgVector { + if dim != "" { + return fmt.Sprintf("vector(%s)", dim) + } + return "vector" + } + if isPostgres { + return "JSONB" // store as JSON array, brute-force search + } + return "TEXT" // SQLite: JSON array as text +``` + +`hasPgVector` is passed through a new field on a `SchemaConfig` struct +(or detected inline — the capability registry result is available at +table creation time). + +**New `db` module function — `db.query_similar()`:** + +```python +# Find rows with embeddings closest to the query vector. +# Returns list of row dicts with _distance appended. +results = db.query_similar( + table="embeddings", + column="embedding", + vector=[0.1, 0.2, ...], # query vector (list of floats) + limit=10, + filters={"source_id": "doc-123"} # optional WHERE clause +) +``` + +**Backend dispatch:** + +- **PG + pgvector:** Uses `ORDER BY embedding <=> $1 LIMIT $2` with + native vector distance operator. +- **PG without pgvector / SQLite:** Loads candidate rows (respecting + `filters`), deserializes JSON arrays, computes cosine similarity in + Go, sorts, returns top-N. This is the "it works but slowly" fallback — + acceptable for small corpora (<10k rows), documented as such. + +Implementation: new function `dbQuerySimilar()` in `db_module.go`, +gated on `db.read` permission (read-only operation). The function +checks `cfg.HasPgVector` to choose the fast or slow path. + +`DBModuleConfig` gains: + +```go +type DBModuleConfig struct { + PackageID string + CanWrite bool + DB *sql.DB + IsPostgres bool + HasPgVector bool // NEW: enables native vector ops +} +``` + +Wired from the capability registry at module build time. + +--- + +## New Permission Constants + +```go +// In models/models_extension_perm.go: +const ( + ExtPermFilesRead = "files.read" + ExtPermFilesWrite = "files.write" + ExtPermWorkspaceManage = "workspace.manage" +) +``` + +Added to `ValidExtensionPermissions` map. + +--- + +## Schema Changes + +**Migration 015 — none required for kernel tables.** + +The `files` module uses the existing `ObjectStore` interface — no new +kernel tables. Metadata companions are stored as ObjectStore objects. + +The `workspace` module uses the filesystem — no tables. + +Capabilities are detected at runtime — no tables. + +The `vector` column type is handled by extension DDL generation in +`ext_db_schema.go` — no kernel migration. + +The new permission constants are code-only changes. + +--- + +## New Files + +| File | Purpose | +|------|---------| +| `sandbox/files_module.go` | `files` Starlark module | +| `sandbox/files_module_test.go` | Tests using mock ObjectStore | +| `sandbox/workspace_module.go` | `workspace` Starlark module | +| `sandbox/workspace_module_test.go` | Tests using temp directory | +| `handlers/capabilities.go` | `DetectCapabilities()`, `ParseCapabilities()`, admin endpoint | +| `handlers/capabilities_test.go` | Tests for capability detection and validation | + +## Modified Files + +| File | Change | +|------|--------| +| `models/models_extension_perm.go` | Add `files.read`, `files.write`, `workspace.manage` | +| `sandbox/runner.go` | Add `SetObjectStore()`, `SetWorkspaceRoot()`, wire new modules | +| `sandbox/db_module.go` | Add `dbQuerySimilar()`, `HasPgVector` field | +| `handlers/ext_db_schema.go` | Extend `mapColType()` for `vector(N)`, pass `hasPgVector` | +| `handlers/extensions.go` | Add capability validation in install handler | +| `sandbox/settings_module.go` | Add `settings.has_capability()` | +| `server/main.go` | Call `DetectCapabilities()`, wire to runner | +| `docs/PACKAGE-FORMAT.md` | Document `capabilities` manifest field | +| `docs/EXTENSION-GUIDE.md` | Document new modules and vector column type | + +--- + +## Changeset Plan + +**CS1 — `files` module + permissions** + +New files: `files_module.go`, `files_module_test.go`. +Modified: `models_extension_perm.go`, `runner.go`, `main.go`. +Scope: ObjectStore bridge, permission constants, runner wiring. +CI-green independently — no schema changes, no existing behavior affected. + +**CS2 — `workspace` module** + +New files: `workspace_module.go`, `workspace_module_test.go`. +Modified: `models_extension_perm.go`, `runner.go`, `main.go`. +Scope: Managed disk directories, quota enforcement. +CI-green independently. + +**CS3 — Capability negotiation** + +New files: `capabilities.go`, `capabilities_test.go`. +Modified: `extensions.go` (install validation), `settings_module.go` +(`has_capability`), `main.go`. +Scope: Detection, install-time validation, runtime query, admin endpoint. +CI-green independently. + +**CS4 — Vector column type + `db.query_similar()`** + +Modified: `ext_db_schema.go`, `db_module.go`, `db_module_test.go`. +Scope: Column type mapping, similarity query with dual-path dispatch. +Depends on CS3 (needs `HasPgVector` from capability registry). + +--- + +## Configuration Summary + +| Env Var | Default | Purpose | +|---------|---------|---------| +| `WORKSPACE_ROOT` | `/data/workspaces` | Root directory for workspace module | +| `WORKSPACE_QUOTA_MB` | `0` (unlimited) | Per-extension disk quota | +| `EXT_FILES_MAX_SIZE` | `52428800` (50MB) | Max single file size via `files.put()` | + +--- + +## Example: Vector Store Extension + +A `vector-store` library extension consuming all four primitives: + +**manifest.json:** +```json +{ + "id": "vector-store", + "title": "Vector Store", + "type": "library", + "tier": "starlark", + "version": "0.1.0", + "permissions": ["db.write", "files.read", "connections.read"], + "capabilities": { + "required": [], + "optional": ["pgvector"] + }, + "db_tables": { + "documents": { + "columns": { + "source": "text", + "chunk_text": "text", + "page": "int", + "metadata": "text" + }, + "indexes": [["source"]] + }, + "embeddings": { + "columns": { + "document_id": "text", + "embedding": "vector(384)", + "chunk_index": "int" + }, + "indexes": [["document_id"]] + } + }, + "exports": ["ingest", "search", "search_clustered"] +} +``` + +**script.star:** +```python +def ingest(source_name, chunks): + """Store document chunks and generate embeddings.""" + for i, chunk in enumerate(chunks): + row = db.insert("documents", { + "source": source_name, + "chunk_text": chunk["text"], + "page": chunk.get("page", 0), + "metadata": json.encode(chunk.get("metadata", {})), + }) + # Embedding generation delegated to caller (llm-bridge) + # Caller passes pre-computed vectors + if "embedding" in chunk: + db.insert("embeddings", { + "document_id": row["id"], + "embedding": json.encode(chunk["embedding"]), + "chunk_index": i, + }) + +def search(query_vector, limit=10, source_filter=None): + """Semantic similarity search — dispatches to native or brute-force.""" + filters = {} + if source_filter: + filters["source"] = source_filter + # query_similar handles pgvector vs fallback transparently + results = db.query_similar( + table="embeddings", + column="embedding", + vector=query_vector, + limit=limit, + ) + # Hydrate with document text + enriched = [] + for r in results: + docs = db.query("documents", filters={"id": r["document_id"]}, limit=1) + if docs: + enriched.append({ + "text": docs[0]["chunk_text"], + "source": docs[0]["source"], + "page": docs[0]["page"], + "distance": r["_distance"], + }) + return enriched + +def search_clustered(embeddings_list, k): + """K-means clustering for query-free thematic selection. + Returns k representative chunks. Clustering runs in the + extension — the kernel provides the data, not the algorithm.""" + # This would be implemented by a consumer extension with + # http access to call a clustering API, or by a sidecar + # that runs sklearn. The vector-store library provides + # the data access pattern; clustering logic lives elsewhere. + pass +``` + +--- + +## Future Considerations + +- **Content-addressed deduplication.** If multiple extensions store the + same PDF, the ObjectStore holds duplicate bytes. A SHA256-keyed blob + layer with refcounting would deduplicate transparently. Deferred — + adds complexity without clear need pre-1.0. + +- **Extension-to-extension file sharing.** The current design isolates + file namespaces per extension. A `files.grant(name, target_pkg_id)` + primitive could enable controlled sharing. Deferred — composition + through API routes is sufficient initially. + +- **Streaming upload for large files.** Starlark `files.put()` buffers + in memory. For files >50MB, extensions should use HTTP `api_routes` + with Go handlers that stream directly to ObjectStore. The `files` + module is for extension-internal storage, not user-facing upload. + +- **Workspace snapshots.** `workspace.snapshot(name)` → creates a + tarball in the `files` store. Useful for backup/reproducibility. + Deferred — extensions can implement this themselves. + +- **Rate limiting / quota on `db.query_similar()` fallback.** The + brute-force path loads all candidate rows into Go memory. For large + tables this is dangerous. A row-count guard (e.g., refuse if >50k + candidates) with a clear error message pointing to pgvector is the + right safety valve.