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) <noreply@anthropic.com>
This commit is contained in:
232
ROADMAP.md
232
ROADMAP.md
@@ -1,14 +1,15 @@
|
|||||||
# Armature — Roadmap
|
# 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,
|
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||||
|
is an extension.
|
||||||
|
|
||||||
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
|
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
|
||||||
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
|
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
|
||||||
Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub ·
|
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`.
|
**Completed history:** v0.2.x–v0.5.x fully documented in `CHANGELOG.md`.
|
||||||
Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization,
|
Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization,
|
||||||
@@ -184,22 +185,212 @@ same pattern as the kernel surface migrations.
|
|||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Playwright test harness | | `ci/e2e-surface-test.sh` — docker-compose, chromium, run-all, assert. |
|
| CI gating redesign | | Fix path rules: `VERSION`/`scripts/*` deploy-only, `docs/*` no longer skips deploy, test-runners trigger on any code change. |
|
||||||
| Screenshot-on-failure | | Full-page screenshot + console log. CI artifacts. |
|
| 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. |
|
||||||
| Navigation smoke test | | Playwright visits every surface. Asserts topbar, no JS errors, home link works. |
|
| Screenshot-on-failure | | Full-page screenshot + console log saved as CI artifacts. |
|
||||||
| Visual regression baseline | | Optional screenshot diff. Not a gate — report for review. |
|
| CI pipeline integration | | Re-enable `test-runners` stage, add `e2e-smoke` stage. Failure blocks merge. |
|
||||||
| CI pipeline integration | | 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)
|
### v0.8.x — Storage Primitives
|
||||||
- **Rich media extensions:** image generation, code sandbox, STT/TTS
|
|
||||||
- **Desktop app** (Tauri or Electron)
|
Design doc: `docs/DESIGN-storage-primitives.md`
|
||||||
- **Sidecar tier:** container-based extensions
|
|
||||||
- **Federation:** cross-instance package sharing
|
The last major kernel expansion. Completes the primitive set that the
|
||||||
- **Plugin marketplace** with signing and review
|
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. |
|
| 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. |
|
| Surface runners over expanding ICD | Different test tier, different failure class. |
|
||||||
| Headless E2E via Playwright | Runners produce structured JSON; Playwright navigates and reads output. |
|
| 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. |
|
||||||
|
|||||||
687
docs/DESIGN-extension-composability.md
Normal file
687
docs/DESIGN-extension-composability.md
Normal file
@@ -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<VNode>} — 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`<div class="notes-toolbar">
|
||||||
|
<button onclick=${save}>Save</button>
|
||||||
|
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||||
|
noteId: currentNote.id,
|
||||||
|
getContent: () => editor.getValue(),
|
||||||
|
setContent: (text) => editor.setValue(text)
|
||||||
|
})}
|
||||||
|
</div>`
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `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`<button class="sw-btn sw-btn--ghost"
|
||||||
|
onclick=${toggle}
|
||||||
|
title="Dictate">
|
||||||
|
${recording ? '⏹' : '🎤'}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**notes (surface) — toolbar rendering:**
|
||||||
|
```javascript
|
||||||
|
html`<div class="notes-toolbar__actions">
|
||||||
|
<button onclick=${() => saveNote()}>💾</button>
|
||||||
|
<button onclick=${() => togglePin()}>📌</button>
|
||||||
|
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||||
|
noteId: note.id,
|
||||||
|
getContent: () => editor.getValue(),
|
||||||
|
setContent: (text) => editor.setValue(text),
|
||||||
|
})}
|
||||||
|
</div>`
|
||||||
|
```
|
||||||
|
|
||||||
|
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`<div class="chat-image">
|
||||||
|
<img src=${src} />
|
||||||
|
<div class="chat-image__actions">
|
||||||
|
${sw.slots.renderAll('chat:image-actions', {
|
||||||
|
imageUrl: src,
|
||||||
|
messageId,
|
||||||
|
metadata,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`<button class="sw-btn sw-btn--sm" onclick=${regen} title="Regenerate">
|
||||||
|
🔄
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`<button class="sw-btn sw-btn--sm" onclick=${openEditor} title="Edit">
|
||||||
|
✏️
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**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`<button class="sw-btn sw-btn--sm" onclick=${upscale} title="Upscale 2×">
|
||||||
|
🔍
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
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`<button class="sw-btn sw-btn--ghost"
|
||||||
|
onclick=${restructure}
|
||||||
|
disabled=${loading}
|
||||||
|
title="AI Restructure">
|
||||||
|
${loading ? '⏳' : '✨'}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
668
docs/DESIGN-storage-primitives.md
Normal file
668
docs/DESIGN-storage-primitives.md
Normal file
@@ -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": <bytes>, "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.
|
||||||
Reference in New Issue
Block a user